---
type: adr
work_item_id: ET-014
adr_id: ADR-019
title: "ADR-019: При открытии любого bottom-sheet принудительно закрывать terrain-popup — без правки z-index стека"
status: accepted
created_at: 2026-06-04
updated_at: 2026-06-04
authors:
  - "agent:architect"
supersedes: []
superseded_by: []
labels:
  - "ET-014:ui-z-index"
  - "minor-change"
---

# ADR-019 — Terrain-popup уступает место bottom-sheet'у

## Статус

**Accepted.** Архитектурное решение для ET-014.

Это **UI / DOM-stacking фикс**. По BRD §5 (BR-04, BR-05) — не arch:major-change.
ADR оформляется для фиксации **отказа от двух альтернативных вариантов**
(подъём z-index всей категории `.bottom-sheet` и точечный подъём
`#sheet-gps-filters`), чтобы они не вернулись в обсуждение в следующем
work-item, который столкнётся с похожим конфликтом.

## Контекст

### Текущее состояние (как есть)

Стек z-index клиентского UI (`src/web/app.css`):

| Элемент           | z-index | Файл/строка             |
|-------------------|---------|-------------------------|
| `#map`            | 0       | app.css:68              |
| `#no-data-warning`| 200     | app.css:410             |
| `#sheet-backdrop` | 390     | app.css:225             |
| `.bottom-sheet`   | 400     | app.css:188             |
| `#map-controls-r` | 400     | app.css:129             |
| `.terrain-popup`  | **500** | app.css:787             |
| `#marker-dialog`  | 500     | app.css:399             |
| `#search-panel`   | 600     | app.css:1101            |
| `#ruler-info`     | 600     | app.css:1122            |

Поток открытия фильтров (`src/web/gps_tracks.js:737`):

1. `#terrain-toggle` (кнопка-гора) → `toggleTerrainPopup()` показывает
   `#terrain-popup` (z=500), вешает `closeTerrainOnOutside` на `document`.
2. Пользователь жмёт `#public-tracks-filters-btn` («Фильтры…») внутри popup'а.
3. `togglePublicTracksFiltersSheet()` вызывает `openSheet('sheet-gps-filters')`.
4. `openSheet()` (`app.js:206`) добавляет класс `.open` на sheet и `.visible`
   на `#sheet-backdrop`.
5. **`#terrain-popup` остаётся открытым** (display: block, z=500).
6. Sheet (z=400) и backdrop (z=390) визуально оказываются **под** popup'ом.
7. `closeTerrainOnOutside` не срабатывает: клик произошёл по
   `#public-tracks-filters-btn`, который `.contains()` целью popup'а.

### Проблема

- На mobile (viewport 360-414): popup занимает ~60% ширины справа, sheet
  выезжает снизу, его правые ~60% перекрыты popup'ом → пользователь видит
  узкую левую полоску, фильтрами пользоваться нельзя (BR-01).
- На desktop (≥1024): popup справа, sheet выезжает как боковая панель
  слева → они геометрически не пересекаются, но **семантически открыты
  два меню одновременно** — это нарушение BR-02 («панель слоёв не должна
  перекрывать панель фильтров») и BR-03 («без артефактов наложения»),
  плюс выход за пределы BRD §3 «бизнес-цель: сделать фильтры реально
  доступными» в части UX-чистоты.
- Backdrop sheet'а (z=390) не визуализирован: попадает под popup, на
  mobile отсутствует «фон не-фильтра затемнён» эффект; на desktop backdrop
  всё равно скрыт media-query (`app.css:543`).

### Архитектурный вопрос

**Как заставить sheet быть полноценно «верхним» виджетом, не вводя
точечных z-index хаков и не рискуя стеком marker-dialog (z=500),
search-panel (z=600), ruler-info (z=600).**

## Рассмотренные варианты

### Вариант A — закрывать `#terrain-popup` при открытии sheet (выбран)

При открытии любого `.bottom-sheet` принудительно скрывать
`#terrain-popup` (display:none), снимать `.active` с `#terrain-toggle`,
отвязывать висящий `closeTerrainOnOutside`.

Точка вставки — общий `openSheet()` в `src/web/app.js`. Не
точечно в `togglePublicTracksFiltersSheet()`, потому что:
- Сейчас «Фильтры…» — единственная точка входа в sheet из popup'а
  (BRD §8 допущение). Будущее: если фильтры POI или фильтры маршрута
  тоже окажутся «ссылками внутри popup'а», правило срабатывает само,
  без новой задачи.
- Для существующих 5 sheet'ов (`sheet-route`, `sheet-recon`,
  `sheet-scenic`, `sheet-link`, `sheet-gpx`) вызов — no-op (popup
  при их открытии не открыт). REQ-F-06 («регрессий нет») выполняется
  автоматически.

Pros:
- 0 правок CSS → 0 риска регрессии стека (marker-dialog z=500,
  search-panel z=600, ruler-info z=600 — REQ-NF-03).
- Лечит **обе** среды одной правкой (mobile: фильтры доступны; desktop:
  «два меню одновременно» — устранено).
- Backdrop sheet'а (z=390) теперь корректно затемняет фон на mobile
  (popup больше не закрывает его).
- Логика «открыл sheet → скрыли pointer-меню» — стандартный mobile UX
  (так ведут себя dropdown'ы в Material / iOS Sheets).
- BRD R2 это разрешает: «после закрытия фильтров пользователь
  возвращается к карте, а не к панели слоёв».
- Локализация: 1 helper + 1 строка в `openSheet`. ~7 строк кода.

Cons / Принимаем:
- Пользователь, привыкший «жму Фильтры… → panel слоёв остаётся открытой
  на фоне» — больше так не увидит. Это не регрессия, это устранение
  бага: BRD §1 признаёт текущее поведение блокером.
- Если случай «нужны два открытых меню одновременно» появится в будущем
  — придётся переосмыслить. Сейчас такого сценария нет.

### Вариант B — поднять z-index всех `.bottom-sheet` выше terrain-popup

`.bottom-sheet { z-index: 510; }`, `#sheet-backdrop { z-index: 505; }`.

Pros:
- Системное решение: вся категория `.bottom-sheet` гарантированно
  сверху.

Cons (отклонён):
- **Столкновение с `#marker-dialog` (z=500).** Marker-dialog —
  отдельный виджет (не `.bottom-sheet`), но визуально это тоже
  «sheet-like». Если пользователь активирует «Метку» поверх открытого
  sheet'а (через swipe-down и тулбар), marker-dialog окажется под
  sheet'ом → AC-10 / REQ-NF-03 нарушится. Сейчас совместное открытие
  редко, но не запрещено.
- **На desktop не лечит «два меню».** Popup справа (z=500), sheet слева
  (z=510) — геометрически не пересекаются, sheet «сверху» в стеке, но
  визуально на экране всё ещё видны оба меню. BR-03 «без артефактов
  наложения» формально нарушено.
- Backdrop поднимать до z=505 — нормально, но это всё ещё ниже popup'а
  по логике стека («backdrop sheet'а» оказывается **над** terrain-popup,
  что может затемнить popup — формально не баг, но визуально странно).
- Расширяет blast radius CSS-правки на всех 6 sheet'ов сразу.

### Вариант C — точечный z-index только `#sheet-gps-filters`

`#sheet-gps-filters { z-index: 510; }`, без правки backdrop.

Pros:
- Самое маленькое изменение CSS (2 строки).

Cons (отклонён):
- **Узкий хак.** Если завтра «Фильтры…» появятся ещё где-то (например,
  фильтр POI прямо из popup'а POI или фильтр маршрута из мини-sheet'а
  маршрута), у нас будет та же проблема и новая «специальная» правка.
- **На desktop не лечит «два меню».** Та же проблема, что у варианта B.
- Backdrop (`#sheet-backdrop` z=390) на mobile всё равно остаётся под
  popup'ом → визуально popup остаётся «поверх затемнения» → нарушает
  ожидание пользователя «sheet полноценно перекрыл всё, кроме самого
  себя».
- Создаёт прецедент «один sheet — особенный». Каждая следующая итерация
  будет соблазн добавить ещё один специальный z-index.

### Вариант D — отказаться от popup'а, перенести «Фильтры…» на тулбар

Полностью убрать `#public-tracks-filters-btn` из `#terrain-popup`,
добавить отдельную кнопку на правом тулбаре.

Cons (отклонён):
- **Out of scope BRD §5**: «Добавление новых способов открытия фильтров
  (например, отдельной кнопки на toolbar) — не входит в scope.»
- Меняет UX, нарушает архитектуру «slots в panel слоёв».

### Вариант E — открывать sheet модально внутри popup'а

Превратить sheet в child popup'а с собственным позиционированием.

Cons (отклонён):
- Радикальная перестройка DOM-структуры sheet'а: он должен оставаться
  bottom-sheet'ом по другим сценариям (другие work-items предполагают
  единый компонент).
- Сложнее testabilitу (Playwright-кейсы рассчитаны на текущую
  семантику `.open` класса на корневом `.bottom-sheet`).
- Большой scope creep для bug-fix задачи.

## Решение

1. **В `src/web/app.js`** добавить helper:

   ```js
   function closeTerrainPopup() {
     const popup = document.getElementById('terrain-popup');
     const btn = document.getElementById('terrain-toggle');
     if (!popup || popup.style.display === 'none') return;
     popup.style.display = 'none';
     if (btn) btn.classList.remove('active');
     document.removeEventListener('click', closeTerrainOnOutside);
   }
   ```

2. **В `openSheet(id)`** (`src/web/app.js:206`) **первой строкой
   после null-check** вызвать `closeTerrainPopup()`:

   ```js
   function openSheet(id) {
     const sheet = document.getElementById(id);
     if (!sheet) return;
     // ET-014: terrain-popup yields to any opening sheet (see ADR-019).
     // Prevents z-index collision (popup z=500 over sheet z=400) and
     // resolves the "two menus open at once" anti-pattern on desktop.
     closeTerrainPopup();
     // Close all other sheets first
     document.querySelectorAll('.bottom-sheet.open').forEach(s => {
       if (s.id !== id) closeSheet(s.id);
     });
     sheet.classList.add('open');
     const backdrop = document.getElementById('sheet-backdrop');
     backdrop.classList.add('visible');
   }
   ```

3. **`closeTerrainOnOutside(e)` не меняется** — продолжает работать как
   раньше для сценария «клик вне popup'а и вне `#terrain-toggle`»
   (REQ-F-05 / AC-08). Если хочется DRY — реализатор может вызвать
   `closeTerrainPopup()` из тела `closeTerrainOnOutside`, но это
   опциональный cleanup; обязательного требования нет (две функции с
   одинаковым эффектом окей в vanilla JS без зависимостей).

4. **`togglePublicTracksFiltersSheet()` в `gps_tracks.js` не меняется.**
   Логика закрытия popup'а теперь живёт в `openSheet()` — общий путь
   для всех будущих и текущих sheet'ов.

### Что НЕ меняется

- `src/web/app.css` — **никаких z-index правок**. Стек marker-dialog (500),
  search-panel (600), ruler-info (600), `.bottom-sheet` (400),
  `#sheet-backdrop` (390), `.terrain-popup` (500), `#map-controls-r`
  (400), `#no-data-warning` (200), `#map` (0) — без изменений.
- `src/web/index.html` — без изменений.
- `src/web/gps_tracks.js` — без изменений.
- `src/web/style.json` / `style-dark.json` — без изменений.
- `src/api/*` — без изменений.
- `Dockerfile`, `docker-compose.yml`, nginx, БД, миграции — без изменений.

## Классификация изменения

**minor-change.**

Меняется 1 файл:
- `src/web/app.js` (+1 helper-функция ~7 строк, +1 вызов в `openSheet`).

Эскалация: **не arch:major-change.** Не требует расширенного approve.
Не относится к категориям из CLAUDE.md «всё в Docker / on-premise / new
service / new DB» — чистый клиентский UI fix.

## Последствия

### Положительные

- BR-01..BR-03 (фильтры реально доступны, без артефактов) — закрываются
  атомарной правкой одной функции.
- BR-04 (другие sheets без регрессии) — автоматически: `closeTerrainPopup()`
  для них — no-op.
- BR-05 (terrain-popup сам по себе без регрессии) — `toggleTerrainPopup`,
  `closeTerrainOnOutside`, чекбоксы рельефа, переключатели подложки/единиц
  не трогаются.
- BR-06 (свет/тёмная тема) — нет theme-specific кода → одинаково работает.
- REQ-NF-03 (marker-dialog, search-panel, ruler-info не регрессируют) —
  z-index не трогается → нулевой риск.
- REQ-NF-04 (PWA / safe-area) — не задействован.
- На mobile backdrop sheet'а (z=390) теперь корректно затемняет фон
  (раньше popup z=500 его перекрывал) → пользователь визуально
  понимает, что sheet — модальный.
- Семантика «sheet — главный модальный виджет» становится единым правилом
  для всей `openSheet()` функции.

### Отрицательные / Принимаем

- Пользователь, открывший фильтры из panel слоёв, после закрытия
  фильтров **не возвращается** к panel слоёв — он видит карту.
  Чтобы снова попасть в panel слоёв, нужно повторно нажать `#terrain-toggle`.
  Принимаем по BRD R2: «панель слоёв — точка входа в фильтры, после
  закрытия фильтров пользователь возвращается к карте». Это решение
  оператора.
- Если когда-нибудь появится сценарий «sheet и terrain-popup должны
  сосуществовать» — нужно будет вводить параметр в `openSheet({ keepPopup })`
  или вообще другую функцию. Сейчас такого сценария нет.

### Технический долг

- **TD-1: Унификация `closeTerrainOnOutside` через `closeTerrainPopup`.**
  Опциональный cleanup: рефакторинг тела `closeTerrainOnOutside` на
  вызов нового helper'а. Не блокирует ET-014, можно сделать отдельным
  fix-up коммитом. Если не сделать — две функции с почти одинаковым
  телом будут жить рядом.
- **TD-2: Параметризация `openSheet(id, opts)`.** Если в будущем
  потребуется открыть sheet, **не** закрывая popup (новый редкий
  сценарий — пока не предвидится), `openSheet` нужно будет расширить
  объектом опций. Сейчас YAGNI.
- **TD-3: Общий «модальный менеджер» для popup + sheet + dialog.**
  Сейчас три виджета (`.terrain-popup`, `.bottom-sheet`, `#marker-dialog`)
  имеют пересекающиеся z-index'ы (500, 400, 500). Если когда-нибудь
  появятся новые модальные виджеты или сложные комбинации, можно
  выделить общий «modal stack manager» с явным API
  `pushModal/popModal`. Сейчас overkill — три виджета и одно правило
  «sheet выгоняет popup» решают всё.

## Альтернативы для будущего

| # | Идея | Когда возвращаться |
|---|------|---------------------|
| F-1 | Подъём z-index `.bottom-sheet` до 510 (Вариант B) | Если появится сценарий «два меню одновременно нужны» и Вариант A не сработает |
| F-2 | Точечный z-index `#sheet-gps-filters` (Вариант C) | Никогда — порождает специальные случаи |
| F-3 | Перенос «Фильтры…» на тулбар (Вариант D) | По бизнес-запросу, отдельный work-item (изменит scope BRD ET-014) |
| F-4 | Modal stack manager (TD-3) | Когда модальных виджетов станет ≥5 или появятся вложенные модалки |
| F-5 | Параметризация `openSheet(id, opts)` (TD-2) | По мере появления исключений из правила «sheet выгоняет popup» |

## Связанные документы

- BRD: `docs/work-items/ET-014/01-brd.md` §4 (BR-01..BR-06), §9 (R1..R3)
- TRZ: `docs/work-items/ET-014/02-trz.md` §1.3 (корень проблемы),
  §2.1 (REQ-F-01..REQ-F-07), §2.2 (REQ-NF-01..REQ-NF-05), §3 (варианты)
- AC: `docs/work-items/ET-014/03-acceptance-criteria.md` (AC-01..AC-14)
- UI test cases: `docs/work-items/ET-014/04b-ui-test-cases.md`
  (TC-UI-01..TC-UI-08)
- Инфра: `docs/work-items/ET-014/07-infra-requirements.md`
- Данные: `docs/work-items/ET-014/08-data-requirements.md`
- Риски: `docs/work-items/ET-014/10-tech-risks.md`
- Глобальный ADR-индекс: `docs/architecture/adr/README.md`
- Прецедент ADR-017 (ET-013) — формат «UI-калибровочного» ADR
