Implement pixel-perfect legal search interface #53
No files matched your search
@@ -5,6 +5,7 @@ credentials.json
|
||||
__pycache__/
|
||||
.venv/
|
||||
*.py[cod]
|
||||
tools/frontend-visual-review/node_modules/
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Frontend `0.2.3`: добавлен публичный поиск по правовой базе с фильтрами,
|
||||
адаптивной выдачей, просмотром редакций и поиском по тексту документа;
|
||||
внутренняя лаборатория сохранена, её подсказки стали компактнее и привязаны
|
||||
к целевым элементам. Интерфейс использует локальные шрифты Noto Sans и Noto
|
||||
Serif.
|
||||
- Исправлены переключение на доступный язык редакции, очистка содержимого и
|
||||
ссылки на официальный источник при смене документа или ошибке загрузки;
|
||||
подписи фильтров локализуются на кыргызский язык.
|
||||
- Infrastructure: systemd unit Gitea Actions runner теперь использует
|
||||
`ProtectHome=read-only`, сохраняя доступность `/run/user` и существующий
|
||||
writable carve-out для rootless runtime; VPS не изменялся.
|
||||
|
||||
@@ -86,6 +86,15 @@ spacing:
|
||||
генеративного изображения. Название продукта — **Акылдаш**; «УКУК» из
|
||||
ранних концепций не используется.
|
||||
|
||||
Изображение определяет целевую композицию, пропорции панелей и расположение
|
||||
элементов поискового экрана. Эта спецификация определяет семантические токены,
|
||||
шрифты, доступность и правила поведения. Расхождения между ними фиксируются
|
||||
до реализации; утверждённые токены не заменяются цветами, взятыми пипеткой
|
||||
из растрового изображения. Изменения постоянных правил вносятся сюда явно.
|
||||
Этапы работ, исходные измерения и критерии визуальной приёмки находятся в
|
||||
[плане точного воспроизведения поискового интерфейса](docs/product/frontend-pixel-perfect-plan.md).
|
||||
План не является второй дизайн-системой и не означает завершение реализации.
|
||||
|
||||
**Key Characteristics:**
|
||||
|
||||
- Спокойное чтение и компактные, понятные элементы управления.
|
||||
@@ -162,6 +171,34 @@ spacing:
|
||||
Разворачивание документа и поиск внутри него реализуются только вместе
|
||||
с работающим поведением, а не как декоративные кнопки.
|
||||
|
||||
### Композиция поискового экрана
|
||||
|
||||
- Первая строка desktop: знак и название, разделитель и подпись продукта
|
||||
слева; подписанное поле поиска с очисткой и кнопкой «Найти» в центре;
|
||||
переключатель языка интерфейса справа. Служебные действия авторизованного
|
||||
пользователя не вытесняют поиск и не меняют эту иерархию.
|
||||
- Вторая строка: навигация «Поиск» / «Об источниках» и удаляемые активные
|
||||
фильтры. Сведения о демонстрационном режиме показываются только в нём.
|
||||
- Ниже расположены три смежные панели: фильтры, результаты, документ.
|
||||
Иерархия создаётся фонами и непрерывными тонкими разделителями, без
|
||||
промежутков между самостоятельными карточками панелей.
|
||||
- Фильтры имеют компактные группы, текстовое действие сброса, выбор органа
|
||||
и пару полей даты в одной строке при достаточной ширине. Вид элемента
|
||||
выбора соответствует одиночному или множественному контракту API.
|
||||
- Заголовок результатов и счётчик находятся слева, сортировка справа.
|
||||
Высота строк зависит от содержимого; едины отступы и типографика.
|
||||
Выбранный результат имеет мягкий красный фон, тонкую красную рамку
|
||||
и текстовый признак открытия.
|
||||
- Панель документа начинается с контекстной навигации и разворачивания,
|
||||
затем идут название, компактные метаданные, действия, поиск внутри
|
||||
документа и единое поле чтения. Название документа имеет отдельную
|
||||
типографическую роль, отличную от заголовков интерфейсных панелей.
|
||||
- При коротком содержимом footer расположен у нижней границы окна;
|
||||
длинное содержимое доступно прокруткой без перекрытия текста и управления.
|
||||
Бренд находится слева, фактические версии frontend и backend справа.
|
||||
- Поисковая страница не показывает инструменты оценивания лаборатории.
|
||||
Общие стили и ресурсы не смешивают композицию этих двух сценариев.
|
||||
|
||||
Внутренняя лаборатория сохраняет свой двухпанельный сценарий: фактический
|
||||
порядок результатов и оценивание слева, текст выбранной редакции справа.
|
||||
Она не получает фильтры, аналитику или публичную навигацию исключительно
|
||||
@@ -176,8 +213,9 @@ spacing:
|
||||
горизонтальной прокрутки; широкие таблицы имеют отдельную обозначенную
|
||||
область прокрутки.
|
||||
|
||||
Проверочные размеры при реализации: **1440, 1024 и 390 px**. Это размеры
|
||||
проверки, а не заранее навязанные CSS breakpoints.
|
||||
Проверочные размеры при реализации: **1280×800, 1440, 1024 и 390 px**.
|
||||
1280×800 — основной размер сравнения поискового экрана с референсом.
|
||||
Это размеры проверки, а не заранее навязанные CSS breakpoints.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
@@ -204,6 +242,13 @@ spacing:
|
||||
помечается `aria-hidden`, не находится под текстом и не мешает нажатию.
|
||||
На телефоне орнамент сокращается либо убирается.
|
||||
|
||||
Поисковый desktop включает небольшой золотистый орнамент в свободной нижней
|
||||
части панели фильтров. Он не перекрывает длинные списки и не резервирует
|
||||
место ценой недоступности управления. Знак бренда и орнамент поставляются
|
||||
как SVG-ресурсы. Favicon использует тот же знак, адаптированный для малых
|
||||
размеров; проверяется читаемость 16×16 и 32×32. Иконки управления имеют
|
||||
согласованные толщину линий, оптический размер и выравнивание.
|
||||
|
||||
## Components
|
||||
|
||||
Это требования к будущей реализации, а не перечень уже обновлённых компонентов.
|
||||
|
||||
@@ -10,7 +10,8 @@ Telegram-бот — только часть рабочего окружения
|
||||
## Текущее состояние
|
||||
|
||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2`, backend версии
|
||||
`0.12.2` и внутренний frontend лаборатории `0.1.2`. Авторизация хранится в
|
||||
`0.13.0` и frontend `0.2.3` с поисковым экраном и внутренней лабораторией.
|
||||
Авторизация хранится в
|
||||
SQLite; первый пользователь `admin` создаётся интерактивной CLI-командой.
|
||||
Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
|
||||
действующий Налоговый кодекс для запросов о налоге на прибыль.
|
||||
@@ -18,8 +19,8 @@ SQLite; первый пользователь `admin` создаётся инт
|
||||
| Компонент | Версия | Состояние |
|
||||
|---|---:|---|
|
||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||
| Backend | `0.12.2` | авторизация, поиск с фильтрами и ограниченный staging текущего API Минюста |
|
||||
| Frontend | `0.1.2` | лаборатория поиска, личный кабинет и управление учетными записями |
|
||||
| Backend | `0.13.0` | авторизация, поиск с фильтрами, ограниченный staging и статические ресурсы |
|
||||
| Frontend | `0.2.3` | поиск по правовой базе, просмотр редакций и внутренняя лаборатория |
|
||||
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
|
||||
| RAG и база знаний | — | ещё не созданы |
|
||||
|
||||
@@ -62,4 +63,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.0 · Frontend v0.2.3
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backend Акылдаш
|
||||
|
||||
Версия: `0.12.2`
|
||||
Версия: `0.13.0`
|
||||
|
||||
Первая backend-область проекта — загрузка правовых документов из официального
|
||||
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
||||
@@ -296,4 +296,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Backend v0.12.2 · Frontend v0.1.2
|
||||
Акылдаш · Backend v0.13.0 · Frontend v0.2.3
|
||||
@@ -23,7 +23,7 @@ from typing import Callable
|
||||
from ingestion.minjust_cbd import atomic_write, connect_manifest, json_bytes
|
||||
|
||||
API_ROOT = "https://cbd.minjust.gov.kg/api/v1"
|
||||
APP_VERSION = "0.12.2"
|
||||
APP_VERSION = "0.13.0"
|
||||
REQUEST_CONTRACT = {
|
||||
"version": 2,
|
||||
"endpoint": "/GetEdition",
|
||||
|
||||
@@ -26,6 +26,16 @@ LANGUAGES = {"ru", "ky"}
|
||||
CODE = re.compile(r"^[0-9]+$")
|
||||
MAX_PAGE_SIZE = 100
|
||||
MAX_RESULT_WINDOW = 10_000
|
||||
STATIC_ASSETS = {
|
||||
"/static/favicon.svg": ("favicon.svg", "image/svg+xml"),
|
||||
"/static/favicon-32.png": ("favicon-32.png", "image/png"),
|
||||
"/static/fonts/noto-sans-cyrillic-ext.woff2": ("fonts/noto-sans-cyrillic-ext.woff2", "font/woff2"),
|
||||
"/static/fonts/noto-sans-cyrillic.woff2": ("fonts/noto-sans-cyrillic.woff2", "font/woff2"),
|
||||
"/static/fonts/noto-sans-latin.woff2": ("fonts/noto-sans-latin.woff2", "font/woff2"),
|
||||
"/static/fonts/noto-serif-cyrillic-ext.woff2": ("fonts/noto-serif-cyrillic-ext.woff2", "font/woff2"),
|
||||
"/static/fonts/noto-serif-cyrillic.woff2": ("fonts/noto-serif-cyrillic.woff2", "font/woff2"),
|
||||
"/static/fonts/noto-serif-latin.woff2": ("fonts/noto-serif-latin.woff2", "font/woff2"),
|
||||
}
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
@@ -303,6 +313,17 @@ class Api:
|
||||
except (OSError, UnicodeError) as error:
|
||||
raise ApiError(500, "review interface is unavailable") from error
|
||||
|
||||
@staticmethod
|
||||
def static_asset(route: str) -> tuple[str, bytes]:
|
||||
asset = STATIC_ASSETS.get(route)
|
||||
if asset is None:
|
||||
raise ApiError(404, "static asset not found")
|
||||
path = Path(__file__).with_name("static") / asset[0]
|
||||
try:
|
||||
return asset[1], path.read_bytes()
|
||||
except OSError as error:
|
||||
raise ApiError(500, "static asset is unavailable") from error
|
||||
|
||||
def handle(self, method: str, path: str, body: dict | None = None, user: dict | None = None) -> tuple[int, dict]:
|
||||
parsed = urllib.parse.urlsplit(path)
|
||||
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
|
||||
@@ -386,6 +407,18 @@ def handler(api: Api):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if method == "GET" and route in STATIC_ASSETS:
|
||||
content_type, body = api.static_asset(route)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Cache-Control", "public, max-age=604800, immutable")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if not (method == "POST" and route in {"/auth/login", "/auth/logout"}):
|
||||
if not user:
|
||||
raise ApiError(401, "authentication required")
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Iterator
|
||||
|
||||
from search.catalog import authority_codes, legal_force_code, source_code
|
||||
|
||||
APP_VERSION = "0.12.2"
|
||||
APP_VERSION = "0.13.0"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
|
||||
|
||||
|
||||
@@ -3,8 +3,17 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#F7F2E8">
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png">
|
||||
<title>Оценка поисковой выдачи · Акылдаш</title>
|
||||
<style>
|
||||
@font-face { font-family: "Noto Sans"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("/static/fonts/noto-sans-cyrillic-ext.woff2") format("woff2"); unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; }
|
||||
@font-face { font-family: "Noto Sans"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("/static/fonts/noto-sans-cyrillic.woff2") format("woff2"); unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; }
|
||||
@font-face { font-family: "Noto Sans"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("/static/fonts/noto-sans-latin.woff2") format("woff2"); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
|
||||
@font-face { font-family: "Noto Serif"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("/static/fonts/noto-serif-cyrillic-ext.woff2") format("woff2"); unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; }
|
||||
@font-face { font-family: "Noto Serif"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("/static/fonts/noto-serif-cyrillic.woff2") format("woff2"); unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; }
|
||||
@font-face { font-family: "Noto Serif"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("/static/fonts/noto-serif-latin.woff2") format("woff2"); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
|
||||
:root { color-scheme: light; font: 16px/1.5 "Noto Sans", sans-serif; color: #38262A; background: #F7F2E8; --ink: #38262A; --muted: #786469; --line: #DED3CE; --surface: #fff; --surface-muted: #FBF8F4; --brand: #9D2633; --brand-dark: #84202B; --accent: #A37B32; --focus: #245C78; --subtle: #F8EBED; --serif: "Noto Serif", serif; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; color: var(--ink); background: #F7F2E8; }
|
||||
@@ -65,14 +74,15 @@
|
||||
.review__empty { padding: 3rem 1rem; color: var(--muted); text-align: center; }
|
||||
.review__empty strong { display: block; margin-block-end: .35rem; color: var(--ink); }
|
||||
.review__tour-spotlight { position: fixed; z-index: 1; pointer-events: none; border: 4px solid var(--accent); border-radius: 6px; box-shadow: 0 0 0 100vmax rgb(56 38 42 / .58), 0 0 0 8px rgb(163 123 50 / .25); }
|
||||
.review__tour { width: min(34rem, calc(100% - 2rem)); padding: .5rem; color: var(--ink); }
|
||||
.review__tour { position: fixed; inset: auto; width: min(20rem, calc(100vw - 1.5rem)); max-width: none; max-height: calc(100dvh - 1.5rem); margin: 0; overflow: auto; padding: 0; color: var(--ink); font: inherit; background: rgb(255 255 255 / .92); border: 1px solid rgb(222 211 206 / .9); border-radius: 8px; box-shadow: 0 .75rem 2rem rgb(56 38 42 / .18); backdrop-filter: blur(12px); }
|
||||
.review__tour::backdrop { background: transparent; }
|
||||
.review__tour-content { padding: clamp(1.5rem, 4vw, 2rem); }
|
||||
.review__tour h2 { margin: 0 0 .65rem; font-size: clamp(1.5rem, 3vw, 2rem); line-height: 1.2; letter-spacing: -.02em; }
|
||||
.review__tour p { margin: 0; color: var(--muted); }
|
||||
.review__tour-footer { display: flex; justify-content: space-between; gap: .75rem; align-items: center; margin-block-start: 1.5rem; }
|
||||
.review__tour-content { padding: 1rem; }
|
||||
.review__tour h2 { margin: 0 0 .4rem; font: 600 1.125rem/1.3 var(--serif); }
|
||||
.review__tour p { margin: 0; color: var(--muted); font-size: .9rem; }
|
||||
.review__tour-footer { display: flex; justify-content: space-between; gap: .75rem; align-items: center; margin-block-start: 1rem; }
|
||||
.review__tour-progress { color: var(--muted); font-size: .8125rem; }
|
||||
.review__tour-actions { display: flex; gap: .5rem; }
|
||||
.review__tour-actions button { min-height: 38px; padding: .4rem .7rem; font-size: .875rem; }
|
||||
.review__nav { display: flex; align-items: center; justify-content: space-between; gap: 1rem; max-width: 1440px; margin: auto; padding: .75rem clamp(1rem, 3vw, 3rem); border-block-end: 1px solid var(--line); }
|
||||
.review__nav-links, .review__nav-account { display: flex; align-items: center; gap: .5rem; }
|
||||
.review__nav button, .review__nav a { min-height: 44px; }
|
||||
@@ -138,15 +148,214 @@
|
||||
@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; } }
|
||||
: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; }
|
||||
body:has(.search:not([hidden])) > footer.review__header { display: none; }
|
||||
body:has(.search:not([hidden])) > .review__actions { display: none; }
|
||||
body.review__locked .search__chrome { visibility: hidden; }
|
||||
body:has(.search:not([hidden])) > .search__chrome { display: block; }
|
||||
.search__chrome { display: none; background: var(--surface); border-block-end: 1px solid var(--line); }
|
||||
.search[hidden] { display: none !important; }
|
||||
.search__topbar { min-height: var(--search-header-height); display: grid; grid-template-columns: minmax(390px, 430px) minmax(0, 1fr) auto; gap: 16px; align-items: center; padding: 5px 26px; border-block-end: 1px solid var(--line); }
|
||||
.search__brand { display: flex; min-width: 0; align-items: center; gap: 12px; color: var(--ink); text-decoration: none; white-space: nowrap; }
|
||||
.search__brand-mark { width: 44px; height: 44px; flex: none; color: var(--brand); }
|
||||
.search__brand-name { font: 700 26px/1.1 var(--serif); }
|
||||
.search__brand-divider { width: 1px; height: 32px; background: var(--line); }
|
||||
.search__brand-description { color: var(--muted); font-size: 14px; }
|
||||
.search__query { display: grid; grid-template-columns: minmax(0, 1fr) 160px; gap: 12px; align-items: end; min-width: 0; padding: 0; border: 0; background: transparent; }
|
||||
.search__query label { display: grid; gap: 4px; color: var(--muted); font-size: 14px; font-weight: 500; }
|
||||
.search__query label > span { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.search__query-input { position: relative; }
|
||||
.search__query input { height: 44px; padding-inline: 42px; }
|
||||
.search__query-icon { position: absolute; inset-inline-start: 12px; inset-block-start: 11px; width: 22px; height: 22px; color: var(--ink); pointer-events: none; }
|
||||
.search__query-clear { position: absolute; inset-inline-end: 4px; inset-block-start: 3px; width: 38px; height: 38px; min-height: 38px; padding: 0; border: 0; color: var(--muted); }
|
||||
.search__query .review__button--primary { height: 44px; }
|
||||
.search__ui-language { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.search__ui-language button { min-width: 36px; min-height: 40px; border: 0; border-radius: 0; padding: 0 4px; color: var(--muted); }
|
||||
.search__ui-language button[aria-pressed="true"] { color: var(--ink); border-block-end: 2px solid var(--brand); }
|
||||
.search__account { display: flex; align-items: center; gap: 6px; margin-inline-start: auto; }
|
||||
.search__account button { width: 40px; min-height: 40px; padding: 8px; }
|
||||
.search__account-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block-end: 16px; }
|
||||
.search__subnav { min-height: var(--search-nav-height); display: flex; align-items: center; gap: 12px; padding: 6px 26px; overflow-x: auto; white-space: nowrap; }
|
||||
.search__subnav a, .search__subnav button { min-height: 36px; padding: 5px 14px; text-decoration: none; }
|
||||
.search__mobile-filters, .search__mobile-back, .search__filter-close, .search__filter-overlay { display: none; }
|
||||
.search__subnav a.is-active { min-width: 97px; background: var(--brand); color: #fff; border-color: var(--brand); }
|
||||
.search__subnav .search__nav-text { margin-inline: 11px 28px; padding-inline: 0; border: 0; color: var(--ink); font-size: 13px; }
|
||||
.search__chips { display: flex; flex: 0 1 auto; gap: 8px; align-items: center; min-width: 0; overflow-x: auto; margin-inline-start: -4px; }
|
||||
.search__chip { display: inline-flex; align-items: center; gap: 8px; min-height: 34px; border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; font-size: 14px; }
|
||||
.search__chips .search__chip { padding-inline: 4px; }
|
||||
.search__chips .search__chip button { width: 16px; }
|
||||
.search__language-chip { padding-inline: 4px; }
|
||||
.search__language-chip select { width: 78px; min-width: 0; min-height: 24px; border: 0; padding: 0 12px 0 0; background: transparent; font-size: 14px; }
|
||||
html[lang="ky"] .search__language-chip select { width: 100px; }
|
||||
.search__chip button { min-height: 24px; width: 24px; padding: 0; border: 0; font-size: 18px; line-height: 1; }
|
||||
.search { width: 100%; max-width: none; min-height: 0; flex: 1; display: flex; flex-direction: column; margin: 0; }
|
||||
.search__sr-only { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.search__status { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.search__layout { flex: 1; min-height: 0; grid-template-columns: minmax(210px, 226px) minmax(350px, 422px) minmax(400px, 1fr); border-block: 1px solid var(--line); }
|
||||
.search__filters, .search__results, .search__document { min-height: 0; padding: 14px 16px 20px; }
|
||||
.search__filters { position: relative; display: flex; flex-direction: column; overflow: auto; background: var(--surface-muted); padding-inline: 30px 16px; }
|
||||
.search__filters .review__panel-heading { margin-block-end: 18px; }
|
||||
.search__filters .review__panel-heading button { min-height: 36px; padding: 2px; border: 0; color: var(--brand); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.search__filter { margin-block: 0 16px; }
|
||||
.search__filter--document-type { margin-block-end: 24px; }
|
||||
.search__filter h3 { margin-block-end: 6px; font-size: 16px; }
|
||||
.search__filter label { min-height: 30px; gap: 10px; }
|
||||
.search__filter input[type="radio"] { appearance: none; position: relative; width: 24px; min-height: 24px; height: 24px; flex: none; margin: 0; border: 1px solid var(--line); border-radius: 6px; padding: 0; background: #fff; }
|
||||
.search__filter input[type="radio"]:checked { border-color: var(--success); background: var(--success); }
|
||||
.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 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; }
|
||||
.search__dates label { position: relative; display: block; min-width: 0; }
|
||||
.search__dates input { min-height: 42px; padding: 7px 9px; }
|
||||
.search__dates label:not(.has-value) input { color: transparent; }
|
||||
.search__date-placeholder { position: absolute; inset-inline-start: 9px; inset-block-start: 50%; color: var(--muted); font-size: 14px; pointer-events: none; transform: translateY(-50%); }
|
||||
.search__dates label.has-value .search__date-placeholder { display: none; }
|
||||
.search__ornament { width: calc(100% - 12px); height: 56px; flex: none; margin: auto 6px 0; color: var(--accent); pointer-events: none; }
|
||||
.search__results { overflow: auto; background: var(--surface-muted); }
|
||||
.search__results .review__panel-heading { align-items: start; margin-block-end: 0; }
|
||||
.search__results .review__panel-heading select { min-height: 40px; padding: 6px 10px; font-size: 14px; }
|
||||
.search__sort-label { display: grid; gap: 3px; color: var(--muted); font-size: 12px; }
|
||||
.search__result { position: relative; padding: 12px 10px; border-block-end: 1px solid var(--line); }
|
||||
.search__result:not(.search__result--selected) { padding-block: 20px; }
|
||||
.search__result--selected { margin-inline: -6px; border: 1px solid var(--brand); border-radius: 5px; background: var(--subtle); }
|
||||
.search__result-title { display: block; width: 100%; min-height: auto; margin: 0; padding: 0; text-align: start; font-size: 18px; line-height: 1.35; }
|
||||
.search__result--selected .search__result-title { padding-inline-end: 68px; }
|
||||
.search__opened { position: absolute; inset-block-start: 18px; inset-inline-end: 8px; color: var(--muted); font-size: 14px; }
|
||||
.search__result-meta { margin: 6px 0 0; color: var(--muted); font-size: 15px; }
|
||||
.search__result-snippet { margin: 6px 0 0; color: var(--ink); font-size: 15px; line-height: 1.45; }
|
||||
.search__result-snippet mark, .search__document-body mark { color: inherit; background: var(--color-highlight); padding: 0 2px; }
|
||||
.search__document-body mark.is-current { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
||||
.search__result em { font-style: normal; }
|
||||
.search__pagination { padding-block-start: 12px; }
|
||||
.search__pagination button { min-height: 40px; padding: 6px 12px; }
|
||||
.search__document { padding: 14px 28px 28px; background: var(--surface); overflow: auto; }
|
||||
.search__document-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: start; }
|
||||
.search__breadcrumbs { display: flex; gap: 12px; align-items: center; color: var(--muted); font-size: 14px; }
|
||||
.search__breadcrumbs span:first-child { color: var(--brand); }
|
||||
.search__document-head h2 { grid-column: 1 / -1; margin-block: 0 4px; color: var(--ink); font: 700 36px/1 var(--serif); }
|
||||
.search__document-head .search__expand { min-height: 32px; border: 0; padding: 0 4px; }
|
||||
.search__document-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; border: 0; color: var(--muted); }
|
||||
.search__document-meta a { color: var(--brand); }
|
||||
.search__document-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block-end: 2px; }
|
||||
.search__document-toolbar button, .search__document-toolbar select { min-height: 42px; padding: 6px 12px; }
|
||||
.search__document-toolbar label { display: grid; gap: 2px; color: var(--muted); font-size: 12px; }
|
||||
.search__document-toolbar select { width: 180px; max-width: 180px; overflow: hidden; text-overflow: ellipsis; color: var(--ink); font-size: 14px; }
|
||||
.search__document-toolbar .search__edition-label { display: block; }
|
||||
.search__document-toolbar .search__edition-label select { width: 160px; max-width: 160px; }
|
||||
.search__source-link { max-width: 150px; margin-inline-start: auto; color: var(--brand); font-size: 14px; white-space: nowrap; }
|
||||
.search__document-language { display: inline-flex !important; align-items: center; gap: 6px !important; margin-inline-start: auto; font-size: 12px !important; }
|
||||
.search__document-language select { min-height: 28px; max-width: 130px; padding: 4px 8px; font-size: 14px; }
|
||||
.search__toc { margin-block-end: 0; }
|
||||
.search__toc summary { width: fit-content; min-height: 42px; padding: 9px 8px; border: 1px solid var(--line); border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
.search__toc ol { margin-block: 8px; padding-inline-start: 24px; }
|
||||
.search__toc a { color: var(--brand); }
|
||||
.search__find { display: grid; grid-template-columns: minmax(0,1fr) auto auto auto; gap: 4px; align-items: center; padding: 4px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-muted); }
|
||||
.search__find label { grid-column: 1 / -1; font-size: 14px; line-height: 1.2; }
|
||||
.search__find input { min-height: 32px; }
|
||||
.search__find button { min-width: 40px; min-height: 32px; padding: 4px 10px; }
|
||||
.search__find-count { white-space: nowrap; font-size: 14px; }
|
||||
.search__document-note { margin: 2px 0 18px; padding: 4px 10px; border-radius: 6px; background: var(--surface-muted); color: var(--muted); font-size: 14px; line-height: 1.2; }
|
||||
.search__document-body { max-width: 75ch; margin: 0; font: 400 18px/1.65 "Noto Sans", sans-serif; }
|
||||
.search__document-body h1, .search__document-body h2, .search__document-body h3 { font-family: var(--serif); }
|
||||
.search__document-body h2 { margin-block: 27px 0; font-size: 24px; }
|
||||
.search__document-body p { margin-block: 0 18px; }
|
||||
.search__document-body h2 + p { margin-block-start: 2px; }
|
||||
.search__document-body p + h2 { margin-block-start: 39px; }
|
||||
.search__document-body table { display: block; max-width: 100%; overflow-x: auto; }
|
||||
.search__document-body img { max-width: 100%; height: auto; }
|
||||
.search__footer { min-height: 56px; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 10px 26px; color: var(--muted); font-size: 14px; }
|
||||
.search__footer strong { color: var(--ink); }
|
||||
.search__about { max-width: 42rem; }
|
||||
.search__about h2 { font: 600 24px/1.3 var(--serif); }
|
||||
.search__about a { color: var(--brand); }
|
||||
#document-dialog.search__expanded { width: min(1200px, calc(100vw - 32px)); max-width: none; padding: 24px; }
|
||||
#document-dialog.search__expanded .search__document-body { max-width: 78ch; margin: 24px auto; }
|
||||
@media (max-width: 1100px) {
|
||||
.search__topbar { grid-template-columns: minmax(230px, 310px) minmax(0,1fr) auto; gap: 12px; padding-inline: 20px; }
|
||||
.search__brand-description, .search__account #signed-in-user { display: none; }
|
||||
.search__layout { grid-template-columns: minmax(190px, 210px) minmax(320px, 1fr) minmax(380px, 1.3fr); }
|
||||
.search__filters { padding-inline: 20px 12px; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
body:has(.search:not([hidden])) { height: auto; min-height: 100dvh; overflow: visible; }
|
||||
.search__topbar { grid-template-columns: auto minmax(0,1fr) auto; }
|
||||
.search__brand-divider, .search__brand-description { display: none; }
|
||||
.search__query { grid-template-columns: minmax(0,1fr) 112px; }
|
||||
.search__layout { grid-template-columns: minmax(0, 1fr); }
|
||||
.search__filters, .search__results { border-inline-end: 0; border-block-end: 1px solid var(--line); }
|
||||
.search__filters { padding: 16px 24px; }
|
||||
.search__filter label { min-height: 44px; }
|
||||
.search__filters .review__panel-heading button, .search__document-toolbar button, .search__document-toolbar select, .search__document-language select, .search__find input, .search__find button, .search__expand { min-height: 44px; }
|
||||
.search__mobile-filters { display: inline-flex; }
|
||||
.search__filters { display: none; }
|
||||
.search__filters.is-open { position: fixed; z-index: 21; inset: 0 0 0 auto; display: block; width: min(360px, calc(100vw - 32px)); overflow: auto; overscroll-behavior: contain; background: var(--surface-muted); box-shadow: -12px 0 32px rgb(56 38 42 / .18); }
|
||||
.search__filter-overlay.is-open { position: fixed; z-index: 20; inset: 0; display: block; background: rgb(56 38 42 / .42); }
|
||||
.search__filter-close { display: inline-flex; }
|
||||
.search__mobile-back { display: inline-flex; }
|
||||
.search__layout[data-mobile-view="document"] .search__results { display: none; }
|
||||
.search__layout[data-mobile-view="document"] .search__document { display: block; }
|
||||
.search__layout[data-mobile-view="results"] .search__document { display: none; }
|
||||
.search__layout[data-mobile-view="results"] .search__results { display: block; }
|
||||
.search__layout[data-mobile-view="document"] .search__filters { display: none; }
|
||||
.search__ornament { display: none; }
|
||||
.search__document { min-height: 30rem; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.search__topbar { grid-template-columns: minmax(0,1fr) auto; gap: 8px; padding: 10px 16px; }
|
||||
.search__brand { grid-column: 1; gap: 8px; }
|
||||
.search__brand-mark { width: 36px; height: 36px; }
|
||||
.search__brand-name { font-size: 22px; }
|
||||
.search__ui-language { grid-column: 2; grid-row: 1; }
|
||||
.search__account { grid-column: 1 / -1; grid-row: 3; }
|
||||
.search__query { grid-column: 1 / -1; grid-row: 2; grid-template-columns: minmax(0,1fr) 96px; gap: 8px; }
|
||||
.search__query .review__button--primary { padding-inline: 8px; }
|
||||
.search__subnav { padding-inline: 12px; gap: 4px; }
|
||||
.search__chips { margin-inline-start: 4px; }
|
||||
.search__filters, .search__results { padding: 16px; }
|
||||
.search__document { padding: 18px 16px; }
|
||||
.search__document-head h2 { font-size: 26px; }
|
||||
.search__document-toolbar { gap: 6px; }
|
||||
.search__source-link { margin-inline-start: 0; }
|
||||
.search__find { grid-template-columns: minmax(0,1fr) auto auto; }
|
||||
.search__find-count { grid-column: 1 / -1; grid-row: 3; }
|
||||
.search__footer { align-items: start; flex-direction: column; padding: 12px 16px; }
|
||||
.search__dates { max-width: 320px; }
|
||||
.search__date-label { margin-block-start: 16px; }
|
||||
}
|
||||
@media print {
|
||||
body:has(.search:not([hidden])) { display: block; background: #fff; }
|
||||
.search__chrome, .search__filters, .search__results, .search__document-toolbar, .search__toc, .search__find, .search__footer, .search__expand { display: none !important; }
|
||||
.search__document-toolbar { display: block !important; }
|
||||
.search__document-toolbar label, .search__document-toolbar button, .search__document-toolbar .search__toc { display: none !important; }
|
||||
.search__source-link { display: inline !important; }
|
||||
.search, .search__layout, .search__document { display: block; width: auto; min-height: 0; overflow: visible; border: 0; padding: 0; }
|
||||
.search__document-body { max-width: none; }
|
||||
}
|
||||
</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="search-nav" href="/">Поиск</a><a id="review-nav" href="/review">Лаборатория</a><button id="account-open" type="button">Личный кабинет</button><button id="users-open" type="button" hidden>Пользователи</button></nav><div class="review__nav-account"><span id="signed-in-user"></span><button id="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><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__header-content"><div class="review__header-top"><button id="tutorial-open" type="button">Как это работает</button></div>
|
||||
<h1>Проверка качества поиска</h1><p>Сопоставляйте запрос с текстом документов и сохраняйте оценку выдачи.</p></div>
|
||||
</header>
|
||||
<header class="search__chrome" aria-label="Акылдаш">
|
||||
<div class="search__topbar">
|
||||
<a class="search__brand" href="/" aria-label="Акылдаш — поиск по правовой базе">
|
||||
<svg class="search__brand-mark" viewBox="0 0 48 48" role="img" aria-label="Знак Акылдаш"><circle cx="24" cy="24" r="20" fill="none" stroke="currentColor" stroke-width="3"/><path d="M24 5v38M5 24h38M11 11l26 26M37 11 11 37M17 7c2 8 12 8 14 0M17 41c2-8 12-8 14 0M7 17c8 2 8 12 0 14M41 17c-8 2-8 12 0 14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>
|
||||
<span class="search__brand-name">Акылдаш</span><span class="search__brand-divider" aria-hidden="true"></span><span class="search__brand-description">Правовая база Кыргызстана</span>
|
||||
</a>
|
||||
<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>
|
||||
</header>
|
||||
<main class="review__main" id="review">
|
||||
<form class="review__search" id="search-form">
|
||||
<label class="review__field">Юридический запрос<input id="query" name="q" maxlength="500" required autocomplete="off" placeholder="Например, как открыть ОсОО?"></label>
|
||||
@@ -162,21 +371,22 @@
|
||||
</main>
|
||||
<main class="search" id="search-page" hidden>
|
||||
<h1 class="search__sr-only">Поиск по правовой базе</h1>
|
||||
<form class="search__query" id="public-search-form"><label for="public-query">Поиск по правовой базе</label><div class="search__query-row"><input id="public-query" maxlength="500" autocomplete="off" placeholder="Введите правовой вопрос или название документа"><button class="review__button--primary" type="submit">Найти</button><label class="search__language">Язык<select id="public-language"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label></div></form>
|
||||
<p class="review__status" id="public-status" role="status" aria-live="polite"></p>
|
||||
<div class="search__layout"><aside class="search__filters" aria-labelledby="filters-heading"><div class="review__panel-heading"><h2 id="filters-heading">Фильтры</h2><button id="clear-filters" type="button">Сбросить</button></div><div id="filter-fields"></div><label>Дата принятия от<input id="date-from" type="date"></label><label>Дата принятия до<input id="date-to" type="date"></label></aside>
|
||||
<section class="search__results" aria-labelledby="search-results-heading"><div class="review__panel-heading"><div><h2 id="search-results-heading">Результаты</h2><span class="review__panel-hint" id="result-count">Введите запрос</span></div><label>Сортировка<select id="sort-order"><option value="relevance">По релевантности</option><option value="date">По дате</option></select></label></div><ol id="public-results" class="search__result-list"><li class="review__empty"><strong>Начните с поиска</strong>Введите запрос, чтобы найти документы.</li></ol><div class="search__pagination"><button id="page-prev" type="button" disabled>Назад</button><span id="page-label">Страница 1</span><button id="page-next" type="button" disabled>Далее</button></div></section>
|
||||
<section class="search__document" aria-labelledby="public-document-heading"><div class="review__panel-heading"><h2 id="public-document-heading">Документ</h2></div><div class="search__document-controls"><label>Редакция<select id="document-edition" disabled></select></label><label>Язык документа<select id="document-language" disabled></select></label></div><p id="public-document-meta" class="review__document-meta">Выберите результат, чтобы прочитать доступную редакцию.</p><article id="public-document-body" class="review__document-body"></article></section>
|
||||
<p class="search__sr-only" id="public-status" role="status" aria-live="polite"></p>
|
||||
<div class="search__layout" id="search-layout"><aside class="search__filters" id="search-filters" aria-labelledby="filters-heading"><div class="review__panel-heading"><h2 id="filters-heading">Фильтры</h2><button id="clear-filters" type="button">Сбросить</button><button class="search__filter-close" id="mobile-filters-close" type="button">Закрыть фильтры</button></div><div id="filter-fields"></div><span class="search__date-label">Дата принятия</span><div class="search__dates"><label><span class="search__sr-only">Дата принятия от</span><input id="date-from" type="date" aria-label="Дата принятия от"><span class="search__date-placeholder" aria-hidden="true">От</span></label><label><span class="search__sr-only">Дата принятия до</span><input id="date-to" type="date" aria-label="Дата принятия до"><span class="search__date-placeholder" aria-hidden="true">До</span></label></div><svg class="search__ornament" viewBox="0 0 220 56" aria-hidden="true"><path d="M2 34h60c15 0 17 17 31 17s16-17 30-17h95M2 41h55c13 0 17 13 31 13 15 0 17-13 31-13h99M98 42c-15-16 7-17 13-29 6 12 28 13 13 29-8 8-18 8-26 0Zm13-29v34M106 39l15-15m-15 15L96 27" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></aside><button class="search__filter-overlay" id="filter-overlay" type="button" aria-label="Закрыть фильтры"></button>
|
||||
<section class="search__results" aria-labelledby="search-results-heading"><div class="review__panel-heading"><div><h2 id="search-results-heading">Результаты</h2><span class="review__panel-hint" id="result-count">Введите запрос</span></div><label class="search__sort-label">Сортировка<select id="sort-order"><option value="relevance">По релевантности</option><option value="date">По дате</option></select></label></div><ol id="public-results" class="search__result-list"><li class="review__empty"><strong>Начните с поиска</strong>Введите запрос, чтобы найти документы.</li></ol><div class="search__pagination"><button id="page-prev" type="button" disabled>Назад</button><span id="page-label">Страница 1</span><button id="page-next" type="button" disabled>Далее</button></div></section>
|
||||
<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>
|
||||
<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><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="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="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.1.2 · Backend v0.12.2 · Внутренняя лаборатория релевантности</div></footer>
|
||||
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.2.3 · Backend v0.13.0 · Внутренняя лаборатория релевантности</div></footer>
|
||||
<script>
|
||||
document.body.classList.add('review__locked');
|
||||
const DRAFT_KEY = 'akyldash-search-review-draft';
|
||||
@@ -191,74 +401,436 @@
|
||||
const state = { results: [], token: '', selected: null, query: '' };
|
||||
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: 'Бардык органдар'}
|
||||
};
|
||||
const copy = (key) => searchCopy[uiLanguage][key];
|
||||
const modalCopy = {
|
||||
ru: {account: 'Личный кабинет', lab: 'Лаборатория', users: 'Пользователи', logout: 'Выйти', role: 'Роль', admin: 'Администратор', created: 'Дата создания', changePassword: 'Изменить пароль', passwordRequirement: 'Новый пароль должен содержать не менее 12 символов.', currentPassword: 'Текущий пароль', newPassword: 'Новый пароль', confirmPassword: 'Повторите новый пароль', change: 'Изменить пароль', close: 'Закрыть', usersHeading: 'Управление пользователями', temporaryPassword: 'Временный пароль', temporaryPasswordHelp: 'Пароль — от 12 символов. Новый пользователь сменит его при первом входе. Передайте временный пароль отдельно.', createUser: 'Создать пользователя', sourcesHeading: 'Источники документов', sourceIntro: 'Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.', officialSource: 'Официальный источник:', sourceName: 'Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики', loginHeading: 'Вход в Акылдаш', loginHelp: 'Введите учетные данные, выданные администратором.', username: 'Логин', password: 'Пароль', login: 'Войти', newPasswordHeading: 'Создайте новый пароль', newPasswordHelp: 'Для продолжения замените временный пароль. Используйте не менее 12 символов.', loginError: 'Не удалось войти. Проверьте логин и пароль.', passwordMismatch: 'Новые пароли не совпадают.', passwordChangeError: 'Не удалось изменить пароль.', usersLoadError: 'Не удалось загрузить список пользователей.', passwordResetError: 'Не удалось сбросить пароль.', userDeleteError: 'Не удалось удалить учетную запись.', userCreateError: 'Не удалось создать учетную запись.'},
|
||||
ky: {account: 'Жеке кабинет', lab: 'Лаборатория', users: 'Колдонуучулар', logout: 'Чыгуу', role: 'Ролу', admin: 'Администратор', created: 'Түзүлгөн күнү', changePassword: 'Сырсөздү өзгөртүү', passwordRequirement: 'Жаңы сырсөз кеминде 12 белгиден турушу керек.', currentPassword: 'Учурдагы сырсөз', newPassword: 'Жаңы сырсөз', confirmPassword: 'Жаңы сырсөздү кайталаңыз', change: 'Сырсөздү өзгөртүү', close: 'Жабуу', usersHeading: 'Колдонуучуларды башкаруу', temporaryPassword: 'Убактылуу сырсөз', temporaryPasswordHelp: 'Сырсөз кеминде 12 белгиден турушу керек. Жаңы колдонуучу биринчи киргенде аны өзгөртөт. Убактылуу сырсөздү өзүнчө жөнөтүңүз.', createUser: 'Колдонуучу түзүү', sourcesHeading: 'Документтердин булактары', sourceIntro: 'Издөө Кыргыз Республикасынын укуктук булактарынан даярдалган документтер боюнча жүргүзүлөт. Ар бир документ үчүн жеткиликтүү редакция жана расмий булактын шилтемеси көрсөтүлөт.', officialSource: 'Расмий булак:', sourceName: 'Кыргыз Республикасынын Юстиция министрлигинин укуктук маалыматтарынын борборлоштурулган банкы', loginHeading: 'Акылдашка кирүү', loginHelp: 'Администратор берген эсептик дайындарды киргизиңиз.', username: 'Колдонуучунун аты', password: 'Сырсөз', login: 'Кирүү', newPasswordHeading: 'Жаңы сырсөз түзүңүз', newPasswordHelp: 'Улантуу үчүн убактылуу сырсөздү алмаштырыңыз. Кеминде 12 белги колдонуңуз.', loginError: 'Кирүү мүмкүн болгон жок. Колдонуучунун атын жана сырсөздү текшериңиз.', passwordMismatch: 'Жаңы сырсөздөр дал келген жок.', passwordChangeError: 'Сырсөздү өзгөртүү мүмкүн болгон жок.', usersLoadError: 'Колдонуучулардын тизмесин жүктөө мүмкүн болгон жок.', passwordResetError: 'Сырсөздү баштапкы абалга келтирүү мүмкүн болгон жок.', userDeleteError: 'Колдонуучуну өчүрүү мүмкүн болгон жок.', userCreateError: 'Колдонуучуну түзүү мүмкүн болгон жок.'}
|
||||
};
|
||||
const modalMessage = (key) => modalCopy[uiLanguage][key];
|
||||
const reviewRoute = location.pathname === '/review';
|
||||
$('review').hidden = !reviewRoute; $('search-page').hidden = 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.1.2 · Backend v0.12.2'; }
|
||||
$('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'; }
|
||||
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}`; $('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'); $('users-open').hidden = user.username.toLowerCase() !== 'admin'; document.body.classList.remove('review__locked'); if (!reviewRoute) initSearchPage(); else if (!hasSeenTour()) setTimeout(openTutorial, 350); };
|
||||
const filterFields = [['document_types', 'Вид документа', 'document_type'], ['statuses', 'Статус', 'status'], ['authorities', 'Орган принятия', 'authority'], ['legal_forces', 'Юридическая сила', 'legal_force']];
|
||||
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 filterFields = [['statuses', {ru: 'Статус', ky: 'Статус'}, 'status'], ['document_types', {ru: 'Вид документа', ky: 'Документтин түрү'}, 'document_type'], ['authorities', {ru: 'Орган принятия', ky: 'Кабыл алган орган'}, 'authority']];
|
||||
let filterCatalogs = {};
|
||||
let filterCatalogsLoaded = false;
|
||||
let publicPage = 1;
|
||||
let selectedPublicDocument = null;
|
||||
const selectedFilters = () => Object.fromEntries(filterFields.map(([key,, parameter]) => [parameter, document.querySelector(`input[name="filter-${parameter}"]:checked`)?.value || '']));
|
||||
function renderFilterCatalogs(payload) { filterCatalogs = payload; const root = $('filter-fields'); root.replaceChildren(); filterFields.forEach(([key, title, parameter]) => { const items = payload[key] || []; if (!items.length) return; const group = document.createElement('section'); group.className = 'search__filter'; const heading = document.createElement('h3'); heading.textContent = title; group.append(heading); items.forEach((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.ru || item.labels.ky || item.code} (${item.count})`; label.append(input, text); group.append(label); }); root.append(group); }); }
|
||||
function renderPublicResults(results) { const list = $('public-results'); list.replaceChildren(); if (!results.length) { list.innerHTML = '<li class="review__empty"><strong>Документы не найдены</strong>Измените запрос или сбросьте фильтры.</li>'; return; } results.forEach((result, index) => { const row = document.createElement('li'); row.className = 'search__result'; const title = document.createElement('button'); title.type = 'button'; title.className = 'search__result-title'; title.textContent = result.name || 'Название не указано'; title.addEventListener('click', () => openPublicDocument(result, row)); const meta = document.createElement('p'); meta.className = 'review__meta'; meta.textContent = [result.type, result.status, result.date_adopted, result.number, result.legal_force].filter(Boolean).join(' · '); const snippet = document.createElement('p'); snippet.textContent = result.snippet || 'Для документа нет выдержки из текста.'; row.append(title, meta, snippet); list.append(row); }); }
|
||||
async function runSearch(page = 1) { const query = $('public-query').value.trim(); if (!query) { $('public-status').textContent = 'Введите запрос, чтобы начать поиск.'; return; } publicPage = page; $('public-status').textContent = 'Выполняется поиск…'; const params = new URLSearchParams({q: query, language: $('public-language').value, page: String(page), page_size: '20', sort: $('sort-order').value}); for (const [key, value] of Object.entries(selectedFilters())) if (value) params.set(key, value); if ($('date-from').value) params.set('date_from', $('date-from').value); if ($('date-to').value) params.set('date_to', $('date-to').value); history.replaceState(null, '', `/?${params}`); try { const response = await apiFetch(`/search?${params}`); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || 'Не удалось выполнить поиск.'); renderPublicResults(payload.results); $('result-count').textContent = `${payload.results.length} документов`; $('public-status').textContent = payload.results.length ? `Результаты для запроса «${query}».` : `По запросу «${query}» документов нет. Измените запрос или фильтры.`; $('page-label').textContent = `Страница ${page}`; $('page-prev').disabled = page <= 1; $('page-next').disabled = !payload.has_next; } catch (error) { $('public-status').textContent = error.message || 'Не удалось выполнить поиск. Повторите попытку.'; $('result-count').textContent = 'Поиск недоступен'; } }
|
||||
let searchRequest = 0;
|
||||
let editionRequest = 0;
|
||||
let currentDocumentHtml = '';
|
||||
let documentMatches = [];
|
||||
let activeDocumentMatch = -1;
|
||||
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) {
|
||||
filterCatalogs = payload;
|
||||
filterCatalogsLoaded = true;
|
||||
const root = $('filter-fields');
|
||||
root.replaceChildren();
|
||||
filterFields.forEach(([key, titles, parameter]) => {
|
||||
const title = titles[uiLanguage];
|
||||
const items = payload[key] || [];
|
||||
if (!items.length) return;
|
||||
const group = document.createElement('section');
|
||||
group.className = parameter === 'authority' ? 'search__filter search__filter--authority' : parameter === 'document_type' ? 'search__filter search__filter--document-type' : 'search__filter';
|
||||
if (parameter === 'authority') {
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = 'filter-authority';
|
||||
label.append(document.createTextNode(title));
|
||||
const select = document.createElement('select');
|
||||
select.id = 'filter-authority';
|
||||
const all = document.createElement('option');
|
||||
all.value = '';
|
||||
all.textContent = copy('allAuthorities');
|
||||
select.append(all);
|
||||
items.forEach((item) => { const option = document.createElement('option'); option.value = item.code; option.textContent = `${item.labels[uiLanguage] || item.labels.ru || item.labels.ky || item.code} (${item.count})`; select.append(option); });
|
||||
select.addEventListener('change', () => runSearch(1));
|
||||
label.append(select);
|
||||
group.append(label);
|
||||
} else {
|
||||
const heading = document.createElement('h3');
|
||||
heading.textContent = title;
|
||||
group.append(heading);
|
||||
items.forEach((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);
|
||||
});
|
||||
}
|
||||
root.append(group);
|
||||
});
|
||||
}
|
||||
function syncFilterChips() {
|
||||
const root = $('active-filters');
|
||||
root.replaceChildren();
|
||||
const filters = selectedFilters();
|
||||
const labels = Object.fromEntries(filterFields.map(([key, titles, parameter]) => [parameter, {title: titles[uiLanguage], items: filterCatalogs[key] || []}]));
|
||||
Object.entries(filters).forEach(([parameter, value]) => {
|
||||
if (!value) return;
|
||||
const item = labels[parameter].items.find((candidate) => candidate.code === value);
|
||||
const chip = document.createElement('span'); chip.className = 'search__chip';
|
||||
chip.append(document.createTextNode(item?.labels[uiLanguage] || item?.labels.ru || item?.labels.ky || value));
|
||||
const remove = document.createElement('button'); remove.type = 'button'; remove.innerHTML = '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m5 5 10 10m0-10L5 15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>'; remove.setAttribute('aria-label', `Убрать фильтр «${labels[parameter].title}»`);
|
||||
remove.addEventListener('click', () => { if (parameter === 'authority') $('filter-authority').value = ''; else { const input = document.querySelector(`input[name="filter-${parameter}"]:checked`); if (input) input.checked = false; } runSearch(1); });
|
||||
chip.append(remove); root.append(chip);
|
||||
});
|
||||
[['date-from', 'date_from', copy('from')], ['date-to', 'date_to', copy('to')]].forEach(([id, key, label]) => {
|
||||
if (!$(`${id}`).value) return;
|
||||
const value = $(`${id}`).value;
|
||||
const chip = document.createElement('span'); chip.className = 'search__chip'; chip.append(document.createTextNode(`${label}: ${value}`));
|
||||
const remove = document.createElement('button'); remove.type = 'button'; remove.innerHTML = '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m5 5 10 10m0-10L5 15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>'; remove.setAttribute('aria-label', `Убрать дату ${value}`); remove.addEventListener('click', () => { $(id).value = ''; syncDateHint(id); runSearch(1); });
|
||||
chip.append(remove); root.append(chip);
|
||||
});
|
||||
}
|
||||
function appendHighlighted(parent, value, query) {
|
||||
const text = String(value || '');
|
||||
if (!query) { parent.textContent = text; return; }
|
||||
const terms = query.split(/\s+/).map((term) => `${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}${/^\p{L}{5,}$/u.test(term) ? '\\p{L}{0,2}' : ''}`);
|
||||
const pattern = new RegExp(terms.join('\\s+'), 'giu');
|
||||
let cursor = 0;
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
parent.append(document.createTextNode(text.slice(cursor, match.index)));
|
||||
const mark = document.createElement('mark'); mark.textContent = match[0]; parent.append(mark);
|
||||
cursor = match.index + match[0].length;
|
||||
}
|
||||
parent.append(document.createTextNode(text.slice(cursor)));
|
||||
}
|
||||
function renderPublicResults(results) {
|
||||
const list = $('public-results');
|
||||
list.replaceChildren();
|
||||
if (!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;
|
||||
}
|
||||
results.forEach((result) => {
|
||||
const row = document.createElement('li'); row.className = 'search__result'; row.dataset.code = result.code;
|
||||
const title = document.createElement('button'); title.type = 'button'; title.className = 'search__result-title'; title.textContent = result.name || copy('noTitle');
|
||||
title.addEventListener('click', () => openPublicDocument(result, row));
|
||||
const meta = document.createElement('p'); meta.className = 'search__result-meta'; meta.textContent = [result.type, result.status, $('public-language').value.toUpperCase(), result.date_adopted, result.number, result.legal_force].filter(Boolean).join(' · ');
|
||||
const snippet = document.createElement('p'); snippet.className = 'search__result-snippet'; appendHighlighted(snippet, result.snippet || copy('noSnippet'), $('public-query').value.trim());
|
||||
row.append(title, meta, snippet); list.append(row);
|
||||
});
|
||||
}
|
||||
async function runSearch(page = 1, updateUrl = true) {
|
||||
const query = $('public-query').value.trim();
|
||||
if (!query) { $('public-status').textContent = copy('queryRequired'); syncFilterChips(); return; }
|
||||
publicPage = Math.max(1, page);
|
||||
const requestId = ++searchRequest;
|
||||
$('public-status').textContent = copy('searching');
|
||||
const params = new URLSearchParams({q: query, language: $('public-language').value, page: String(publicPage), page_size: '20', sort: $('sort-order').value});
|
||||
for (const [key, value] of Object.entries(selectedFilters())) if (value) params.set(key, value);
|
||||
if ($('date-from').value) params.set('date_from', $('date-from').value);
|
||||
if ($('date-to').value) params.set('date_to', $('date-to').value);
|
||||
if (updateUrl) history.pushState(null, '', `/?${params}`);
|
||||
syncFilterChips();
|
||||
try {
|
||||
const response = await apiFetch(`/search?${params}`);
|
||||
const payload = await response.json();
|
||||
if (requestId !== searchRequest) return;
|
||||
if (!response.ok) throw new Error(payload.error || copy('searchError'));
|
||||
renderPublicResults(payload.results || []);
|
||||
const count = (payload.results || []).length;
|
||||
$('result-count').textContent = `${count} ${count === 1 ? copy('oneResult') : copy('manyResults')}`;
|
||||
$('public-status').textContent = count ? `${copy('resultsFor')} «${query}».` : `«${query}»: ${copy('noDocuments')}`;
|
||||
$('page-label').textContent = `${copy('page')} ${publicPage}`;
|
||||
$('page-prev').disabled = publicPage <= 1;
|
||||
$('page-next').disabled = !payload.has_next;
|
||||
} catch (error) {
|
||||
if (requestId !== searchRequest) return;
|
||||
$('public-status').textContent = error.message || copy('searchError');
|
||||
$('result-count').textContent = copy('noResultsCount');
|
||||
}
|
||||
}
|
||||
function buildDocumentToc() {
|
||||
const body = $('public-document-body');
|
||||
const headings = [...body.querySelectorAll('h1, h2, h3')];
|
||||
const root = $('document-toc-list'); root.replaceChildren();
|
||||
if (!headings.length) { const item = document.createElement('li'); item.textContent = copy('noSections'); root.append(item); return; }
|
||||
headings.forEach((heading, index) => {
|
||||
if (!heading.id) heading.id = `document-section-${index + 1}`;
|
||||
const item = document.createElement('li'); const link = document.createElement('a'); link.href = `#${heading.id}`; link.textContent = heading.textContent.trim();
|
||||
link.addEventListener('click', (event) => { event.preventDefault(); heading.scrollIntoView({block: 'start'}); heading.focus({preventScroll: true}); });
|
||||
if (!heading.hasAttribute('tabindex')) heading.tabIndex = -1;
|
||||
item.append(link); root.append(item);
|
||||
});
|
||||
}
|
||||
function highlightDocumentMatches() {
|
||||
const body = $('public-document-body'); body.innerHTML = currentDocumentHtml;
|
||||
documentMatches = []; activeDocumentMatch = -1;
|
||||
const query = $('document-find').value.trim();
|
||||
if (query) {
|
||||
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, {acceptNode: (node) => node.parentElement.closest('script, style, mark') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT});
|
||||
const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const pattern = new RegExp(escaped, 'giu');
|
||||
nodes.forEach((node) => {
|
||||
const text = node.nodeValue; const matches = [...text.matchAll(pattern)];
|
||||
if (!matches.length) return;
|
||||
const fragment = document.createDocumentFragment(); let cursor = 0;
|
||||
matches.forEach((match) => { fragment.append(document.createTextNode(text.slice(cursor, match.index))); const mark = document.createElement('mark'); mark.textContent = match[0]; mark.dataset.match = String(documentMatches.length); documentMatches.push(mark); fragment.append(mark); cursor = match.index + match[0].length; });
|
||||
fragment.append(document.createTextNode(text.slice(cursor))); node.replaceWith(fragment);
|
||||
});
|
||||
}
|
||||
const count = documentMatches.length;
|
||||
$('document-find-count').textContent = count ? `1 ${uiLanguage === 'ru' ? 'из' : 'ичинен'} ${count}` : query ? copy('noMatches') : '';
|
||||
$('document-find-prev').disabled = $('document-find-next').disabled = count === 0;
|
||||
if (count) moveDocumentMatch(1);
|
||||
buildDocumentToc();
|
||||
}
|
||||
function moveDocumentMatch(step) {
|
||||
if (!documentMatches.length) return;
|
||||
activeDocumentMatch = (activeDocumentMatch + step + documentMatches.length) % documentMatches.length;
|
||||
documentMatches.forEach((mark, index) => { mark.classList.toggle('is-current', index === activeDocumentMatch); });
|
||||
$('document-find-count').textContent = `${activeDocumentMatch + 1} ${uiLanguage === 'ru' ? 'из' : 'ичинен'} ${documentMatches.length}`;
|
||||
documentMatches[activeDocumentMatch].scrollIntoView({block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth'});
|
||||
}
|
||||
let filterReturnFocus = null;
|
||||
function closeMobileFilters() {
|
||||
$('search-filters').classList.remove('is-open'); $('filter-overlay').classList.remove('is-open');
|
||||
$('search-filters').removeAttribute('aria-modal'); $('search-filters').removeAttribute('role');
|
||||
if (matchMedia('(max-width: 900px)').matches) $('search-filters').setAttribute('aria-hidden', 'true'); else $('search-filters').removeAttribute('aria-hidden');
|
||||
$('mobile-filters-open').setAttribute('aria-expanded', 'false');
|
||||
['search__chrome', 'search__results', 'search__document', 'search__footer'].forEach((selector) => document.querySelector(`.${selector}`).inert = false);
|
||||
filterReturnFocus?.focus();
|
||||
}
|
||||
function openMobileFilters() {
|
||||
filterReturnFocus = document.activeElement;
|
||||
$('search-filters').classList.add('is-open'); $('filter-overlay').classList.add('is-open');
|
||||
$('search-filters').setAttribute('role', 'dialog'); $('search-filters').setAttribute('aria-modal', 'true'); $('search-filters').setAttribute('aria-hidden', 'false'); $('mobile-filters-open').setAttribute('aria-expanded', 'true');
|
||||
['search__chrome', 'search__results', 'search__document', 'search__footer'].forEach((selector) => document.querySelector(`.${selector}`).inert = true);
|
||||
$('mobile-filters-close').focus();
|
||||
}
|
||||
function setMobileView(view) {
|
||||
if (!matchMedia('(max-width: 900px)').matches) { $('search-layout').removeAttribute('data-mobile-view'); return; }
|
||||
$('search-layout').dataset.mobileView = view;
|
||||
if (view === 'document') { $('public-document-heading').tabIndex = -1; $('public-document-heading').focus(); }
|
||||
}
|
||||
async function loadPublicEdition() {
|
||||
if (!selectedPublicDocument) return;
|
||||
const requestId = ++editionRequest;
|
||||
const {code} = selectedPublicDocument;
|
||||
const edition = $('document-edition').value;
|
||||
const editionInfo = selectedPublicDocument.editions.find((item) => item.source_code === edition);
|
||||
let language = $('document-language').value;
|
||||
const editionInfo = selectedPublicDocument.editions.find((item) => String(item.source_code) === String(edition));
|
||||
const languages = editionInfo?.available_languages || [];
|
||||
|
agent
commented
[P2] Выбирайте доступный язык при смене редакции. Если в новой редакции нет языка предыдущей, проверка выводит ошибку и оставляет текст пустым, хотя текст на другом языке доступен. При смене редакции выбирайте первый доступный язык и обновляйте селектор перед загрузкой. [P2] Выбирайте доступный язык при смене редакции. Если в новой редакции нет языка предыдущей, проверка выводит ошибку и оставляет текст пустым, хотя текст на другом языке доступен. При смене редакции выбирайте первый доступный язык и обновляйте селектор перед загрузкой.
|
||||
const languageSelect = $('document-language');
|
||||
[...languageSelect.options].forEach((option) => { option.disabled = !languages.includes(option.value); });
|
||||
if (!languages.length) { $('public-document-meta').textContent = 'В этой редакции нет доступного текста.'; $('public-document-body').replaceChildren(); return; }
|
||||
if (!languages.includes(languageSelect.value)) languageSelect.value = languages[0];
|
||||
const language = languageSelect.value;
|
||||
$('public-document-meta').textContent = 'Загрузка редакции…';
|
||||
if (!languages.includes(language) && languages.length) { language = languages[0]; $('document-language').value = language; }
|
||||
$('document-source').hidden = true; $('document-source').removeAttribute('href');
|
||||
|
agent
commented
[P2] Скрывайте ссылку на предыдущую редакцию до успешной загрузки новой. После успешного просмотра одной редакции пользователь выбирает другую; если запрос завершается ошибкой или у неё нет доступного языка, [P2] Скрывайте ссылку на предыдущую редакцию до успешной загрузки новой. После успешного просмотра одной редакции пользователь выбирает другую; если запрос завершается ошибкой или у неё нет доступного языка, `currentDocumentHtml` и панель очищаются, но `document-source` остаётся видимым с URL старой редакции. В итоге под ошибкой новой редакции доступна неверная ссылка «Официальный источник». Скрывайте ссылку в начале `loadPublicEdition()` и показывайте только после успешной загрузки; для пустого/ошибочного состояния также сбрасывайте связанные элементы управления.
|
||||
currentDocumentHtml = ''; $('public-document-body').replaceChildren();
|
||||
if (!languages.includes(language)) { $('document-facts').textContent = copy('editionMissing'); return; }
|
||||
$('document-facts').textContent = copy('loadingEdition');
|
||||
try {
|
||||
const response = await apiFetch(`/documents/${encodeURIComponent(code)}/editions/${encodeURIComponent(edition)}?language=${encodeURIComponent(language)}`);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || 'Редакция недоступна.');
|
||||
if (requestId !== editionRequest || selectedPublicDocument?.code !== code || $('document-edition').value !== edition || $('document-language').value !== language) return;
|
||||
if (!response.ok) throw new Error(payload.error || copy('editionUnavailable'));
|
||||
const content = payload.content[language];
|
||||
const meta = $('public-document-meta'); meta.replaceChildren();
|
||||
const details = document.createElement('span'); details.textContent = [selectedPublicDocument.type, selectedPublicDocument.status, selectedPublicDocument.date_adopted].filter(Boolean).join(' · ');
|
||||
const source = document.createElement('a'); source.href = `https://cbd.minjust.gov.kg/${encodeURIComponent(code)}/edition/${encodeURIComponent(edition)}/${encodeURIComponent(language)}`; source.target = '_blank'; source.rel = 'noreferrer'; source.textContent = 'Официальный источник';
|
||||
meta.append(details, document.createTextNode(' · '), source);
|
||||
$('public-document-body').innerHTML = content.html;
|
||||
} catch (error) { $('public-document-meta').textContent = error.message || 'Не удалось загрузить документ.'; $('public-document-body').replaceChildren(); }
|
||||
const meta = $('document-facts'); meta.replaceChildren();
|
||||
const type = document.createElement('span'); type.textContent = selectedPublicDocument.type || copy('document');
|
||||
const status = document.createElement('span'); status.textContent = selectedPublicDocument.status || '';
|
||||
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 || '';
|
||||
$('public-document-body').innerHTML = currentDocumentHtml;
|
||||
$('public-document-body').lang = language;
|
||||
$('document-find').disabled = false; $('document-note').hidden = true;
|
||||
highlightDocumentMatches();
|
||||
} catch (error) {
|
||||
if (requestId !== editionRequest) return;
|
||||
$('document-source').hidden = true; $('document-source').removeAttribute('href');
|
||||
currentDocumentHtml = '';
|
||||
$('document-facts').textContent = error.message || copy('editionUnavailable');
|
||||
$('public-document-body').replaceChildren();
|
||||
}
|
||||
}
|
||||
async function openPublicDocument(result, row) {
|
||||
document.querySelectorAll('.search__result--selected').forEach((item) => item.classList.remove('search__result--selected'));
|
||||
|
agent
commented
[P2] Сбрасывайте сохранённый HTML при открытии другого документа. Если у нового документа нет редакций или загрузка завершается ошибкой, панель очищается, но [P2] Сбрасывайте сохранённый HTML при открытии другого документа. Если у нового документа нет редакций или загрузка завершается ошибкой, панель очищается, но `currentDocumentHtml` остаётся от предыдущего документа. Кнопка «Развернуть» затем показывает старый текст под новым заголовком.
|
||||
row.classList.add('search__result--selected'); selectedPublicDocument = result;
|
||||
$('public-document-heading').textContent = result.name || 'Документ'; $('public-document-body').replaceChildren();
|
||||
row.classList.add('search__result--selected'); selectedPublicDocument = result; ++editionRequest; currentDocumentHtml = '';
|
||||
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');
|
||||
$('public-document-heading').textContent = result.name || copy('document'); $('public-document-body').replaceChildren();
|
||||
setMobileView('document');
|
||||
$('document-find').value = ''; $('document-find').disabled = true; $('document-note').hidden = true;
|
||||
$('document-source').hidden = true; $('document-source').removeAttribute('href'); $('document-edition').replaceChildren(); $('document-language').replaceChildren();
|
||||
try {
|
||||
const response = await apiFetch(`/documents/${encodeURIComponent(result.code)}/editions`);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || 'Не удалось получить список редакций.');
|
||||
if (selectedPublicDocument !== result) return;
|
||||
if (!response.ok) throw new Error(payload.error || copy('editionUnavailable'));
|
||||
selectedPublicDocument.editions = payload.editions || [];
|
||||
const editionSelect = $('document-edition'); editionSelect.replaceChildren();
|
||||
const editionSelect = $('document-edition');
|
||||
(payload.editions || []).forEach((edition) => { const option = document.createElement('option'); option.value = edition.source_code; const name = edition.name?.[$('public-language').value] || edition.name?.ru || edition.name?.ky; option.textContent = [name, edition.source_code].filter(Boolean).join(' · '); editionSelect.append(option); });
|
||||
const matchedEdition = [...editionSelect.options].find((option) => option.value === String(result.edition));
|
||||
if (matchedEdition) editionSelect.value = matchedEdition.value;
|
||||
editionSelect.disabled = !editionSelect.options.length;
|
||||
const languages = payload.available_languages || [];
|
||||
const languageSelect = $('document-language'); languageSelect.replaceChildren();
|
||||
languages.forEach((language) => { const option = document.createElement('option'); option.value = language; option.textContent = language === 'ru' ? 'Русский' : 'Кыргызский'; languageSelect.append(option); });
|
||||
const languageSelect = $('document-language');
|
||||
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('Для документа нет доступных редакций с текстом.');
|
||||
if (!languages.length || !editionSelect.options.length) throw new Error(copy('noEditions'));
|
||||
languageSelect.value = languages.includes($('public-language').value) ? $('public-language').value : languages[0];
|
||||
await loadPublicEdition();
|
||||
} catch (error) { $('public-document-meta').textContent = error.message || 'Документ недоступен.'; }
|
||||
} catch (error) { if (selectedPublicDocument === result) $('document-facts').textContent = error.message || 'Документ недоступен.'; }
|
||||
}
|
||||
$('document-edition').addEventListener('change', loadPublicEdition); $('document-language').addEventListener('change', loadPublicEdition);
|
||||
async function initSearchPage() { if (reviewRoute || filterCatalogs.length) return; const params = new URLSearchParams(location.search); $('public-query').value = params.get('q') || ''; $('public-language').value = params.get('language') || 'ru'; $('sort-order').value = params.get('sort') || 'relevance'; $('date-from').value = params.get('date_from') || ''; $('date-to').value = params.get('date_to') || ''; const response = await apiFetch(`/search/filters?language=${encodeURIComponent($('public-language').value)}`); if (response.ok) renderFilterCatalogs(await response.json()); for (const [key,, parameter] of filterFields) { const value = params.get(parameter); const input = value && document.querySelector(`input[name="filter-${parameter}"][value="${CSS.escape(value)}"]`); if (input) input.checked = true; } const query = params.get('q'); if (query) runSearch(Number(params.get('page') || 1)); }
|
||||
async function initSearchPage() {
|
||||
if (reviewRoute) return;
|
||||
const params = new URLSearchParams(location.search);
|
||||
$('public-query').value = params.get('q') || '';
|
||||
$('public-language').value = params.get('language') || 'ru';
|
||||
$('sort-order').value = params.get('sort') || 'relevance';
|
||||
$('date-from').value = params.get('date_from') || '';
|
||||
$('date-to').value = params.get('date_to') || '';
|
||||
syncDateHint('date-from'); syncDateHint('date-to');
|
||||
const uiLanguage = (() => { try { return localStorage.getItem('akyldash-ui-language') || 'ru'; } catch (_) { return 'ru'; } })();
|
||||
setUiLanguage(uiLanguage);
|
||||
setMobileView('results');
|
||||
if (!filterCatalogsLoaded) { const response = await apiFetch(`/search/filters?language=${encodeURIComponent($('public-language').value)}`); if (response.ok) renderFilterCatalogs(await response.json()); }
|
||||
document.querySelectorAll('#filter-fields input:checked').forEach((input) => { input.checked = false; });
|
||||
if ($('filter-authority')) $('filter-authority').value = '';
|
||||
for (const [key,, parameter] of filterFields) {
|
||||
const value = params.get(parameter);
|
||||
if (parameter === 'authority') { const select = $('filter-authority'); if (select) select.value = value || ''; }
|
||||
else { const input = value && document.querySelector(`input[name="filter-${parameter}"][value="${CSS.escape(value)}"]`); if (input) input.checked = true; }
|
||||
}
|
||||
syncFilterChips();
|
||||
if (params.get('q')) await runSearch(Number(params.get('page') || 1), false);
|
||||
}
|
||||
function setUiLanguage(language) {
|
||||
uiLanguage = language === 'ky' ? 'ky' : 'ru';
|
||||
const ky = uiLanguage === 'ky';
|
||||
$('ui-ru').setAttribute('aria-pressed', String(!ky)); $('ui-ky').setAttribute('aria-pressed', String(ky));
|
||||
document.documentElement.lang = ky ? 'ky' : 'ru';
|
||||
const setLabel = (selector, value) => { const label = document.querySelector(selector); if (label?.firstChild?.nodeType === Node.TEXT_NODE) label.firstChild.textContent = `${value} `; };
|
||||
const setLabels = (selector, values) => document.querySelectorAll(selector).forEach((label, index) => { if (label.firstChild?.nodeType === Node.TEXT_NODE) label.firstChild.textContent = `${values[index]} `; });
|
||||
const setText = (id, key) => { if ($(id)) $(id).textContent = copy(key); };
|
||||
setText('search-nav', 'search'); setText('sources-open', 'sources'); setText('filters-heading', 'filters');
|
||||
setText('clear-filters', 'clear'); setText('mobile-filters-open', 'filters'); setText('mobile-filters-close', 'close');
|
||||
setText('search-results-heading', 'results'); setText('document-print', 'print');
|
||||
$('search-account-open').setAttribute('aria-label', ky ? 'Жеке кабинет' : 'Личный кабинет'); $('search-account-open').title = $('search-account-open').getAttribute('aria-label');
|
||||
$('public-search-form').querySelector('button[type="submit"]').textContent = ky ? 'Издөө' : 'Найти';
|
||||
$('public-search-form').querySelector('label[for="public-query"]').textContent = copy('queryLabel');
|
||||
$('public-query').placeholder = copy('queryPlaceholder'); $('clear-query').setAttribute('aria-label', copy('clearQuery'));
|
||||
$('search-page').querySelector('h1').textContent = copy('queryLabel');
|
||||
document.querySelector('.search__brand-description').textContent = copy('brand');
|
||||
document.querySelector('.search__ui-language').setAttribute('aria-label', ky ? 'Туюнтма тили' : 'Язык интерфейса');
|
||||
$('public-language').setAttribute('aria-label', copy('searchLanguage'));
|
||||
$('public-language').options[0].textContent = copy('russian'); $('public-language').options[1].textContent = copy('kyrgyz');
|
||||
$('sort-order').options[0].textContent = copy('relevance'); $('sort-order').options[1].textContent = copy('dateSort');
|
||||
setLabel('.search__sort-label', copy('sort')); setText('result-count', 'enterQuery');
|
||||
document.querySelector('.search__date-label').textContent = copy('date');
|
||||
document.querySelectorAll('.search__date-placeholder')[0].textContent = copy('from'); document.querySelectorAll('.search__date-placeholder')[1].textContent = copy('to');
|
||||
$('date-from').setAttribute('aria-label', ky ? 'Кабыл алынган күндөн' : 'Дата принятия от'); $('date-to').setAttribute('aria-label', ky ? 'Кабыл алынган күнгө чейин' : 'Дата принятия до');
|
||||
$('page-prev').textContent = copy('previous'); $('page-next').textContent = copy('next');
|
||||
setText('document-back', 'back'); setText('document-expand', 'expand'); $('document-expand').setAttribute('aria-label', copy('expand'));
|
||||
const crumbs = document.querySelector('.search__breadcrumbs').children; crumbs[0].textContent = copy('document');
|
||||
if (!selectedPublicDocument) { setText('document-breadcrumb', 'chooseResult'); setText('document-facts', 'chooseDocument'); setText('public-document-heading', 'document'); }
|
||||
setLabel('.search__document-language', ky ? 'Тили' : 'Язык'); $('document-language').setAttribute('aria-label', ky ? 'Документтин тили' : 'Язык документа');
|
||||
$('document-toc').querySelector('summary').textContent = ky ? 'Мазмуну' : 'Содержание';
|
||||
$('document-edition').setAttribute('aria-label', ky ? 'Редакциясы' : 'Редакция документа');
|
||||
$('document-find').previousElementSibling.textContent = copy('find'); $('document-find').placeholder = copy('findPlaceholder');
|
||||
$('document-find-prev').setAttribute('aria-label', ky ? 'Мурунку дал келүү' : 'Предыдущее совпадение'); $('document-find-next').setAttribute('aria-label', ky ? 'Кийинки дал келүү' : 'Следующее совпадение');
|
||||
$('document-source').textContent = copy('source');
|
||||
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 || '';
|
||||
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 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;
|
||||
setLabels('#password-form label', [modal.currentPassword, modal.newPassword, modal.confirmPassword]); document.querySelector('#password-form button').textContent = modal.change;
|
||||
$('dialog-close').textContent = $('sources-close').textContent = modal.close;
|
||||
$('sources-heading').textContent = modal.sourcesHeading;
|
||||
document.querySelector('#sources-dialog p:first-of-type').textContent = modal.sourceIntro;
|
||||
document.querySelector('#sources-dialog p:nth-of-type(2)').firstChild.textContent = `${modal.officialSource} `;
|
||||
document.querySelector('#sources-dialog a').textContent = modal.sourceName;
|
||||
document.querySelectorAll('.search__opened').forEach((item) => { item.textContent = copy('opened'); });
|
||||
if (documentMatches.length) $('document-find-count').textContent = `${activeDocumentMatch + 1} ${ky ? 'ичинен' : 'из'} ${documentMatches.length}`;
|
||||
else if ($('document-find').value.trim()) $('document-find-count').textContent = copy('noMatches');
|
||||
const resultTotal = $('public-results').querySelector('.review__empty') ? 0 : $('public-results').children.length;
|
||||
if ($('public-query').value.trim()) {
|
||||
$('result-count').textContent = `${resultTotal} ${resultTotal === 1 ? copy('oneResult') : copy('manyResults')}`;
|
||||
$('public-status').textContent = resultTotal ? `${copy('resultsFor')} «${$('public-query').value.trim()}».` : `«${$('public-query').value.trim()}»: ${copy('noDocuments')}`;
|
||||
} else {
|
||||
setText('result-count', 'enterQuery'); setText('public-status', 'queryRequired');
|
||||
}
|
||||
const empty = $('public-results').querySelector('.review__empty');
|
||||
if (empty) { empty.querySelector('strong').textContent = copy('noResults'); empty.lastChild.textContent = copy('noResultsHint'); }
|
||||
const pageMatch = $('page-label').textContent.match(/\d+/); if (pageMatch) $('page-label').textContent = `${copy('page')} ${pageMatch[0]}`;
|
||||
if (filterCatalogsLoaded) {
|
||||
const selected = selectedFilters(); renderFilterCatalogs(filterCatalogs);
|
||||
for (const [key, , parameter] of filterFields) {
|
||||
if (parameter === 'authority') { if ($('filter-authority')) $('filter-authority').value = selected[parameter]; }
|
||||
else { const input = [...document.querySelectorAll(`input[name="filter-${parameter}"]`)].find((item) => item.value === selected[parameter]); if (input) input.checked = true; }
|
||||
}
|
||||
syncFilterChips();
|
||||
}
|
||||
try { localStorage.setItem('akyldash-ui-language', ky ? 'ky' : 'ru'); } catch (_) {}
|
||||
}
|
||||
$('ui-ru').addEventListener('click', () => setUiLanguage('ru'));
|
||||
$('ui-ky').addEventListener('click', () => setUiLanguage('ky'));
|
||||
$('public-search-form').addEventListener('submit', (event) => { event.preventDefault(); runSearch(1); });
|
||||
$('sort-order').addEventListener('change', () => runSearch(1)); $('date-from').addEventListener('change', () => runSearch(1)); $('date-to').addEventListener('change', () => runSearch(1)); $('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); });
|
||||
$('clear-query').addEventListener('click', () => { $('public-query').value = ''; $('public-query').focus(); $('public-query').dispatchEvent(new Event('input', {bubbles: true})); });
|
||||
$('sort-order').addEventListener('change', () => runSearch(1));
|
||||
['date-from', 'date-to'].forEach((id) => { $(id).addEventListener('input', () => syncDateHint(id)); $(id).addEventListener('change', () => { syncDateHint(id); runSearch(1); }); });
|
||||
$('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; }); $('date-from').value = ''; $('date-to').value = ''; $('sort-order').value = 'relevance'; runSearch(1); });
|
||||
async function loadUsers() {
|
||||
$('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('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);
|
||||
$('search-filters').addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') { closeMobileFilters(); return; }
|
||||
if (event.key !== 'Tab' || !$('search-filters').classList.contains('is-open')) return;
|
||||
const controls = [...$('search-filters').querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), [href]')].filter((node) => node.offsetParent !== null);
|
||||
if (!controls.length) return;
|
||||
const first = controls[0]; const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||
});
|
||||
$('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(); });
|
||||
$('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());
|
||||
$('sources-close').addEventListener('click', () => $('sources-dialog').close());
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Tab') return;
|
||||
const dialog = document.querySelector('dialog[open]');
|
||||
if (!dialog) return;
|
||||
const controls = [...dialog.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')].filter((item) => item.getClientRects().length && getComputedStyle(item).visibility !== 'hidden');
|
||||
if (!controls.length) { event.preventDefault(); dialog.focus(); return; }
|
||||
const active = document.activeElement;
|
||||
if (!dialog.contains(active) || (event.shiftKey && active === controls[0]) || (!event.shiftKey && active === controls.at(-1))) {
|
||||
event.preventDefault(); (event.shiftKey ? controls.at(-1) : controls[0]).focus();
|
||||
}
|
||||
});
|
||||
async function loadUsers() {
|
||||
$('users-error').textContent = '';
|
||||
const response = await apiFetch('/admin/users');
|
||||
if (!response.ok) { $('users-error').textContent = 'Не удалось загрузить список пользователей.'; return; }
|
||||
@@ -287,10 +859,10 @@
|
||||
$('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(); });
|
||||
$('account-open').addEventListener('click', () => $('account-dialog').showModal());
|
||||
$('users-open').addEventListener('click', async () => { await loadUsers(); $('users-dialog').showModal(); });
|
||||
['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(); }));
|
||||
document.querySelectorAll('[data-close]').forEach((button) => button.addEventListener('click', () => $(button.dataset.close).close()));
|
||||
$('logout').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(); });
|
||||
['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); })();
|
||||
const setStatus = (text, error = false) => { $('status').textContent = text; $('status').classList.toggle('review__status--error', error); $('status').setAttribute('role', error ? 'alert' : 'status'); $('status').setAttribute('aria-live', error ? 'assertive' : 'polite'); };
|
||||
const setError = (text) => setStatus(text, true);
|
||||
@@ -321,9 +893,43 @@
|
||||
$('dialog-close').addEventListener('click', () => { $('document-dialog').close(); if (state.selected) state.selected.focus(); });
|
||||
let tutorialStep = 0; let tutorialReturnFocus = null;
|
||||
function clearTourTarget() { $('tutorial-spotlight').hidden = true; $('document-panel').classList.remove('review__document--tour-visible'); }
|
||||
function renderTutorial() { const step = TOUR_STEPS[tutorialStep]; const target = $(step.target); if (step.target === 'document-panel' && getComputedStyle(target).display === 'none') target.classList.add('review__document--tour-visible'); target.scrollIntoView({block: 'nearest', inline: 'nearest'}); const bounds = target.getBoundingClientRect(); $('tutorial-spotlight').style.cssText = `inset-block-start: ${Math.max(8, bounds.top - 4)}px; inset-inline-start: ${Math.max(8, bounds.left - 4)}px; inline-size: ${bounds.width + 8}px; block-size: ${bounds.height + 8}px;`; $('tutorial-spotlight').hidden = false; $('tutorial-heading').textContent = step.title; $('tutorial-text').textContent = step.text; $('tutorial-progress').textContent = step.label; $('tutorial-next').textContent = tutorialStep === TOUR_STEPS.length - 1 ? 'Понятно' : 'Далее'; }
|
||||
function renderTutorial() {
|
||||
const step = TOUR_STEPS[tutorialStep]; const target = $(step.target);
|
||||
$('document-panel').classList.toggle('review__document--tour-visible', step.target === 'document-panel' && matchMedia('(max-width: 800px)').matches);
|
||||
const dialog = $('tutorial-dialog');
|
||||
const focusedControl = dialog.open && dialog.contains(document.activeElement) ? document.activeElement.id : 'tutorial-next';
|
||||
if (dialog.open) dialog.close();
|
||||
target.scrollIntoView({block: 'nearest', inline: 'nearest'});
|
||||
const bounds = target.getBoundingClientRect();
|
||||
$('tutorial-spotlight').style.cssText = `inset-block-start: ${Math.max(8, bounds.top - 4)}px; inset-inline-start: ${Math.max(8, bounds.left - 4)}px; inline-size: ${bounds.width + 8}px; block-size: ${bounds.height + 8}px;`;
|
||||
$('tutorial-spotlight').hidden = false;
|
||||
$('tutorial-heading').textContent = step.title; $('tutorial-text').textContent = step.text; $('tutorial-progress').textContent = step.label;
|
||||
$('tutorial-next').textContent = tutorialStep === TOUR_STEPS.length - 1 ? 'Понятно' : 'Далее';
|
||||
dialog.showModal();
|
||||
const gap = 12; const edge = 12;
|
||||
const width = dialog.offsetWidth; const height = dialog.offsetHeight;
|
||||
const viewportWidth = document.documentElement.clientWidth; const viewportHeight = window.innerHeight;
|
||||
const clamp = (value, max) => Math.max(edge, Math.min(value, max - edge));
|
||||
const placements = [
|
||||
{fits: viewportWidth - bounds.right >= width + gap + edge, left: bounds.right + gap, top: bounds.top + bounds.height / 2 - height / 2},
|
||||
{fits: bounds.left >= width + gap + edge, left: bounds.left - width - gap, top: bounds.top + bounds.height / 2 - height / 2},
|
||||
{fits: viewportHeight - bounds.bottom >= height + gap + edge, left: bounds.left + bounds.width / 2 - width / 2, top: bounds.bottom + gap},
|
||||
{fits: bounds.top >= height + gap + edge, left: bounds.left + bounds.width / 2 - width / 2, top: bounds.top - height - gap}
|
||||
];
|
||||
const placement = placements.find((item) => item.fits) || placements.reduce((best, item) => {
|
||||
const overlap = (candidate) => {
|
||||
const left = clamp(candidate.left, viewportWidth - width); const top = clamp(candidate.top, viewportHeight - height);
|
||||
return Math.max(0, Math.min(left + width, bounds.right) - Math.max(left, bounds.left)) * Math.max(0, Math.min(top + height, bounds.bottom) - Math.max(top, bounds.top));
|
||||
};
|
||||
return overlap(item) < overlap(best) ? item : best;
|
||||
});
|
||||
dialog.style.left = `${clamp(placement.left, viewportWidth - width)}px`;
|
||||
dialog.style.top = `${clamp(placement.top, viewportHeight - height)}px`;
|
||||
$(focusedControl)?.focus();
|
||||
}
|
||||
function closeTutorial() { clearTourTarget(); $('tutorial-dialog').close(); markTourSeen(); tutorialReturnFocus?.focus(); }
|
||||
function openTutorial() { tutorialReturnFocus = document.activeElement; tutorialStep = 0; renderTutorial(); $('tutorial-dialog').showModal(); $('tutorial-next').focus(); }
|
||||
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; } });
|
||||
document.addEventListener('input', saveDraft); document.addEventListener('change', saveDraft);
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<circle cx="24" cy="24" r="20" fill="#F7F2E8" stroke="#9D2633" stroke-width="3"/>
|
||||
<path d="M24 5v38M5 24h38M11 11l26 26M37 11 11 37" fill="none" stroke="#9D2633" stroke-width="3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 276 B |
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) NotoSans-Italic[wdth,wght].ttf: Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) NotoSerif-Italic[wdth,wght].ttf: Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -24,6 +24,19 @@ class SearchApiTest(unittest.TestCase):
|
||||
(edition / "ru/content.txt").write_text("Текст\n", encoding="utf-8")
|
||||
return Api("http://opensearch:9200", "current", root)
|
||||
|
||||
def test_static_font_assets_are_allowlisted(self):
|
||||
content_type, body = Api.static_asset("/static/fonts/noto-sans-cyrillic-ext.woff2")
|
||||
self.assertEqual(content_type, "font/woff2")
|
||||
self.assertTrue(body.startswith(b"wOF2"))
|
||||
content_type, body = Api.static_asset("/static/favicon.svg")
|
||||
self.assertEqual(content_type, "image/svg+xml")
|
||||
self.assertIn(b"<svg", body)
|
||||
content_type, body = Api.static_asset("/static/favicon-32.png")
|
||||
self.assertEqual(content_type, "image/png")
|
||||
self.assertTrue(body.startswith(b"\x89PNG\r\n\x1a\n"))
|
||||
with self.assertRaisesRegex(ApiError, "static asset not found"):
|
||||
Api.static_asset("/static/fonts/../../DESIGN.md")
|
||||
|
||||
def test_search_pagination_filters_and_highlight(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = self.make_api(Path(temporary))
|
||||
|
||||
@@ -45,4 +45,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.0 · Frontend v0.2.3
|
||||
@@ -137,7 +137,7 @@ protected file metadata remain unverified.
|
||||
removed afterward. This does not verify VPS startup, DNS, or public TLS.
|
||||
- The app has no migration framework or SMTP setup. Repository versions on this
|
||||
branch are:
|
||||
backend `0.12.2`, frontend `0.1.2`, Telegram bot `0.2.2` (the bot is not
|
||||
backend `0.13.0`, frontend `0.2.0`, Telegram bot `0.2.2` (the bot is not
|
||||
deployed on this VPS).
|
||||
|
||||
## Corpus readiness (2026-10-18)
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
# План точного воспроизведения поискового интерфейса
|
||||
|
||||
Дата: 23 октября 2026 года.
|
||||
|
||||
Статус: основная реализация и локальная приёмка выполнены в
|
||||
`feature/frontend-pixel-perfect`; после первичного прогона скорректированы
|
||||
навигация, плотность результатов, заголовок документа, fixture-контент,
|
||||
клавиатурный trap для диалогов и компактные позиционируемые подсказки
|
||||
лаборатории. Скриншоты, покропленные сравнения и расширенный
|
||||
Playwright-сценарий находятся в репозитории; см. [отчёт](visual-review/README.md).
|
||||
Локальные тесты и guards из CI прошли. Ожидают runner: проверки Compose/Caddy и
|
||||
сборка production images. Независимый review запускает владелец. Скринридер и
|
||||
реальный корпус всё ещё требуют проверки.
|
||||
|
||||
## 1. Цель, границы и источники истины
|
||||
|
||||
Привести существующую авторизованную поисковую страницу `/` к композиции
|
||||
утверждённого изображения «Акылдаш · Түндүк», сохранив реальные API,
|
||||
авторизацию, безопасность и отдельный сценарий лаборатории `/review`.
|
||||
|
||||
Источники:
|
||||
|
||||
- [DESIGN.md](../../DESIGN.md) — единственная нормативная дизайн-система:
|
||||
токены, типографика, доступность, постоянные правила компонентов.
|
||||
- [Исходный концепт](../design/tunduk-desktop-concept.png) — визуальный
|
||||
эталон композиции. Вложение пользователя 1280×800 визуально соответствует
|
||||
этому сохранённому изображению более высокого разрешения.
|
||||
- [План frontend СПС](frontend-search-sps-plan.md) — продуктовый контекст.
|
||||
- [Readiness plan](frontend-design-readiness-plan.md) — отдельные условия
|
||||
готовности публичного продукта, которые этот план не отменяет.
|
||||
|
||||
Изображение задаёт композицию и пропорции; `DESIGN.md` задаёт токены,
|
||||
шрифты и ограничения. До реализации фиксировать расхождения, а не молча
|
||||
изменять палитру, шрифты или доступность ради совпадения растровых пикселей.
|
||||
Временные задачи и результаты измерений остаются здесь; новые постоянные
|
||||
правила добавляются в `DESIGN.md` без копирования всей дизайн-системы.
|
||||
|
||||
Не входят: публичный запуск, изменение прав доступа, ранжирования, корпуса,
|
||||
индекса, production, регистрация, аналитика, сравнение редакций, переход
|
||||
на новый frontend framework. Содержимое демонстрационного документа
|
||||
не становится юридическими данными продукта.
|
||||
|
||||
Figma не является обязательной зависимостью: сохранённого эталона достаточно.
|
||||
При необходимости совместного редактирования можно подготовить отдельный
|
||||
Figma-макет с Auto Layout и переменными, ссылающийся на эти же правила;
|
||||
это не заменяет проверку фактической вёрстки в браузере.
|
||||
|
||||
## 2. Исходное состояние до реализации
|
||||
|
||||
Таблица ниже фиксирует базовую проверку до реализации, выполненную по коду и
|
||||
изображениям без браузера; строки не описывают текущее состояние. Последующие
|
||||
результаты Playwright сохранены в [отчёте визуальной приёмки](visual-review/README.md).
|
||||
Они относятся к локальному UI со стабильными fixture-ответами и не подтверждают
|
||||
состояние production или актуального OpenSearch.
|
||||
|
||||
Основные файлы:
|
||||
|
||||
- `backend/search/review.html` — разметка, встроенные CSS и JavaScript обоих
|
||||
сценариев, footer и авторизация.
|
||||
- `backend/search/api.py` — выдача страницы для `/` и `/review`, API поиска,
|
||||
фильтров и редакций.
|
||||
- `backend/test_search_api.py` — существующие проверки API.
|
||||
- `.gitea/workflows/ci.yaml` — обязательные проверки и сборка образов.
|
||||
|
||||
| Проблема | Подтверждение в реализации | Требуемый результат |
|
||||
|---|---|---|
|
||||
| Другая шапка | Общая `.review__header`, поиск отдельной формой ниже | Бренд, поиск и язык в одной верхней строке |
|
||||
| Нет второй строки эталона | Нет общей полосы навигации и активных фильтров | Навигация и удаляемые фильтры под шапкой |
|
||||
| Другие пропорции | `.search__layout`: `230px / .9fr / 1.2fr`, `min-height: 65vh` | Измеренная сетка и корректная высота рабочей области |
|
||||
| Не тот вид фильтров | Все справочники выводятся radio-списками, даты вертикально | Компактные группы, select органа, даты рядом |
|
||||
| Выбранный результат неполон | Есть только фоновое выделение | Фон, рамка, подпись «Открыт», точные отступы |
|
||||
| Неполная панель документа | Есть заголовок, select редакции/языка, метаданные и текст | Полная иерархия и работающие действия эталона |
|
||||
| Смешаны стили документа | Объявлен `.search__document-body`, статья использует `.review__document-body` | Одна корректная область стилей поискового документа |
|
||||
| Шрифты не поставляются | Noto указаны в CSS, локальные файлы не найдены | Проверенная загрузка локальных шрифтов |
|
||||
| Нет фирменных ресурсов | Знак сделан CSS-кружком; орнамент и favicon отсутствуют | SVG-знак, орнамент, favicon |
|
||||
| Лабораторная панель на поиске | `.review__actions` вне скрываемого `<main>`; для `/` не скрывается | Оценивание только на `/review` |
|
||||
| Счётчик вводит в заблуждение | Выводится `payload.results.length`; API не возвращает total | Явное число на странице либо отдельно реализованный total |
|
||||
| Нет визуального контроля | Найденные тесты проверяют API, не геометрию страницы | Воспроизводимые браузерные снимки и сравнение |
|
||||
|
||||
Это исходное описание состояния до реализации. Сейчас переключатель RU/KY,
|
||||
отдельный язык поиска, реальные редакции и язык документа, оглавление, поиск
|
||||
по тексту документа, разворачивание, печать и отдельный маршрут `/review`
|
||||
реализованы. Подробности текущей локальной приёмки и её ограничения приведены
|
||||
в [отчёте](visual-review/README.md); оставшиеся непроверенные состояния не
|
||||
считаются принятыми только потому, что чекбоксы плана перечислены ниже.
|
||||
|
||||
## 3. Этап 0 — воспроизводимый эталон
|
||||
|
||||
- [x] Перед реализацией перечитать актуальные инструкции, `DESIGN.md`,
|
||||
реализацию маршрутов и обязательные проверки; использовать установленный
|
||||
Ponytail согласно правилам репозитория.
|
||||
- [x] Сохранить чужие незакоммиченные изменения, создать тематическую
|
||||
`feature/` ветку и не переносить посторонние изменения в PR.
|
||||
- [x] Зафиксировать основной viewport 1280×800 CSS px, браузер и его версию,
|
||||
ОС, масштаб 100%, device scale factor и остальные viewport в отчёте.
|
||||
- [ ] Получить исходный снимок текущего интерфейса на тех же размерах.
|
||||
- [x] Подготовить детерминированные тестовые данные: запрос «налог на прибыль»,
|
||||
три результата из изображения, выбран первый, открыта соответствующая
|
||||
демонстрационная редакция, фильтр статуса и исходная прокрутка.
|
||||
- [x] Изолировать fixture от реального режима и пометить демонстрационное
|
||||
содержимое только в тестовом сценарии. Не добавлять подмену юридических данных
|
||||
в production-путь.
|
||||
- [x] Нормализовать сохранённое изображение до 1280×800 без изменения
|
||||
пропорций и сохранить параметры подготовки эталона.
|
||||
- [x] Записать конфликты изображения с нормативной типографикой и токенами;
|
||||
согласованные ограничения приведены в отчёте.
|
||||
|
||||
Критерий завершения: один и тот же тестовый сценарий воспроизводится
|
||||
независимо от состояния OpenSearch и текущей реальной выдачи; реальный режим
|
||||
продолжает использовать API. Авторизация не ослабляется ради снимков.
|
||||
|
||||
## 4. Этап 1 — карта геометрии
|
||||
|
||||
Эталон 1586×992 пропорционально вписан в 1280×800; фактические значения
|
||||
сверены с Playwright при device scale factor 1. Допуск границ крупных зон —
|
||||
около 1 CSS px.
|
||||
|
||||
| Область | Нормализованный эталон | Реализация |
|
||||
|---|---:|---:|
|
||||
| Шапка и строка навигации | y ≈ 0–126 px | y = 0–127 px |
|
||||
| Рабочая область | y ≈ 126–744 px | y = 127–744 px |
|
||||
| Footer | y ≈ 744–800 px | y = 744–800 px |
|
||||
| Фильтры | x ≈ 0–226 px | x = 0–226 px |
|
||||
| Результаты | x ≈ 226–648 px | x = 226–648 px |
|
||||
| Документ | x ≈ 648–1280 px | x = 648–1280 px |
|
||||
|
||||
Внутренние контрольные координаты и ограничения см. в
|
||||
[отчёте визуальной приёмки](visual-review/README.md); там же есть overlay и
|
||||
разница по каждой крупной области.
|
||||
|
||||
- [x] Измерить крупные границы, основную навигацию и ключевые элементы
|
||||
документа по исходнику; фактические значения записаны в отчёте.
|
||||
- [ ] Измерить все внутренние отступы, размеры полей и кнопок, межстрочные
|
||||
интервалы и разделители по всем областям.
|
||||
- [x] Зафиксировать адаптивные точки и проверить сетку на 1440/1024/390/320 px;
|
||||
реальные длинные данные ещё не проверены.
|
||||
- [ ] Разделить метрики повторяемых элементов и высоты, зависящие от текста.
|
||||
Не выравнивать принудительно высоту всех результатов или разделов.
|
||||
- [ ] Определить владельца прокрутки каждой области, поведение footer
|
||||
при коротком содержимом и доступность длинного документа.
|
||||
|
||||
Критерий завершения: геометрия описана измерениями и адаптивными правилами,
|
||||
а не независимыми приблизительными настройками каждого блока.
|
||||
|
||||
## 5. Этап 2 — разделение сценариев и базовых стилей
|
||||
|
||||
- [x] Разделить композиции поиска и лаборатории, сохранить маршруты,
|
||||
авторизацию, кабинет, управление пользователями, обучение и черновики.
|
||||
- [x] Ограничить лабораторные элементы маршрутом `/review`, включая
|
||||
панель проверяющего и сохранения; маршрут проверен браузерным тестом.
|
||||
- [ ] Использовать общий слой семантических токенов и базовых элементов;
|
||||
стили поискового экрана изолировать BEM-классами.
|
||||
- [ ] Устранить дублирующие и неиспользуемые правила, исправить класс статьи.
|
||||
- [ ] Убрать зависимость шапки и footer поиска от лабораторного layout.
|
||||
- [ ] Если CSS/JS/ресурсы выносятся из HTML, добавить ограниченную выдачу
|
||||
static-файлов с MIME, защитой от обхода путей и сохранением защитных
|
||||
заголовков. Убедиться, что ресурсы входят в production-образ.
|
||||
|
||||
Ожидаемые зоны изменений: `backend/search/review.html`, при необходимости
|
||||
каталог static-ресурсов рядом с ним и `backend/search/api.py`.
|
||||
Не вводить Next.js, React или новый сборщик только ради этой задачи.
|
||||
|
||||
Критерий завершения: оба сценария работают независимо, общие CSS не меняют
|
||||
их композицию случайным образом; существующие права доступа сохранены.
|
||||
|
||||
## 6. Этап 3 — шрифты, знак, орнамент и favicon
|
||||
|
||||
- [ ] Поставить локальные WOFF2 Noto Sans 400/500/600 и Noto Serif 600/700
|
||||
с лицензиями; исключить случайное синтетическое начертание.
|
||||
- [x] Проверить загрузку, `font-display`, кириллицу и `Ө ө Ү ү Ң ң`.
|
||||
Визуальные снимки выполнять после `document.fonts.ready`.
|
||||
- [ ] Подготовить SVG-знак по визуальному направлению референса;
|
||||
сверить размер, толщину линий и положение относительно названия.
|
||||
- [ ] Подготовить отдельный симметричный золотистый SVG-орнамент,
|
||||
`aria-hidden`, без перекрытия управления и текста.
|
||||
- [x] Подготовить favicon SVG и растровый fallback 32×32; подключить
|
||||
в `<head>`, проверить выдачу и MIME. Масштабирование SVG проверено визуально.
|
||||
- [ ] Подготовить единые SVG-иконки поиска, очистки, календаря, печати,
|
||||
оглавления, разворачивания и стрелок; иконкам-кнопкам дать доступные имена.
|
||||
- [ ] Перенести значения в токены, не размножать HEX по компонентам.
|
||||
|
||||
Критерий завершения: шрифты реально загружены, знак и орнамент являются
|
||||
ресурсами, favicon отображается; нет CSS-заглушек вместо фирменной графики.
|
||||
|
||||
## 7. Этап 4 — сборка композиции
|
||||
|
||||
Работать от крупных областей к деталям; сначала геометрия, затем типографика.
|
||||
|
||||
- [ ] Шапка: знак, название, разделитель, подпись продукта, поле с подписью
|
||||
и очисткой, «Найти», язык интерфейса. Служебные действия доступны компактно.
|
||||
- [ ] Вторая строка: «Поиск», «Об источниках», удаляемые фильтры;
|
||||
демонстрационная подпись только для fixture. «Об источниках» открывает
|
||||
реальные сведения о происхождении данных, а не пустую заглушку.
|
||||
- [ ] Три панели: непрерывные границы, правильные фоны и пропорции,
|
||||
отсутствие лишнего свободного ряда статуса в стабильном состоянии.
|
||||
Сообщения для скринридера сохраняются.
|
||||
- [ ] Фильтры: заголовок, текстовый сброс, статус, вид, орган, даты рядом,
|
||||
орнамент в свободной нижней части без перекрытия длинного содержимого.
|
||||
- [ ] Результаты: заголовок и честный счётчик, компактная сортировка,
|
||||
строки выдачи, выбранная строка с рамкой/фоном/«Открыт», метаданные,
|
||||
безопасная подсветка выдержки, пагинация.
|
||||
- [ ] Документ: breadcrumbs и разворачивание, отдельная роль заголовка,
|
||||
компактные метаданные, язык, действия, поиск внутри, поле чтения.
|
||||
- [ ] Footer: бренд слева, фактические версии frontend/backend справа;
|
||||
не копировать устаревшую backend-версию и слово «макет» из изображения.
|
||||
|
||||
Критерий завершения: порядок, вложенность и пропорции всех крупных зон
|
||||
соответствуют эталону; нет лишних лабораторных блоков.
|
||||
|
||||
## 8. Этап 5 — рабочее поведение и интеграция
|
||||
|
||||
- [ ] Сохранить поиск, сортировку, пагинацию, загрузку редакций и языков.
|
||||
- [x] Синхронизировать фильтры с URL и удалять отдельный фильтр без сброса
|
||||
остальных; сохранение второго фильтра проверяется Playwright.
|
||||
- [ ] Проверить восстановление и историю навигации через прямые ссылки и Back/Forward.
|
||||
- [ ] Согласовать вид фильтров с контрактом: текущий API принимает одиночные
|
||||
значения. Не имитировать множественный выбор checkbox без поддержки API.
|
||||
Если нужен multiselect, отдельно описать и проверить расширение контракта.
|
||||
- [ ] Разделить язык интерфейса, поиска и текста документа. Для RU/KY
|
||||
перевести подписи, статусы и ошибки; смена UI не подменяет текст редакции.
|
||||
- [ ] Не выдавать число на странице за общее: использовать явную подпись
|
||||
либо отдельно реализовать достоверный total с проверкой его семантики.
|
||||
- [ ] Построить оглавление по реальным заголовкам, с корректными якорями;
|
||||
при отсутствии структуры показать понятное состояние.
|
||||
- [ ] Реализовать поиск по текстовым узлам документа: ввод/очистка,
|
||||
подсветка, активное совпадение, счётчик, предыдущий/следующий,
|
||||
прокрутка и состояние нулевого результата. Не повреждать HTML,
|
||||
ссылки и таблицы; не вставлять пользовательский запрос через `innerHTML`.
|
||||
- [ ] Реализовать разворачивание/возврат с сохранением выбранной редакции,
|
||||
прокрутки и корректного фокуса.
|
||||
- [x] Добавить печать через print CSS: документ, его реквизиты и источник
|
||||
без поисковых и служебных панелей; служебные элементы проверены в print media.
|
||||
- [ ] Защитить смену запроса/документа/редакции от запоздавших ответов
|
||||
через отмену запроса или проверку актуальности результата.
|
||||
- [ ] Сохранить безопасный вывод очищенного документного HTML и ссылок,
|
||||
обработку ошибок и ограничения авторизации.
|
||||
|
||||
Критерий завершения: каждый видимый элемент эталона выполняет действие;
|
||||
нет декоративных кнопок, фиктивных языков и выдуманных юридических статусов.
|
||||
|
||||
## 9. Этап 6 — адаптивность, типографика и состояния
|
||||
|
||||
- [ ] Настроить размеры, веса, line-height и отступы после фиксации сетки.
|
||||
Сверить переносы названия документа, результатов и плотность абзацев.
|
||||
- [x] Проверить 1280×800 и ширины 1440/1024/390 px; дополнительно 320 px.
|
||||
Высоты и параметры окружения записаны в отчёте.
|
||||
- [ ] Проверить реальные длинные редакции, длинные названия на RU/KY и подобрать
|
||||
breakpoints на реальных данных корпуса.
|
||||
- [x] На узких экранах открывать фильтры отдельной доступной панелью,
|
||||
документ — отдельным представлением с возвратом к результатам; проверено на 390 px.
|
||||
- [x] Сокращать/убирать орнамент на телефоне; сохранять текст без
|
||||
горизонтальной прокрутки, широкие таблицы — в собственной области прокрутки.
|
||||
Проверено синтетической широкой таблицей; реальные таблицы корпуса остаются открыты.
|
||||
- [x] Проверить длинные названия, отсутствие редакций, широкую таблицу и
|
||||
широкое изображение синтетическими данными.
|
||||
- [ ] Проверить одноязычные реальные редакции, ссылки, много фильтров и
|
||||
длинный контент из реального корпуса.
|
||||
- [x] Проверить загрузку редакции, empty, search/edition error, retry,
|
||||
disabled-состояние без редакции и focus-visible; query сохраняется при ошибке.
|
||||
- [ ] Проверить hover и сценарии ошибок авторизации/администрирования.
|
||||
- [x] Проверить Tab/Shift+Tab/Escape, возврат фокуса мобильной панели и
|
||||
диалогов, а также `prefers-reduced-motion`.
|
||||
- [ ] Проверить доступные имена всех элементов, live-сообщения, контраст,
|
||||
масштабирование, скринридер и touch-области на поддерживаемых устройствах.
|
||||
|
||||
Критерий завершения: точность desktop не приводит к переполнению,
|
||||
недоступности действий или потере контекста на узком экране.
|
||||
|
||||
## 10. Этап 7 — визуальная приёмка
|
||||
|
||||
Выполнить один пакет первичных снимков всех контрольных размеров,
|
||||
собрать дефекты по областям, исправить одним пакетом и выполнить один
|
||||
подтверждающий проход. Новые циклы обосновывать конкретными оставшимися
|
||||
дефектами или замечаниями независимого reviewer, не бесконечной полировкой.
|
||||
|
||||
- [x] Дождаться шрифтов и стабильных fixture-данных, задать исходную
|
||||
прокрутку; снимки делаются после `document.fonts.ready`.
|
||||
- [x] Проверить, что снимки показывают загруженный поисковый сценарий, а не
|
||||
login, ошибку или другой маршрут.
|
||||
- [x] Для 1280×800 подготовить overlay 50%, difference image и отдельные
|
||||
сравнения шапки, фильтров, результатов, документа и footer.
|
||||
- [x] Сохранить снимки остальных размеров и отчёт с условиями, отклонениями,
|
||||
исправлениями и известными ограничениями.
|
||||
- [ ] Передать независимому reviewer запрос пользователя, эталон,
|
||||
`DESIGN.md`, этот план, diff и снимки всех проверенных размеров.
|
||||
|
||||
Критерии для детерминированного fixture при 1280×800:
|
||||
|
||||
| Проверка | Условие приёмки |
|
||||
|---|---|
|
||||
| Границы крупных зон | Отклонение от зафиксированной карты до 2 CSS px |
|
||||
| Отступы и размеры управления | Отклонение до 2 CSS px от измерений |
|
||||
| Ключевые заголовки | Совпадают переносы при согласованных шрифтах; исключения объяснены |
|
||||
| Композиция | Все зоны и элементы эталона присутствуют, порядок совпадает |
|
||||
| Ресурсы | Загружены шрифты, знак, орнамент, favicon, единые иконки |
|
||||
| Рабочий сценарий | Поиск, фильтры, редакции, действия и навигация работают |
|
||||
| Адаптивность | Нет перекрытий, обрезанного управления и общей горизонтальной прокрутки |
|
||||
| Изоляция | Нет лабораторного оценивания на поиске, `/review` без регрессий |
|
||||
|
||||
Нулевая попиксельная разница не является критерием: сглаживание текста,
|
||||
растровая природа концепта и нормативные токены дают объяснимые различия.
|
||||
Один процент сходства всего экрана не заменяет проверки каждой области.
|
||||
Достижение допусков подтверждается измерениями, а не общей оценкой «похоже».
|
||||
|
||||
## 11. Этап 8 — проверки, версия и PR
|
||||
|
||||
Этот этап относится к реализации в feature-ветке. Frontend повышен до `0.2.3`,
|
||||
backend — до `0.13.0` для allowlist-выдачи локальных шрифтов. Контракт Search
|
||||
API v1 не менялся.
|
||||
|
||||
- [x] Выполнить доступные локально проверки из `AGENTS.md` и CI: backend,
|
||||
Telegram bot, backup, deploy, host/root/verifier/audit/Compose/production
|
||||
config/runner guards, shell syntax и Playwright.
|
||||
- [ ] Получить успешный CI на назначенном runner: `docker compose config`,
|
||||
проверка Caddyfile и сборка backend/OpenSearch production images.
|
||||
- [x] Добавить проверки фактических изменений: static/favicon, браузерные
|
||||
сценарии поиска/редакций/поиска внутри/навигации и регрессии лаборатории;
|
||||
визуальное сравнение использует стабильный fixture.
|
||||
- [x] Повысить версии для совместимого функционального изменения;
|
||||
frontend/backend версии согласованы в footer.
|
||||
- [x] Выполнить push и найти существующий PR ветки через `gitea-api`;
|
||||
изменения остаются в одном PR #53.
|
||||
- [ ] После каждого push независимый reviewer проверяет полный текущий PR,
|
||||
прежние исправления и оставляет краткий PR-комментарий с явным выводом
|
||||
о наличии или отсутствии блокирующих замечаний; reviewer не редактирует код.
|
||||
- [ ] Исправить обоснованные замечания в той же ветке/PR, повторить проверки,
|
||||
commit/push и независимый review.
|
||||
- [ ] После успешных проверок и устранения блокирующих замечаний обновить
|
||||
существующий changelog, записав значимые изменения для пользователя.
|
||||
- [ ] Commit/push changelog в тот же PR, повторить обязательные проверки
|
||||
и независимый review окончательного состояния.
|
||||
|
||||
Готовность к merge: проверки проходят, финальный review не содержит
|
||||
блокирующих замечаний, changelog и версии согласованы, визуальные доказательства
|
||||
доступны reviewer. Не сливать автоматически и не развёртывать production.
|
||||
|
||||
## 12. Порядок выполнения и итоговые материалы
|
||||
|
||||
Зависимости: эталон → измерения → разделение сценариев → ресурсы → композиция
|
||||
→ поведение → адаптивность и состояния → визуальная приёмка → финальный PR.
|
||||
Не начинать микроподгонку кнопок до исправления общей геометрии.
|
||||
|
||||
Ожидаемые материалы реализации:
|
||||
|
||||
- обновлённая поисковая страница и сохранённый сценарий лаборатории;
|
||||
- локальные шрифты с лицензиями, SVG-знак, орнамент, favicon и иконки;
|
||||
- детерминированный тестовый сценарий и воспроизводимый способ снимков;
|
||||
- карта измерений, снимки, overlay/diff и отчёт приёмки;
|
||||
- результаты обязательных проверок и независимого review;
|
||||
- версии, changelog и один PR без автоматического merge.
|
||||
|
||||
Чекбоксы отмечать только после выполнения и проверки. Локальные проверки,
|
||||
которые реально выполнены, зафиксированы в отчёте visual-review.
|
||||
Этот документ не подтверждает готовность API, публичного продукта или
|
||||
production.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Приёмка поискового экрана и лаборатории
|
||||
|
||||
Проверка выполнена 23 октября 2026 года в Chromium 156.0.8078.4, Playwright
|
||||
1.64.1, headless, device scale factor 1. Макеты проверены при 1280×800,
|
||||
1440×900, 1024×900 и 390×844 CSS px; reflow дополнительно проверен при
|
||||
320×800 CSS px. Среда: Ubuntu 26.04 LTS под WSL2 Linux x86_64, Chromium
|
||||
156.0.8078.4 headless, масштаб 100%, device scale factor 1.
|
||||
|
||||
## Снимки
|
||||
|
||||
- [1280×800 — основной экран](desktop-1280x800.png)
|
||||
- [1440 px](desktop-1440px.png)
|
||||
- [1024 px](desktop-1024px.png)
|
||||
- [390 px — мобильный просмотр документа](mobile-390px.png)
|
||||
- [Лаборатория `/review` — подсказка на desktop](review-tour-desktop.png)
|
||||
- [Лаборатория `/review` — подсказка на mobile](review-tour-mobile.png)
|
||||
- [Overlay 50% с приложенным референсом](overlay-50.png)
|
||||
- [Карта абсолютной разницы пикселей](pixel-difference.png)
|
||||
|
||||
Покропленные сравнения областей для отдельного просмотра:
|
||||
|
||||
| Область | Overlay | Разница пикселей |
|
||||
|---|---|---|
|
||||
| Шапка и навигация | [Overlay](region-header-overlay.png) | [Diff](region-header-difference.png) |
|
||||
| Фильтры | [Overlay](region-filters-overlay.png) | [Diff](region-filters-difference.png) |
|
||||
| Результаты | [Overlay](region-results-overlay.png) | [Diff](region-results-difference.png) |
|
||||
| Документ | [Overlay](region-document-overlay.png) | [Diff](region-document-difference.png) |
|
||||
| Footer | [Overlay](region-footer-overlay.png) | [Diff](region-footer-difference.png) |
|
||||
|
||||
## Фактическая проверка
|
||||
|
||||
- Снимок референса 1586×992 пропорционально вписан в 1280×800. Границы
|
||||
крупных зон расходятся с реализацией не более чем на 1 CSS px: колонки
|
||||
x=0/226/648/1280; рабочая область начинается около y=126, заканчивается
|
||||
на y=744; footer — y=744–800.
|
||||
- Вторая строка навигации измерена по макету: поиск x=26–123, «Источники»
|
||||
x=146–245, язык x=285–373, статус x=381–486, начало демо-подписи x=511.
|
||||
Референсная подпись и элементы попадают в эти координаты с расхождением
|
||||
около 1–2 px; у статуса остаётся несколько лишних пикселей по ширине.
|
||||
- Горизонтального переполнения при 1440, 1024 и 390 px нет.
|
||||
- При 320 px также нет горизонтального переполнения.
|
||||
- Загружены Noto Sans и Noto Serif, включая кириллические наборы; повторяющихся
|
||||
HTML id нет, ошибок JavaScript в сценарии не зарегистрировано.
|
||||
- В fixture проверены ответы 200 для SVG и PNG favicon; локализация проверена
|
||||
на фильтрах, результатах, счётчике совпадений и открытом статусе.
|
||||
- Мобильная панель фильтров удерживает фокус в цикле, делает фон inert,
|
||||
показывает 3 px focus ring, закрывается по Escape и возвращает фокус на
|
||||
кнопку открытия.
|
||||
- Playwright проверил сохранение второго фильтра при удалении первого, URL,
|
||||
открытие редакции, поиск по документу
|
||||
(1-е и 2-е совпадения), независимость языка текста от языка интерфейса,
|
||||
отображение поисковых подписей и выбранного статуса на кыргызском,
|
||||
разворачивание документа, действия профиля/пользователей/выхода, мобильную
|
||||
панель и возврат к результатам, изоляцию `/review`, ошибки поиска и редакции,
|
||||
повтор после ошибки и отсутствие доступных редакций.
|
||||
- Проверены print CSS (служебные панели скрыты, документ и официальный источник
|
||||
остаются), горизонтальная прокрутка широкой таблицы и изображения внутри
|
||||
документа без общей прокрутки страницы, длинные названия, `prefers-reduced-motion`,
|
||||
ошибки/повтор после ошибки, состояние без редакций, загрузка и сохранение
|
||||
остальных URL-фильтров при удалении одного.
|
||||
- Для фильтров и открытых `<dialog>` проверены Tab/Shift+Tab, Escape и возврат
|
||||
фокуса. При Tab с последней кнопки в кабинете обнаружено и исправлено
|
||||
выпадение фокуса на `body`; повторный прогон подтверждает цикл внутри диалога.
|
||||
Полный скринридерный аудит этим не заменяется.
|
||||
- Обучающая подсказка `/review` проверена на всех четырёх шагах при 1280×800
|
||||
и 390×844: карточка не шире 320 px, использует Noto Sans/Noto Serif,
|
||||
полупрозрачный фон и располагается у целевого элемента без пересечения.
|
||||
Снимки первого шага сохранены для desktop и mobile.
|
||||
|
||||
Для снимков Playwright перехватывал API и отдавал локальные детерминированные
|
||||
fixture-ответы. Подпись «Демонстрационные данные» добавлялась только в тестовой
|
||||
странице перед снимком. Эти юридические тексты и результаты не входят в код
|
||||
продукта. Для overlay и difference исходный растр 1586×992 вписывается
|
||||
в 1280×800 с сохранением пропорций и полем около 1 px по бокам. В эталоне
|
||||
нет кнопки кабинета; в интерфейсе она сохранена для доступности действий
|
||||
учётной записи и остаётся заметным дополнительным элементом справа во второй
|
||||
строке. Макет показывает язык поиска как удаляемый чип, интерфейс использует
|
||||
доступный select; это функциональное расхождение пока оставлено явно видимым.
|
||||
Содержимое документа, системный формат даты и сглаживание текста также не
|
||||
совпадают попиксельно с концептом.
|
||||
|
||||
## Повторный запуск
|
||||
|
||||
Из корня репозитория выполните `cd tools/frontend-visual-review`, затем
|
||||
`npm ci`, `npx playwright install chromium` и `npm test`. Скрипт читает текущий
|
||||
`backend/search/review.html`, перехватывает API детерминированными fixture-
|
||||
ответами и обновляет перечисленные выше снимки. Overlay и pixel-difference
|
||||
создаются, если найден исходный файл по стандартному пути или передан путь в
|
||||
переменной `REFERENCE_IMAGE`.
|
||||
|
||||
## Оставшиеся визуальные отличия
|
||||
|
||||
Сглаживание текста и содержимое документа отличаются от растрового макета.
|
||||
Поля даты показывают системный формат Chromium. Переключатель языка интерфейса
|
||||
и поле языка поиска остаются разными состояниями; в макете язык показан как
|
||||
удаляемый чип. Дополнительная кнопка кабинета отсутствует в концепте. Не
|
||||
проверялись полный набор ошибок авторизации, скринридер, видимый фокус на каждом
|
||||
шаге, длинные реальные редакции и таблицы из корпуса. Автоматические снимки
|
||||
показывают композицию, но не подтверждают юридическое содержимое или доступность
|
||||
во всех браузерах.
|
||||
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 701 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 442 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,110 @@
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const repo = path.resolve(__dirname, '../..');
|
||||
const artifacts = path.join(repo, 'docs/product/visual-review');
|
||||
const html = fs.readFileSync(path.join(repo, 'backend/search/review.html'), 'utf8');
|
||||
const root = path.join(repo, 'backend/search/static/fonts');
|
||||
fs.mkdirSync(artifacts, {recursive: true});
|
||||
const docs = [
|
||||
{code:'doc-1',name:'Налоговый кодекс Кыргызской Республики',type:'Кодекс',status:'Действует',snippet:'… положения о налоге на прибыль и порядке его исчисления …',edition:'2024',editions:[{source_code:'2024',name:{ru:'Редакция от 12 января 2024 года'},available_languages:['ru','ky']},{source_code:'2023',name:{ru:'Редакция только на русском'},available_languages:['ru']} ]},
|
||||
{code:'doc-2',name:'О введении в действие Налогового кодекса Кыргызской Республики',type:'Закон',status:'Действует',snippet:'… устанавливается порядок введения в действие Налогового кодекса Кыргызской Республики …'},
|
||||
{code:'doc-3',name:'Порядок заполнения налоговой отчётности',type:'Постановление',snippet:'… рекомендации по заполнению налоговой отчётности, включая показатели по налогу на прибыль …'}
|
||||
];
|
||||
const catalogs = {document_types:[{code:'code',labels:{ru:'Кодекс',ky:'Кодекс'},count:12},{code:'law',labels:{ru:'Закон',ky:'Мыйзам'},count:10},{code:'decree',labels:{ru:'Постановление',ky:'Токтом'},count:8}],statuses:[{code:'active',labels:{ru:'Действует',ky:'Күчүндө'},count:24},{code:'repealed',labels:{ru:'Утратил силу',ky:'Күчүн жоготту'},count:5}],authorities:[{code:'ministries_and_committees',labels:{ru:'Министерства и комитеты',ky:'Министрликтер'},count:5}],legal_forces:[]};
|
||||
const article = '<h2>Налог на прибыль</h2><p>Текст документа приведён для примера оформления. Здесь будут отображаться положения выбранной редакции. По запросу налог на прибыль читатель найдёт нужный фрагмент.</p><h2>Налоговая отчётность</h2><p>В этом разделе отображается текст выбранной редакции. Поиск внутри документа помогает перейти к нужному фрагменту.</p><p>Далее приводятся положения о налоге на прибыль.</p><p>Упоминание запроса налог на прибыль в следующем разделе.</p>';
|
||||
let failNextEdition = false; let delayNextEdition = false;
|
||||
(async()=>{
|
||||
const browser = await chromium.launch({headless:true});
|
||||
const page = await browser.newPage({viewport:{width:1280,height:800},deviceScaleFactor:1});
|
||||
const errors=[]; page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.route('**/*', async route=>{
|
||||
const req=route.request(), url=new URL(req.url());
|
||||
if (req.isNavigationRequest()) return route.fulfill({status:200,contentType:'text/html; charset=utf-8',body:html});
|
||||
const p=url.pathname;
|
||||
if(p==='/auth/me') return route.fulfill({json:{account:{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==='/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==='/static/favicon.svg') return route.fulfill({status:200,contentType:'image/svg+xml',body:fs.readFileSync(path.join(root,'../favicon.svg'))});
|
||||
if(p==='/static/favicon-32.png') return route.fulfill({status:200,contentType:'image/png',body:fs.readFileSync(path.join(root,'../favicon-32.png'))});
|
||||
if(p.startsWith('/static/fonts/')) { const f=path.join(root,path.basename(p)); return route.fulfill({status:200,contentType:'font/woff2',body:fs.readFileSync(f)}); }
|
||||
return route.fulfill({status:404,contentType:'application/json',body:'{}'});
|
||||
});
|
||||
await page.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3%20%D0%BD%D0%B0%20%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active');
|
||||
const favicons=await page.evaluate(async()=>Promise.all([...document.querySelectorAll('link[rel="icon"]')].map(async link=>({type:link.type,status:(await fetch(link.href)).status}))));
|
||||
await page.evaluate(()=>document.fonts.ready);
|
||||
await page.locator('.search__result-title').first().click();
|
||||
await page.locator('#public-document-body h2').first().waitFor();
|
||||
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});
|
||||
const fonts = await page.evaluate(async()=>({sans:document.fonts.check('400 16px "Noto Sans"'),serif:document.fonts.check('600 24px "Noto Serif"'),kyrgyzSans:(await document.fonts.load('400 16px "Noto Sans"','ӨөҮүҢң')).length>0,kyrgyzSerif:(await document.fonts.load('600 24px "Noto Serif"','ӨөҮүҢң')).length>0,ids:[...document.querySelectorAll('[id]')].map(x=>x.id).filter((id,i,a)=>a.indexOf(id)!==i)}));
|
||||
const widths={}; for(const width of [1440,1024]){await page.setViewportSize({width,height:900});widths[width]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth,layout:document.querySelector('#search-layout').getBoundingClientRect().width}));await page.screenshot({path:path.join(artifacts,`desktop-${width}px.png`)});} await page.setViewportSize({width:320,height:800}); widths[320]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth}));
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.reload(); await page.evaluate(()=>document.fonts.ready);
|
||||
const view=await page.evaluate(()=>({mobileView:document.querySelector('#search-layout').dataset.mobileView,scrollWidth:document.documentElement.scrollWidth,width:innerWidth}));
|
||||
await page.locator('#mobile-filters-open').click();
|
||||
const drawer=await page.locator('#search-filters').getAttribute('aria-modal'); const modalBackground=await page.evaluate(()=>({chrome:document.querySelector('.search__chrome').inert,results:document.querySelector('.search__results').inert,document:document.querySelector('.search__document').inert,footer:document.querySelector('.search__footer').inert})); await page.locator('#search-filters').evaluate(el=>{const c=[...el.querySelectorAll('button:not([disabled]),input:not([disabled]),select:not([disabled]),[href]')].filter(n=>n.offsetParent!==null);c.at(-1).focus()}); await page.keyboard.press('Tab'); const focusWrap=await page.evaluate(()=>document.activeElement.id); const focusVisible=await page.evaluate(()=>getComputedStyle(document.activeElement).outlineWidth); await page.keyboard.press('Shift+Tab'); const focusReverseWrap=await page.evaluate(()=>document.activeElement.id);
|
||||
await page.keyboard.press('Escape'); const focusRestored=await page.evaluate(()=>document.activeElement.id);
|
||||
await page.locator('.search__result-title').first().click();
|
||||
await page.locator('#public-document-body h2').first().waitFor();
|
||||
await page.screenshot({path:path.join(artifacts,'mobile-390px.png'),fullPage:true});
|
||||
await page.locator('#document-back').click();
|
||||
const back=await page.locator('#search-layout').getAttribute('data-mobile-view'); await page.setViewportSize({width:1024,height:900}); await page.waitForFunction(()=>document.querySelector('#search-filters').getAttribute('aria-hidden')===null); const desktopFilters=await page.evaluate(()=>({visible:getComputedStyle(document.querySelector('#search-filters')).display!=='none',ariaHidden:document.querySelector('#search-filters').getAttribute('aria-hidden')}));
|
||||
await page.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 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.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.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3%20%D0%BD%D0%B0%20%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&status=active');
|
||||
await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor();
|
||||
await page.locator('#sources-open').click(); const sourcesDialog=await page.evaluate(()=>({open:document.querySelector('#sources-dialog').open,labelledBy:document.querySelector('#sources-dialog').getAttribute('aria-labelledby'),focusInside:document.querySelector('#sources-dialog').contains(document.activeElement)})); await page.keyboard.press('Escape'); const sourcesEscapeClosed=await page.locator('#sources-dialog').evaluate(d=>!d.open); const sourcesFocusRestored=await page.evaluate(()=>document.activeElement.id);
|
||||
await page.locator('#search-account-open').click(); const accountDialogFocusInside=await page.locator('#account-dialog').evaluate(d=>d.contains(document.activeElement)); await page.keyboard.press('Escape'); const accountEscapeClosed=await page.locator('#account-dialog').evaluate(d=>!d.open); const accountFocusRestored=await page.evaluate(()=>document.activeElement.id);
|
||||
await page.emulateMedia({media:'print'}); const printState=await page.evaluate(()=>({chrome:getComputedStyle(document.querySelector('.search__chrome')).display,filters:getComputedStyle(document.querySelector('.search__filters')).display,results:getComputedStyle(document.querySelector('.search__results')).display,footer:getComputedStyle(document.querySelector('.search__footer')).display,document:getComputedStyle(document.querySelector('#public-document-panel')).display,source:getComputedStyle(document.querySelector('#document-source')).display,sourceHidden:document.querySelector('#document-source').hidden})); await page.emulateMedia({media:'screen'});
|
||||
const tableOverflow=await page.locator('#public-document-body').evaluate(body=>{const table=document.createElement('table');table.style.width='100%';table.innerHTML='<tbody><tr>'+Array.from({length:10},()=>'<td>'+('широкаятаблица'.repeat(20))+'</td>').join('')+'</tr></tbody>';body.append(table);const result={clientWidth:table.clientWidth,scrollWidth:table.scrollWidth,pageWidth:document.documentElement.scrollWidth,viewport:innerWidth};table.remove();return result});
|
||||
await page.emulateMedia({reducedMotion:'reduce'}); const reducedMotion=await page.evaluate(()=>({scrollBehavior:getComputedStyle(document.documentElement).scrollBehavior,transitionDuration:getComputedStyle(document.querySelector('#document-print')).transitionDuration})); await page.emulateMedia({reducedMotion:'no-preference'});
|
||||
await page.locator('#public-query').fill('service-error'); await page.locator('#public-search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#public-status').textContent==='Fixture search error'); const searchError={message:await page.locator('#public-status').textContent(),query:await page.locator('#public-query').inputValue(),role:await page.locator('#public-status').getAttribute('role')};
|
||||
await page.locator('#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');
|
||||
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(JSON.stringify({resultHighlight,fonts,favicons,widths,view,drawer,modalBackground,focusWrap,focusVisible,focusReverseWrap,focusRestored,back,chipsBefore,chipsWithTwo,preservedFilters,urlFilters,openedCount,firstFindCount,nextFindCount,documentLanguage,kyrgyzUi,kyrgyzEmpty,expanded,accountActions,desktopFilters,routes,errors},null,2));
|
||||
const referencePath=process.env.REFERENCE_IMAGE || '/home/vaka/.codex/attachments/662a46bf-0f7f-4bba-a63a-bb823f4470dc/image-1.png';
|
||||
if(fs.existsSync(referencePath)) {
|
||||
const comparePage=await browser.newPage();
|
||||
const reference=fs.readFileSync(referencePath).toString('base64'), actual=fs.readFileSync(path.join(artifacts,'desktop-1280x800.png')).toString('base64');
|
||||
await comparePage.setContent(`<canvas id="overlay" width="1280" height="800"></canvas><canvas id="difference" width="1280" height="800"></canvas><img id="reference" src="data:image/png;base64,${reference}"><img id="actual" src="data:image/png;base64,${actual}">`);
|
||||
await comparePage.evaluate(()=>Promise.all([...document.images].map((image)=>image.decode())));
|
||||
const comparison=await comparePage.evaluate(()=>{
|
||||
const ref=document.querySelector('#reference'),act=document.querySelector('#actual');
|
||||
const place=(context,image)=>{const scale=Math.min(1280/image.naturalWidth,800/image.naturalHeight),width=image.naturalWidth*scale,height=image.naturalHeight*scale;context.fillStyle='#F7F2E8';context.fillRect(0,0,1280,800);context.drawImage(image,(1280-width)/2,(800-height)/2,width,height)};
|
||||
const overlay=document.querySelector('#overlay'),o=overlay.getContext('2d');place(o,ref);o.globalAlpha=.5;o.drawImage(act,0,0,1280,800);o.globalAlpha=1;
|
||||
const difference=document.querySelector('#difference'),d=difference.getContext('2d');place(d,ref);
|
||||
const actualCanvas=document.createElement('canvas');actualCanvas.width=1280;actualCanvas.height=800;actualCanvas.getContext('2d').drawImage(act,0,0,1280,800);
|
||||
const a=d.getImageData(0,0,1280,800),b=actualCanvas.getContext('2d').getImageData(0,0,1280,800);
|
||||
for(let i=0;i<a.data.length;i+=4){const v=Math.max(Math.abs(a.data[i]-b.data[i]),Math.abs(a.data[i+1]-b.data[i+1]),Math.abs(a.data[i+2]-b.data[i+2]));a.data[i]=v;a.data[i+1]=0;a.data[i+2]=0;a.data[i+3]=255}d.putImageData(a,0,0);
|
||||
const crop=(source,[x,y,width,height])=>{const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;canvas.getContext('2d').drawImage(source,x,y,width,height,0,0,width,height);return canvas.toDataURL('image/png')};
|
||||
const regions={header:[0,0,1280,127],filters:[0,127,226,617],results:[226,127,422,617],document:[648,127,632,617],footer:[0,744,1280,56]};
|
||||
return {overlay:overlay.toDataURL('image/png'),difference:difference.toDataURL('image/png'),regions:Object.entries(regions).map(([name,rect])=>({name,overlay:crop(overlay,rect),difference:crop(difference,rect)}))};
|
||||
});
|
||||
fs.writeFileSync(path.join(artifacts,'overlay-50.png'),Buffer.from(comparison.overlay.split(',')[1],'base64'));
|
||||
fs.writeFileSync(path.join(artifacts,'pixel-difference.png'),Buffer.from(comparison.difference.split(',')[1],'base64'));
|
||||
for(const region of comparison.regions){fs.writeFileSync(path.join(artifacts,`region-${region.name}-overlay.png`),Buffer.from(region.overlay.split(',')[1],'base64'));fs.writeFileSync(path.join(artifacts,`region-${region.name}-difference.png`),Buffer.from(region.difference.split(',')[1],'base64'));}
|
||||
await comparePage.close();
|
||||
}
|
||||
await browser.close();
|
||||
if(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);
|
||||
})();
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "akyldash-frontend-visual-review",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"playwright": "1.64.1"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "akyldash-frontend-visual-review",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "node check.cjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "1.64.1"
|
||||
}
|
||||
}
|
||||
[P2] Локализуйте значения фильтров при выборе KY. Здесь всегда используется
item.labels.ru, поэтому пункты статуса и вида документа остаются русскими после смены языка интерфейса. Выбирайте подпись поuiLanguage, как для органа принятия.