diff --git a/docs/work-items/ET-013/01-brd.md b/docs/work-items/ET-013/01-brd.md
new file mode 100644
index 0000000..7233627
--- /dev/null
+++ b/docs/work-items/ET-013/01-brd.md
@@ -0,0 +1,232 @@
+---
+type: brd
+work_item_id: ET-013
+title: "BRD: Сохранить выразительность перепадов высот на z9-z11"
+version: 1
+status: draft
+created_at: 2026-06-04
+updated_at: 2026-06-04
+authors:
+ - "agent:analyst"
+related:
+ - "PH-6.terrain"
+---
+
+# BRD — ET-013: Сохранить выразительность перепадов высот на z9-z11
+
+## 1. Цель
+
+На зумах **z9-z11** перепады высот должны читаться визуально
+сопоставимо с z8: пользователь видит «где холмы, где равнина»,
+а не однородную засветку.
+
+Сейчас при увеличении зума с z8 (где перепады бросаются в глаза
+через слой «Перепады»/TRI и общий цветовой контраст) до z9-z11
+происходит резкая потеря выразительности:
+
+- **z8** — слой «Перепады» (TRI) хорошо читается: крупные пятна
+ «шершавости» рельефа покрывают значимую долю кадра, базовая
+ подложка остаётся видна, перепады бросаются в глаза.
+- **z9** — кнопка «Тени рельефа» (hillshade) **disabled**
+ (UI-минзум = 10), TRI ещё работает, но визуально пятна
+ становятся мельче и контраст слабее.
+- **z10-z11** — hillshade включается, но его `opacity=0.40` и
+ отсутствие усиления контраста делают теневой рельеф «бледной
+ плёнкой» поверх подложки; TRI не компенсирует, потому что
+ его `opacity=0.70` рассчитано на z5-z8.
+
+ET-013 = **скалировать paint-параметры (opacity, contrast,
+resampling) hillshade и TRI по зуму** так, чтобы на z9-z11
+рельеф читался сопоставимо с z8, без перегенерации растровых
+тайлов и без новых данных.
+
+## 2. Контекст
+
+### 2.1 Текущая реализация (после PH-6)
+
+**Источники тайлов** (`src/api/main.py:1240`):
+- `/terrain/hillshade/{z}/{x}/{y}.png` — теневой рельеф.
+- `/terrain/tri/{z}/{x}/{y}.png` — Terrain Ruggedness Index («Перепады»).
+- `/terrain/hypso/{z}/{x}/{y}.png` — гипсометрия (на текущий
+ момент в UI не подключён; вне scope ET-013).
+
+По PH-6 BRD тайлы нарезаны **z8-z14** (PNG 256×256), сгенерированы
+из SRTM 30м со следующими параметрами:
+- hillshade: azimuth 315°, altitude 45°, **z-factor 1.5**;
+- TRI: классификация (flat / nearly flat / slightly rugged /
+ rugged / very rugged), цветовая шкала.
+
+**Клиентский рендеринг** (`src/web/app.js`):
+
+```js
+// Строка ~2782-2783:
+applyTerrainLayer('terrain-hillshade', TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png',
+ hillshadeChecked, 0.40, 10, 15);
+applyTerrainLayer('terrain-tri', TERRAIN_BASE_URL + '/tri/{z}/{x}/{y}.png',
+ triChecked, 0.70, 5, 15);
+```
+
+`applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom)` (строка 3316):
+- создаёт `raster` source с `tileSize: 256`, `scheme: 'tms'`,
+ `minzoom`, `maxzoom`;
+- добавляет `raster` layer с paint `{raster-opacity, raster-resampling: 'linear'}`;
+- никаких zoom-tier выражений: opacity — **константа**.
+
+**UI-минзум hillshade** (`src/web/app.js:3359`):
+```js
+function updateHillshadeAvailability() {
+ const zoom = map.getZoom();
+ if (zoom < 10) { cb.disabled = true; hint.style.display = 'inline'; ... }
+}
+```
+То есть на z9 чекбокс «Тени рельефа» неактивен и видна подсказка
+«Зум 10+». На диске тайл z9 есть (нарезка z8-14), но клиент его
+не запрашивает.
+
+### 2.2 Ответы на open questions из бизнес-запроса
+
+| Вопрос | Ответ |
+|---|---|
+| Чем рисуется рельеф? | Двумя независимыми raster-слоями: **hillshade** (PNG, z8-14 на диске, z10-15 в UI) и **TRI/«Перепады»** (PNG, z8-14 на диске, z5-15 в UI). Гипсометрия в UI сейчас не подключена. |
+| Где задаётся стиль по зумам? | `src/web/app.js:2782-2783` (вызовы `applyTerrainLayer` с константой opacity), `src/web/app.js:3316-3357` (создание raster-слоя), `src/web/app.js:3359-3377` (UI-минзум hillshade). Никаких zoom-tier выражений нет — opacity скаляр. |
+| До какого зума нарезаны тайлы? | По PH-6 BRD: **z8-z14**. На z15 на клиенте работает overzoom MapLibre (maxzoom source < maxzoom layer). Для ET-013 ключевое: на z9-z11 тайлы **есть на диске** — проблема исключительно в рендеринге. |
+| Хватает ли разрешения SRTM 30м на z9-z11? | Да. На z9 1 пиксель тайла ≈ 300м, на z10 ≈ 150м, на z11 ≈ 75м — везде есть запас относительно 30м SRTM. Перепады «теряются» не из-за разрешения данных, а из-за низкого контраста при рендере + отключённого hillshade на z9. |
+| Нужен ли отдельный стиль для крупных зумов? | **Нет**, отдельный layer не нужен. Достаточно: (а) снизить UI-минзум hillshade до z9; (б) перевести `raster-opacity` и `raster-contrast` в zoom-aware `interpolate`-выражения; (в) на крупных зумах переключить `raster-resampling` на `nearest`, чтобы перепады были резкими. |
+
+### 2.3 Почему это бизнес-важно
+
+- **UX expectation**: пользователь зумит карту чтобы детальнее
+ посмотреть рельеф — а получает обратное: «было видно — стало
+ плоско». Это контр-интуитивно и снижает доверие к слою.
+- **Целевая задача продукта** (эндуро-планирование): на z9-z11
+ пользователь оценивает «насколько холмистая зона между двумя
+ точками маршрута» — именно этот масштаб ключевой для выбора
+ направления. Сейчас на этом масштабе слой работает плохо.
+- **Низкозатратное исправление**: данные есть, тайлы есть,
+ логика рендера тривиально дополняется zoom-tier выражениями.
+ Полезность/стоимость очень высокая.
+
+### 2.4 Что НЕ делаем (обоснование)
+
+| Альтернатива | Решение | Причина |
+|---|---|---|
+| Перегенерировать hillshade с z-factor 2.5-3.0 для z9-z14 | **Out of scope.** | Требует доступа к infra-pipeline SRTM, пересборки и редеплоя растровых тайлов. Если frontend-калибровки (F-02..F-05) недостаточно — отдельный work item «hillshade-rerender-z9-z14». |
+| Добавить векторные горизонтали (contours) | **Out of scope.** | Контуров в стэке нет. Это новая фича уровня PH-6.5, требует pipeline на отдельных vector tiles. |
+| Перейти на MapLibre `hillshade` layer (raster-dem) | **Out of scope.** | Требует поднять DEM в формате Terrarium/Mapbox-RGB. Это смена архитектуры рельефа. |
+| Multidirectional hillshade (4 азимута) | **Out of scope.** | Требует пересборки тайлов и комбинирования; см. строку 1. |
+| Подключить гипсометрию в UI на z9-z11 | **Out of scope.** | Hypso тайлы есть на диске, но UI не имеет переключателя — отдельная задача. |
+| Менять PH-6 параметры hillshade (azimuth/altitude) | **Out of scope.** | Это калибровка генератора, не клиентская проблема. |
+
+## 3. Scope
+
+### In scope
+
+| # | Функция |
+| ----- | ---------------------------------------------------------------------------------------------------- |
+| F-01 | Понизить UI-минзум hillshade с 10 до **9** в `updateHillshadeAvailability` (тайлы z9 есть на диске). |
+| F-02 | Понизить `minzoom` источника `terrain-hillshade-source` с 10 до 9 (через изменение вызова `applyTerrainLayer`). |
+| F-03 | Опционально: обновить UI-hint «Зум 10+» → «Зум 9+» в `#terrain-hillshade-hint`. |
+| F-04 | Расширить `applyTerrainLayer` так, чтобы параметр `opacity` мог быть либо числом (текущий контракт), либо MapLibre `interpolate`-выражением. Никаких новых публичных функций. |
+| F-05 | Для hillshade использовать `raster-opacity` zoom-aware: 9→0.65, 10→0.60, 11→0.55, 12→0.50, 14→0.40. Цель: компенсировать «бледность» теней на z9-z11. |
+| F-06 | Для hillshade добавить `raster-contrast` zoom-aware: 9→0.40, 10→0.35, 11→0.30, 12→0.15, 14→0.00. Цель: подчеркнуть перепады без перегенерации. |
+| F-07 | Для hillshade установить `raster-resampling: 'nearest'` на z9-z11 (т.е. везде, где `raster-resampling` не игнорируется). Цель: резкие края перепадов вместо размытия. Сейчас стоит `'linear'`. Замечание: MapLibre не поддерживает интерполяцию `raster-resampling` по зуму, поэтому компромисс — глобально `'nearest'` для hillshade на всех зумах ≥ 9. На z12+ это допустимо (текстура остаётся читаемой при overzoom). |
+| F-08 | Для TRI («Перепады») использовать `raster-opacity` zoom-aware: 5→0.55, 7→0.65, 8→0.70 (как сейчас), 9→0.80, 10→0.85, 11→0.85, 12→0.75, 15→0.70. Цель: усилить TRI ровно на z9-z11 (как компенсацию за рывок hillshade), не трогая z8 и не превращая карту в кашу на z5-z7. |
+| F-09 | Для TRI установить `raster-resampling: 'nearest'`. TRI — категориальная классификация (5 уровней), линейный ресемпл размывает границы классов. Цель: резкие границы «спокойно/шероховато». |
+| F-10 | UI: контракт переключателей «Тени рельефа» / «Перепады» в `#terrain-popup` не меняется. Чекбоксы, persistence в localStorage (`terrain-hillshade`, `terrain-tri`) — без изменений. |
+| F-11 | Регрессия z8: визуально слой «Перепады» на z8 выглядит как раньше (opacity 0.70). |
+| F-12 | Регрессия z12-z15: hillshade и TRI не становятся темнее/контрастнее, чем были (calibration возвращается к старым значениям к z14). |
+| F-13 | Регрессия performance: количество запросов растровых тайлов на сессию не должно вырасти больше, чем на +35% (грубая оценка: +1 zoom-уровень для hillshade на z9 добавляет ~25% тайлов на сессию активного зумирования). |
+| F-14 | Документация: ADR не нужен (это калибровка, не архитектурное решение). Опциональный `06-adr/` остаётся пустым. Изменения покрываются TRZ и комментарием в коде, ссылающимся на ET-013. |
+
+### Out of scope
+
+- **Перегенерация hillshade с большим z-factor** (отдельная задача, см. §2.4).
+- **Добавление векторных горизонталей** (отдельная задача).
+- **Переход на raster-dem / Mapbox Terrain RGB** (смена архитектуры).
+- **Multidirectional hillshade** (требует pipeline).
+- **Подключение гипсометрии в UI** (отдельная задача).
+- **Изменение PH-6 параметров hillshade на сервере** (azimuth, altitude, z-factor).
+- **Изменение генератора TRI** (классификация, цветовая шкала).
+- **Тайл-кэш на стороне сервера** (раздача через FastAPI с `Cache-Control: max-age=31536000` уже есть).
+- **Изменение UI чекбоксов** (только текст hint'а в F-03).
+- **Изменение TERRAIN_DIR / endpoint contract** (`src/api/main.py:1240-1255`).
+- **Изменения PWA / offline-кэш стратегии для тайлов** (PH-9, не сейчас).
+
+## 4. Метрики успеха
+
+| # | Метрика | Критерий |
+| --- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| M-1 | Hillshade доступен на z9 | Чекбокс «Тени рельефа» при `zoom = 9` **не disabled**; hint скрыт; vector-source запрашивает тайлы при включении. |
+| M-2 | Hillshade-opacity zoom-aware | `paint['raster-opacity']` для слоя `terrain-hillshade` — `interpolate`-выражение со stops для z9, z10, z11, z12, z14. |
+| M-3 | Hillshade-contrast zoom-aware | `paint['raster-contrast']` — `interpolate`-выражение с положительными значениями на z9-z11 и 0 на z14. |
+| M-4 | Hillshade-resampling | `paint['raster-resampling']` для `terrain-hillshade` = `'nearest'`. |
+| M-5 | TRI-opacity zoom-aware | `paint['raster-opacity']` для `terrain-tri` — `interpolate`-выражение со stops для z5..z15. |
+| M-6 | TRI-resampling | `paint['raster-resampling']` для `terrain-tri` = `'nearest'`. |
+| M-7 | Регрессия z8 | На z8 видимость слоя «Перепады» (TRI) визуально не отличается от состояния до ET-013 (opacity stops содержат точку `8 → 0.70`). |
+| M-8 | Регрессия z14-z15 | На z14 hillshade visually близок к до-ET-013 (opacity ~0.40, contrast ~0). |
+| M-9 | Качественный тест z9-z11 | На скриншоте z10 над холмистым районом (например, юг Москвы / Ока) перепады «явно различимы» — критерий ручной (TC-UI-04-Z10-Q). При отказе — донастройка stops. |
+| M-10 | Сетевой объём | При типичной сессии (10 зумов между z8 и z12 c включёнными обоими слоями) объём загруженных PNG-тайлов hillshade и TRI вырос не более чем на 35%. |
+
+## 5. Риски
+
+| # | Риск | Вероятность | Влияние | Митигация |
+| ---- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| R-1 | `raster-contrast` со значением 0.4 даёт «жесть» — пересвет/чернота на тёмных тайлах. | Средняя | Среднее | TC-UI-04-Z10-Q — визуальная приёмка. При проблеме — снизить contrast в stops до 0.25-0.30. F-06 — точки калибруются итеративно. |
+| R-2 | На тёмной теме (`theme-dark`, ET-007) hillshade при opacity 0.65 и contrast 0.4 сливается с подложкой в кашу. | Средняя | Среднее | TC-UI-09-Z10-DARK-Q. При проблеме — добавить отдельные stops для dark-theme через `theme-change` event. Прозрачнее (например 0.55 вместо 0.65) на dark. |
+| R-3 | На спутниковой подложке (ET-007) opacity 0.65 + contrast 0.4 слишком «глушит» космоснимок. | Низкая | Среднее | TC-UI-08-Z10-SAT-Q. Hillshade на спутнике пользователю обычно не нужен — он использует подложку как замену рельефу. Если визуально некрасиво — на спутнике hillshade оставить opacity 0.40 (старое поведение). |
+| R-4 | Снижение UI-минзума hillshade до 9 раздувает сетевой трафик (z9 тайл = 4× больше z8 → область покрывается 4× меньшим числом тайлов, но каждый сессия теперь видит на 1 zoom-уровень больше). | Низкая | Низкое | M-10 (≤ +35%). На практике пользователь либо «включил и не двигается», либо «зумит — тайлы кэшируются». nginx и браузер кэшируют PNG агрессивно (Cache-Control: immutable, см. main.py:1252). |
+| R-5 | `raster-resampling: 'nearest'` на overzoom (z12-z15) даёт «пикселизацию», крупные квадраты вместо плавных теней. | Средняя | Низкое | TC-UI-06-Z14-Q. На z12-z14 пользователь обычно отключает hillshade — для города нужна подложка. Если визуально плохо — переключить на `'linear'` на z12+ через JS-логику (отдельный layer). В MVP оставляем `'nearest'`. |
+| R-6 | Изменение opacity TRI на z9-z11 (с 0.7 до 0.85) перекрывает грунтовки / тропы (`trails-track`, `trails-path-bridleway`). | Низкая | Низкое | `applyTerrainLayer` уже вставляет terrain-слои **перед** первым слоем `trails-*` или `poi-*` (`src/web/app.js:3337-3339`). z-order остаётся правильным. |
+| R-7 | После изменения paint-выражения старый clients (вкладка в браузере) видит «сломанный стиль» при F5. | Очень низкая| Низкое | Простой релоад страницы решает (стили задаются в JS, не в localStorage). Никакой миграции состояния не требуется. |
+| R-8 | `interpolate` с `raster-contrast` плохо поддерживается старыми версиями MapLibre. | Низкая | Низкое | MapLibre 4.7.0 (`unpkg.com/maplibre-gl@4.7.0`, см. index.html:10) поддерживает `interpolate` для всех raster paint-properties. |
+| R-9 | TRI на z5-z7 при увеличении opacity на крупных зумах остаётся как было — но без stops для z5/z6/z7 может «прыгнуть». | Низкая | Низкое | F-08 явно задаёт stops для z5, z7, z8 — сохранение прежнего поведения на z5-z7. interpolate-линейный гарантирует гладкость. |
+| R-10 | Цвета TRI (категориальная палитра) на nearest-resampling показывают резкие границы 30-метровых клеток SRTM — выглядит «зернисто». | Средняя | Низкое | Это и есть желаемое поведение: пользователь видит «реальные» границы перепадов, а не сглаженный туман. Если визуально не нравится — оставить `'linear'` для TRI (откатить F-09). |
+| R-11 | Если на test-среде тайлы z9-z11 не нарезаны (расхождение с PH-6 BRD), при включении hillshade на z9 будут 404. | Низкая | Высокое | Pre-implementation check: `curl https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/9/X/Y.png` должен вернуть 200. Если 404 — задача делится: сначала догенерить тайлы (PH-6 follow-up), потом ET-013. |
+
+## 6. Зависимости
+
+### Frontend
+- `src/web/app.js`:
+ - `onTerrainCheckbox` (~2782): вызовы `applyTerrainLayer`.
+ - `applyTerrainLayer` (~3316): расширить, чтобы принимать opacity-выражение и paint-объект.
+ - `updateHillshadeAvailability` (~3359): сменить порог `< 10` на `< 9`.
+- `src/web/index.html`:
+ - `#terrain-hillshade-hint` (строка 60): обновить текст «Зум 10+» → «Зум 9+».
+- Стили карты `style.json`/`style-dark.json` — без изменений (растровые слои не описаны в стилях, они добавляются динамически из JS).
+
+### Backend
+- `src/api/main.py:1240-1255` (`terrain_tile`) — **без изменений**. Никаких новых endpoint, query, заголовков.
+
+### Тесты
+- Новые unit-тесты `tests/unit/test_terrain_paint.py` (новый файл) — проверка структуры paint-выражений (stops, типы значений). Запуск через Node/jsdom либо чистый JS-парсер MapLibre style spec (см. TRZ §3.13).
+- Расширение существующих тестов слоёв (если есть). На текущий момент в репо нет тестов для `applyTerrainLayer` — добавляем минимальные.
+- UI-тесты: `04b-ui-test-cases.md`.
+
+### Документация
+- `01-brd.md` (этот файл).
+- `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `04b-ui-test-cases.md`.
+- ADR не требуется (это калибровка paint-параметров, не архитектурное решение). Если в реализации возникнет нужда в добавлении dark/satellite-specific paint-таблиц — добавляется `06-adr/adr-0001-theme-specific-terrain.md`.
+
+### Инфра / Данные
+- Test-среда `https://openclaw.mva154.duckdns.org/enduro/` — существующий деплой.
+- Растровые тайлы рельефа в `/home/slin/enduro-trails/data/terrain/{hillshade,tri}/{z}/{x}/{y}.png` — **существующие**, без перегенерации.
+- **Обязательная pre-implementation проверка**: тайлы hillshade z9 и z10 над ЦФО действительно доступны (R-11).
+ ```bash
+ curl -I https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/9/308/158.png
+ curl -I https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/10/617/317.png
+ ```
+ Ожидается HTTP 200 на оба.
+
+### Связи с другими work items
+- **PH-6.terrain** — родительская фаза. ET-013 — post-MVP калибровка её UI.
+- **ET-007** — переключатель подложки Схема/Спутник. R-3 покрывает совместимость.
+- **ET-009 / ET-008** — публичные GPS-треки. Не пересекаются (отдельные источники и слои).
+- Будущий work item «hillshade-rerender-z9-z14 с z-factor 2.5» — на случай, если frontend-калибровки недостаточно.
+
+## 7. План в одну строку
+
+Снижаем UI-минзум hillshade с 10 до 9, переводим `raster-opacity` и
+`raster-contrast` hillshade в zoom-aware `interpolate`-выражения
+с пиком контраста на z9-z11, аналогично усиливаем opacity TRI на
+z9-z11, переключаем `raster-resampling` на `'nearest'` — без
+перегенерации растровых тайлов и без изменения backend.
diff --git a/docs/work-items/ET-013/02-trz.md b/docs/work-items/ET-013/02-trz.md
new file mode 100644
index 0000000..49a5e84
--- /dev/null
+++ b/docs/work-items/ET-013/02-trz.md
@@ -0,0 +1,606 @@
+---
+type: trz
+work_item_id: ET-013
+title: "ТЗ: Перепады высот на z9-z11 — zoom-aware paint для hillshade и TRI"
+version: 1
+status: draft
+created_at: 2026-06-04
+updated_at: 2026-06-04
+authors:
+ - "agent:analyst"
+related:
+ - "PH-6.terrain"
+ - "ET-007"
+---
+
+# ТЗ — ET-013: Перепады высот на z9-z11
+
+## 1. Терминология
+
+- **Hillshade** — растровый слой теневого рельефа из
+ `/terrain/hillshade/{z}/{x}/{y}.png`. MapLibre layer id —
+ `terrain-hillshade`, source id — `terrain-hillshade-source`.
+- **TRI** («Перепады») — растровый слой Terrain Ruggedness Index
+ из `/terrain/tri/{z}/{x}/{y}.png`. Layer id — `terrain-tri`,
+ source id — `terrain-tri-source`.
+- **Zoom-tier paint** — MapLibre `interpolate`-выражение со
+ stops по `['zoom']`, задаёт значение paint-property как функцию
+ текущего зума.
+- **Raster paint properties** (MapLibre spec):
+ - `raster-opacity` ∈ [0, 1] — прозрачность слоя.
+ - `raster-contrast` ∈ [-1, 1] — усиление контраста PNG; 0 — без изменений, > 0 — усиление, < 0 — снижение.
+ - `raster-resampling` ∈ `{'linear', 'nearest'}` — алгоритм
+ масштабирования тайла на пиксели экрана. `'nearest'` даёт
+ «пиксельные» резкие границы.
+- **UI-минзум hillshade** — порог в `updateHillshadeAvailability`,
+ ниже которого чекбокс «Тени рельефа» disabled. Сейчас 10, после ET-013 — 9.
+
+## 2. Архитектурные опоры
+
+ET-013 не вводит новых слоёв, источников, endpoint'ов. Используем:
+
+- `src/web/app.js`:
+ - константа `TERRAIN_BASE_URL` (~2726) — без изменений.
+ - `onTerrainCheckbox` (~2766) — без изменений сигнатуры; меняются
+ параметры внутри вызовов `applyTerrainLayer`.
+ - `applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom)` (~3316) —
+ расширяется (см. REQ-F-04).
+ - `updateHillshadeAvailability` (~3359) — порог `< 10` → `< 9`.
+ - `restoreTerrainState` (~3379) — без изменений (вызывает onTerrainCheckbox).
+- `src/web/index.html`:
+ - `#terrain-hillshade-hint` (строка 60) — текст «Зум 10+» → «Зум 9+».
+- `src/api/main.py:1240` (`terrain_tile`) — **без изменений**.
+
+ET-013 = **9 правок: 2 в HTML/text, 7 в одном JS-файле**.
+
+## 3. Требования
+
+### REQ-F-01 — Снизить UI-минзум hillshade до 9
+
+Файл `src/web/app.js`, функция `updateHillshadeAvailability`
+(строка ~3368):
+
+```js
+if (zoom < 10) {
+```
+заменить на
+```js
+if (zoom < 9) { // ET-013: на z9 hillshade уже доступен
+```
+
+**Acceptance check.** При `window._map.setZoom(9)` чекбокс
+`#terrain-hillshade-cb` имеет `disabled === false` и hint
+`#terrain-hillshade-hint` имеет `display: 'none'`.
+
+### REQ-F-02 — Снизить minzoom source `terrain-hillshade-source` до 9
+
+Файл `src/web/app.js`, функция `onTerrainCheckbox` (строка ~2782).
+Заменить:
+```js
+applyTerrainLayer('terrain-hillshade', TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png',
+ hillshadeChecked, 0.40, 10, 15);
+```
+на:
+```js
+// ET-013: hillshade теперь доступен с z9; opacity и contrast — zoom-aware
+applyTerrainLayer('terrain-hillshade',
+ TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png',
+ hillshadeChecked,
+ HILLSHADE_PAINT, // см. REQ-F-04, REQ-F-05
+ 9, 15);
+```
+
+**Acceptance check.** В DevTools после включения слоя:
+```js
+window._map.getSource('terrain-hillshade-source').minzoom === 9
+```
+
+### REQ-F-03 — Снизить minzoom source `terrain-tri-source` остаётся 5
+
+Файл `src/web/app.js`, строка ~2783. Менять только параметр
+opacity (см. REQ-F-08). minzoom/maxzoom не трогаем:
+
+```js
+applyTerrainLayer('terrain-tri',
+ TERRAIN_BASE_URL + '/tri/{z}/{x}/{y}.png',
+ triChecked,
+ TRI_PAINT, // см. REQ-F-04, REQ-F-08
+ 5, 15);
+```
+
+### REQ-F-04 — Расширить `applyTerrainLayer` для поддержки paint-объекта
+
+Файл `src/web/app.js`, функция `applyTerrainLayer` (строки ~3316-3357).
+
+Текущая сигнатура:
+```js
+function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
+ ...
+ paint: { 'raster-opacity': opacity, 'raster-resampling': 'linear' },
+ ...
+}
+```
+
+Новая сигнатура (обратно-совместимая):
+```js
+/**
+ * @param {string} id - id слоя.
+ * @param {string} tileUrl - URL-шаблон тайлов.
+ * @param {boolean} enabled - показывать ли слой.
+ * @param {number|object} opacityOrPaint - либо число (старый контракт,
+ * станет 'raster-opacity'), либо объект paint-properties целиком.
+ * Если объект — должен содержать как минимум 'raster-opacity'.
+ * @param {number} minzoom
+ * @param {number} maxzoom
+ */
+function applyTerrainLayer(id, tileUrl, enabled, opacityOrPaint, minzoom, maxzoom) {
+ const map = window._map;
+ if (!map) return;
+ const sourceId = id + '-source';
+
+ // ET-013: нормализация paint
+ const paint = (typeof opacityOrPaint === 'number')
+ ? { 'raster-opacity': opacityOrPaint, 'raster-resampling': 'linear' }
+ : opacityOrPaint;
+
+ if (enabled) {
+ if (!map.getSource(sourceId)) {
+ map.addSource(sourceId, {
+ type: 'raster',
+ tiles: [tileUrl],
+ tileSize: 256,
+ scheme: 'tms',
+ minzoom: minzoom,
+ maxzoom: maxzoom
+ });
+ }
+ if (!map.getLayer(id)) {
+ const firstTrailLayer = map.getStyle().layers.find(l =>
+ l.id.startsWith('trails-') || l.id.startsWith('poi-')
+ );
+ map.addLayer({
+ id: id,
+ type: 'raster',
+ source: sourceId,
+ paint: paint,
+ minzoom: minzoom,
+ maxzoom: maxzoom
+ }, firstTrailLayer ? firstTrailLayer.id : undefined);
+ }
+ } else {
+ if (map.getLayer(id)) map.removeLayer(id);
+ if (map.getSource(sourceId)) map.removeSource(sourceId);
+ }
+}
+```
+
+**Acceptance check.** Unit-тест (см. REQ-F-13):
+- `applyTerrainLayer(id, url, true, 0.5, 8, 14)` — старый контракт работает.
+- `applyTerrainLayer(id, url, true, {'raster-opacity': 0.5, 'raster-contrast': 0.3, 'raster-resampling': 'nearest'}, 8, 14)` — paint применён как есть.
+
+### REQ-F-05 — Hillshade `raster-opacity` zoom-aware
+
+Файл `src/web/app.js`, после определения `TERRAIN_BASE_URL` (после строки ~2726)
+добавить блок констант:
+
+```js
+// ET-013: zoom-aware paint для слоёв рельефа.
+// Цель — компенсировать «потерю выразительности» перепадов на z9-z11.
+// Pre-z9 — hillshade не показывается (UI-минзум). На z9-z11 — максимальный
+// контраст и opacity, чтобы тени читались как на z8. К z12-z14 — возврат
+// к исходным значениям (тогда у пользователя есть другие способы
+// читать рельеф: подложка, грунтовки, POI).
+
+const HILLSHADE_PAINT = {
+ 'raster-opacity': [
+ 'interpolate', ['linear'], ['zoom'],
+ 9, 0.65,
+ 10, 0.60,
+ 11, 0.55,
+ 12, 0.50,
+ 14, 0.40
+ ],
+ 'raster-contrast': [
+ 'interpolate', ['linear'], ['zoom'],
+ 9, 0.40,
+ 10, 0.35,
+ 11, 0.30,
+ 12, 0.15,
+ 14, 0.00
+ ],
+ 'raster-resampling': 'nearest'
+};
+```
+
+Stops подобраны так:
+- z9-z11 — пик opacity (0.65→0.55) и contrast (0.40→0.30). Это
+ компенсация: тени темнее и контрастнее.
+- z12-z14 — плавный возврат к исходному (opacity 0.40, contrast 0):
+ на крупных зумах пользователь уже видит подложку детально и
+ тени должны «уйти на второй план».
+- `'nearest'` resampling: подчёркивает 30-метровые границы SRTM,
+ перепады выглядят резко.
+
+**Acceptance check.**
+```js
+const layer = window._map.getLayer('terrain-hillshade');
+const opacity = window._map.getPaintProperty('terrain-hillshade', 'raster-opacity');
+Array.isArray(opacity) && opacity[0] === 'interpolate' // true
+```
+
+### REQ-F-06 — Hillshade `raster-contrast` (внутри HILLSHADE_PAINT)
+
+См. REQ-F-05. Constants выносятся в HILLSHADE_PAINT, отдельной правки кода не нужно.
+
+### REQ-F-07 — Hillshade `raster-resampling: 'nearest'`
+
+См. REQ-F-05. Часть HILLSHADE_PAINT.
+
+### REQ-F-08 — TRI `raster-opacity` zoom-aware
+
+В том же блоке (после HILLSHADE_PAINT, до `function toggleTerrainPopup`):
+
+```js
+const TRI_PAINT = {
+ 'raster-opacity': [
+ 'interpolate', ['linear'], ['zoom'],
+ 5, 0.55,
+ 7, 0.65,
+ 8, 0.70, // регрессия z8: текущее значение
+ 9, 0.80,
+ 10, 0.85,
+ 11, 0.85, // пик на z9-z11
+ 12, 0.75,
+ 15, 0.70
+ ],
+ 'raster-resampling': 'nearest'
+};
+```
+
+Stops:
+- **z5-z7** — мягко (0.55-0.65), на «обзорных» зумах не глушим карту.
+- **z8** — 0.70 ровно как сейчас (регрессия).
+- **z9-z11** — пик 0.80-0.85 (целевое улучшение ET-013).
+- **z12-z15** — спад до 0.70-0.75.
+
+**Acceptance check.**
+```js
+const opacity = window._map.getPaintProperty('terrain-tri', 'raster-opacity');
+// На z8 — 0.70 ровно (регрессия).
+// На z10 — 0.85 ровно (целевое поведение).
+```
+
+### REQ-F-09 — TRI `raster-resampling: 'nearest'`
+
+Часть TRI_PAINT, см. REQ-F-08.
+
+### REQ-F-10 — Обновить UI-hint текст
+
+Файл `src/web/index.html`, строка ~60:
+```html
+Зум 10+
+```
+заменить на
+```html
+Зум 9+
+```
+
+### REQ-F-11 — `updateHillshadeAvailability` использует новый порог
+
+См. REQ-F-01. Никаких других изменений в этой функции не нужно.
+
+### REQ-F-12 — Сохранить контракт `onTerrainCheckbox`
+
+Сигнатура и логика persistence в `localStorage` (`terrain-hillshade`,
+`terrain-tri`) — без изменений. Кнопка `#terrain-toggle` `.active`
+переключается так же.
+
+### REQ-F-13 — Unit-тесты paint-выражений
+
+Файл `tests/unit/test_terrain_paint.js` (новый; если JS-тесты раньше
+не было — настроить vitest/jest в `package.json` либо использовать
+существующий тест-раннер; альтернатива — Python-парсер JSON-выражений).
+
+Реализация в одной из двух форм:
+
+**Вариант A: JS unit-тест (jest/vitest)**
+
+```js
+// tests/unit/test_terrain_paint.test.js
+import { HILLSHADE_PAINT, TRI_PAINT } from '../../src/web/terrain-paint.js';
+// Если константы внутри app.js: либо вынести в отдельный модуль,
+// либо использовать AST-парсер. См. альтернативу B.
+
+describe('ET-013 terrain paint', () => {
+ test('HILLSHADE_PAINT: raster-opacity is interpolate by zoom', () => {
+ const op = HILLSHADE_PAINT['raster-opacity'];
+ expect(op[0]).toBe('interpolate');
+ expect(op[1][0]).toBe('linear');
+ expect(op[2][0]).toBe('zoom');
+ // stops: ..., 9, 0.65, 10, 0.60, 11, 0.55, 12, 0.50, 14, 0.40
+ const stops = op.slice(3);
+ expect(stops).toContain(9);
+ expect(stops[stops.indexOf(9) + 1]).toBeCloseTo(0.65, 2);
+ expect(stops[stops.indexOf(11) + 1]).toBeCloseTo(0.55, 2);
+ expect(stops[stops.indexOf(14) + 1]).toBeCloseTo(0.40, 2);
+ });
+
+ test('HILLSHADE_PAINT: raster-contrast peak at z9-z11', () => {
+ const c = HILLSHADE_PAINT['raster-contrast'];
+ expect(c[0]).toBe('interpolate');
+ const stops = c.slice(3);
+ expect(stops[stops.indexOf(9) + 1]).toBeGreaterThanOrEqual(0.35);
+ expect(stops[stops.indexOf(14) + 1]).toBeLessThanOrEqual(0.05);
+ });
+
+ test('HILLSHADE_PAINT: resampling nearest', () => {
+ expect(HILLSHADE_PAINT['raster-resampling']).toBe('nearest');
+ });
+
+ test('TRI_PAINT: z8 unchanged (regression)', () => {
+ const op = TRI_PAINT['raster-opacity'];
+ const stops = op.slice(3);
+ expect(stops[stops.indexOf(8) + 1]).toBeCloseTo(0.70, 2);
+ });
+
+ test('TRI_PAINT: peak at z9-z11', () => {
+ const op = TRI_PAINT['raster-opacity'];
+ const stops = op.slice(3);
+ expect(stops[stops.indexOf(10) + 1]).toBeGreaterThanOrEqual(0.80);
+ expect(stops[stops.indexOf(11) + 1]).toBeGreaterThanOrEqual(0.80);
+ });
+
+ test('TRI_PAINT: resampling nearest', () => {
+ expect(TRI_PAINT['raster-resampling']).toBe('nearest');
+ });
+});
+```
+
+**Вариант B: Python-парсер (если JS-тестов в проекте нет)**
+
+```python
+# tests/unit/test_terrain_paint.py
+import re
+from pathlib import Path
+
+APP_JS = Path(__file__).parents[2] / 'src/web/app.js'
+
+def test_hillshade_paint_exists():
+ txt = APP_JS.read_text(encoding='utf-8')
+ assert 'HILLSHADE_PAINT' in txt
+ assert "'raster-opacity'" in txt
+ assert "'raster-contrast'" in txt
+ assert "'raster-resampling': 'nearest'" in txt
+
+def test_hillshade_opacity_stops():
+ """Сверяем stops по grep — недостаточно строго, но удержит регрессию."""
+ txt = APP_JS.read_text(encoding='utf-8')
+ # ищем блок HILLSHADE_PAINT и проверяем stop'ы
+ m = re.search(r"HILLSHADE_PAINT\s*=\s*\{(.+?)\};", txt, re.DOTALL)
+ assert m, "HILLSHADE_PAINT not found"
+ block = m.group(1)
+ assert '9, 0.65' in block or '9, 0.65' in block
+ assert '11, 0.55' in block
+ assert '14, 0.40' in block
+
+def test_tri_opacity_regression_z8():
+ txt = APP_JS.read_text(encoding='utf-8')
+ m = re.search(r"TRI_PAINT\s*=\s*\{(.+?)\};", txt, re.DOTALL)
+ assert m
+ block = m.group(1)
+ assert '8, 0.70' in block or '8, 0.70' in block, "z8 opacity должна остаться 0.70"
+ assert '10, 0.85' in block
+```
+
+**Решение по умолчанию для ET-013:** Вариант B (Python-парсер),
+т.к. в проекте JS-тестов не существует, а ставить vitest ради ET-013
+— превышение scope. Опционально разработчик может выбрать Вариант A.
+
+### REQ-F-14 — Регрессионные тесты
+
+Файл `tests/unit/test_terrain_paint.py` (тот же файл, что и REQ-F-13):
+
+- **UT-REG-01.** Проверить, что вызов `applyTerrainLayer` с числовым
+ `opacity` (старый контракт) собирает paint `{raster-opacity: X, raster-resampling: 'linear'}` —
+ на случай, если другой код (POI, halo, scenic) использует ту же
+ функцию. На текущий момент `applyTerrainLayer` вызывается **только**
+ внутри `onTerrainCheckbox` — но контракт должен оставаться обратно-совместимым.
+
+ Реализация — статический grep по `src/web/`:
+ ```python
+ import re, glob
+ def test_only_two_callers_of_applyterrainLayer():
+ pattern = re.compile(r'applyTerrainLayer\s*\(')
+ total = 0
+ for f in glob.glob('src/web/*.js'):
+ total += len(pattern.findall(open(f).read()))
+ assert total >= 2 # минимум 2 вызова в onTerrainCheckbox
+ ```
+
+- **UT-REG-02.** `updateHillshadeAvailability` порог = 9
+ (grep по строке `zoom < 9`).
+
+### REQ-F-15 — Integration smoke-тест: тайлы z9 доступны
+
+Файл `tests/integration/test_terrain_z9_tiles.py` (новый):
+
+- **IT-TILE-Z9-01.** При наличии `data/terrain/hillshade/9/`
+ директории — запрос `GET /terrain/hillshade/9/308/158.png`
+ возвращает 200, content-type `image/png`. Если директория
+ не существует — тест **skipped** с пояснением.
+ ```python
+ import os, pytest
+ from fastapi.testclient import TestClient
+ from src.api.main import app
+
+ TERRAIN_DIR = os.environ.get(
+ 'TERRAIN_DIR', os.path.join(os.path.dirname(__file__), '../../data/terrain')
+ )
+
+ client = TestClient(app)
+
+ @pytest.mark.skipif(
+ not os.path.isdir(os.path.join(TERRAIN_DIR, 'hillshade/9')),
+ reason='hillshade z9 tiles not present in CI (PH-6 data not in repo)'
+ )
+ def test_hillshade_z9_tile_returns_200():
+ # Любой существующий тайл из директории
+ z9_dir = os.path.join(TERRAIN_DIR, 'hillshade/9')
+ x = sorted(os.listdir(z9_dir))[0]
+ y_file = sorted(os.listdir(os.path.join(z9_dir, x)))[0]
+ y = y_file.replace('.png', '')
+ r = client.get(f'/terrain/hillshade/9/{x}/{y}.png')
+ assert r.status_code == 200
+ assert r.headers['content-type'] == 'image/png'
+
+ def test_hillshade_invalid_zoom_404():
+ r = client.get('/terrain/hillshade/99/0/0.png')
+ assert r.status_code == 404
+ ```
+
+### REQ-F-16 — UI-тесты Playwright
+
+См. `04b-ui-test-cases.md`. Ключевые проверки (полный список — там):
+
+- TC-UI-01-Z9: hillshade доступен на z9, hint скрыт.
+- TC-UI-02-Z8-REGR: на z8 TRI визуально как до ET-013.
+- TC-UI-03-Z9-Q: визуальная читаемость перепадов на z9 ≥ z8 (качественно).
+- TC-UI-04-Z10-Q: то же для z10.
+- TC-UI-05-Z11-Q: то же для z11.
+- TC-UI-06-Z14-Q: на z14 hillshade «нормальный», не перегретый.
+- TC-UI-07-Z9-MOBILE: мобильный viewport, hillshade видим на z9.
+- TC-UI-08-Z10-SAT-Q: совместимость со спутниковой подложкой.
+- TC-UI-09-Z10-DARK-Q: совместимость с тёмной темой.
+- TC-UI-10-PERSIST: localStorage `terrain-hillshade`/`terrain-tri`
+ переживает перезагрузку, паттерн чекбоксов восстанавливается.
+
+### REQ-F-17 — Persistence без миграции
+
+Ключи `localStorage`:
+- `terrain-hillshade` ('1' | '0') — без изменений.
+- `terrain-tri` ('1' | '0') — без изменений.
+
+После ET-013 пользователи с включённым hillshade при следующей
+загрузке на z9 увидят слой автоматически (раньше он был disabled).
+Это не миграция, а ожидаемое улучшение UX.
+
+### REQ-F-18 — Не менять API контракт
+
+`GET /terrain/{layer}/{z}/{x}/{y}.png` — без изменений. Никаких
+новых query, headers, кодов ответа. `Cache-Control: immutable`
+сохраняется.
+
+### REQ-F-19 — Не менять конфиги и стили
+
+- `src/web/style.json`, `src/web/style-dark.json` — без изменений.
+- `src/web/app.css` — без изменений (стили чекбоксов не меняются).
+- `config/*.yaml` — без изменений.
+
+### REQ-F-20 — Деплой и валидация
+
+После merge в `main` и деплоя:
+
+1. **Pre-merge sanity** (на test-среде до деплоя):
+ ```bash
+ curl -sI https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/9/308/158.png | head -1
+ ```
+ Ожидается `HTTP/1.1 200 OK`. Если 404 — задача останавливается,
+ тайлы z9 нужно догенерировать в рамках PH-6 follow-up.
+
+2. **Smoke в test-среде**:
+ - Открыть карту, центр над Окой/югом Москвы (`[37.6, 54.5]`).
+ - `window._map.setZoom(9)` — кнопка «Тени рельефа» активна.
+ - Включить «Тени рельефа» и «Перепады».
+ - Скриншот → визуальная приёмка по AC-03..AC-05.
+3. **Зафиксировать в `14-deploy-log.md`**.
+
+### REQ-F-21 — Документация
+
+В `docs/work-items/ET-013/` после Анализа:
+- `00-business-request.md` (есть)
+- `01-brd.md`
+- `02-trz.md` (этот файл)
+- `03-acceptance-criteria.md`
+- `04-test-plan.yaml`
+- `04b-ui-test-cases.md`
+
+После реализации: `12-review.md`, `13-test-report.md`,
+`14-deploy-log.md`. ADR опционально (см. BRD §6).
+
+## 4. Не-функциональные требования
+
+### NFR-01 — Производительность клиента
+- Добавление двух `interpolate`-выражений в paint не должно
+ заметно увеличивать render time. MapLibre кэширует
+ скомпилированные style-выражения; разница < 1 мс на frame.
+- `raster-resampling: 'nearest'` дешевле, чем `'linear'`
+ (без bilinear-фильтрации) — на самом деле небольшое
+ ускорение растеризации.
+
+### NFR-02 — Производительность сервера
+Без изменений: endpoint `terrain_tile` отдаёт PNG из файловой системы
+с `Cache-Control: immutable`.
+
+### NFR-03 — Сетевой трафик
+- При снижении UI-минзума hillshade с 10 до 9 пользователь
+ может видеть слой на одной zoom-ступени раньше, что добавляет
+ ~25-35% PNG-тайлов на типичную сессию активного зумирования
+ с включённым hillshade.
+- Browser-кэш + nginx-кэш (`Cache-Control: max-age=31536000,
+ immutable`) поглощают это после первого визита.
+- Регрессия `M-10`: рост ≤ 35%.
+
+### NFR-04 — Совместимость
+- MapLibre 4.7.0 (см. `index.html:10`, `index.html:503`)
+ поддерживает все используемые paint properties и
+ `interpolate`-выражения.
+- Старые tab'ы (без обновления страницы) продолжают работать
+ с прежним кодом до перезагрузки.
+
+### NFR-05 — Безопасность
+Никаких изменений в auth / CSP / валидации.
+
+### NFR-06 — Логирование
+Никаких новых лог-сообщений. `uvicorn.access` для `/terrain/*`
+работает как раньше.
+
+### NFR-07 — Persistence
+`localStorage` — без миграции. Существующие ключи интерпретируются
+как раньше; включённый ранее hillshade автоматически появится на
+z9 при следующей загрузке.
+
+## 5. План работ (для разработчика)
+
+1. **Pre-implementation check**: проверить наличие тайлов z9-z11
+ на test-среде (REQ-F-20 §1). Если 404 — стоп, открыть PH-6
+ follow-up.
+2. **Frontend constants**: добавить `HILLSHADE_PAINT` и `TRI_PAINT`
+ (REQ-F-05, F-08) после `TERRAIN_BASE_URL`.
+3. **Frontend `applyTerrainLayer`**: расширить сигнатуру (REQ-F-04).
+4. **Frontend `onTerrainCheckbox`**: перевести вызовы на константы
+ (REQ-F-02, F-03).
+5. **Frontend `updateHillshadeAvailability`**: порог `< 10` → `< 9`
+ (REQ-F-01, F-11).
+6. **HTML hint**: «Зум 10+» → «Зум 9+» (REQ-F-10).
+7. **Тесты**: `tests/unit/test_terrain_paint.py` (REQ-F-13, F-14).
+8. **Integration smoke**: `tests/integration/test_terrain_z9_tiles.py`
+ (REQ-F-15) — с `@pytest.mark.skipif` для CI без данных.
+9. **`make lint` / `make test`** — должны пройти.
+10. **Code review → merge → deploy в test**.
+11. **Ручная валидация** (REQ-F-20 §2).
+12. **Playwright UI-тесты** по `04b-ui-test-cases.md`.
+13. **Запись в `13-test-report.md` и `14-deploy-log.md`**.
+
+## 6. Открытые вопросы и решения по умолчанию
+
+| Вопрос | Решение по умолчанию |
+|---|---|
+| Стоит ли понижать UI-минзум hillshade ещё дальше (z8)? | **Нет.** На z8 hillshade-тайлы 256px покрывают ~150 км по широте — крупные тени становятся неразборчивым «шумом». TRI работает лучше. Если будущий BRD захочет — отдельная задача. |
+| Стоит ли использовать разные paint для тёмной темы (`theme-dark`)? | **Не в MVP.** Если AC-09 (TC-UI-09-Z10-DARK-Q) показывает «слой сливается с подложкой» — добавить ADR-0001 о theme-specific paint в follow-up. |
+| Стоит ли использовать разные paint для спутниковой подложки? | **Не в MVP.** Hillshade на спутнике пользователю обычно не нужен — он использует подложку как замену рельефу. Если AC-08 (TC-UI-08-Z10-SAT-Q) показывает «глушит подложку» — отдельная итерация. |
+| Стоит ли добавить `raster-saturation` для TRI? | **Не в MVP.** Сначала смотрим на эффект от `raster-opacity` + `'nearest'`. Если визуально недостаточно ярко — добавить второй раунд калибровки. |
+| Перегенерировать ли hillshade с z-factor 2.5 для z9-z14? | **Не сейчас.** Отдельная задача в случае, если frontend-калибровка ET-013 не решает проблему (вероятность по моей оценке — низкая). |
+| Менять ли `raster-resampling` динамически по зуму? | **Нет.** MapLibre не поддерживает `interpolate` для `raster-resampling`. Глобальное `'nearest'` для обоих слоёв — приемлемый компромисс (см. R-5). |
+| Подключить ли гипсометрию в UI? | **Out of scope.** Hypso тайлы есть, но UI-чекбокса нет. Отдельная задача. |
+| Делать ли paint-таблицы переменными окружения / config'ом? | **Нет.** Это калибровка, она живёт в коде и меняется коммитом. Конфигурируемость — преждевременная абстракция. |
+| Стоит ли добавлять `vitest`/`jest` ради JS-unit-тестов? | **Нет в ET-013.** Используем Python-парсер (Вариант B в REQ-F-13). |
diff --git a/docs/work-items/ET-013/03-acceptance-criteria.md b/docs/work-items/ET-013/03-acceptance-criteria.md
new file mode 100644
index 0000000..006e4a5
--- /dev/null
+++ b/docs/work-items/ET-013/03-acceptance-criteria.md
@@ -0,0 +1,236 @@
+---
+type: acceptance-criteria
+work_item_id: ET-013
+title: "Acceptance Criteria: Перепады высот на z9-z11"
+version: 1
+status: draft
+created_at: 2026-06-04
+updated_at: 2026-06-04
+authors:
+ - "agent:analyst"
+---
+
+# Acceptance Criteria — ET-013
+
+Критерии в Gherkin-стиле. Все обязательные. Задача считается
+принятой, когда каждый критерий прошёл проверку (автоматическую
+в CI или ручную в test-среде).
+
+## AC-01 — UI-минзум hillshade понижен до 9
+
+**Given** ветка `feature/ET-013-z9-z11-z8` после реализации
+**When** проверяется код
+**Then**:
+- В `src/web/app.js` функция `updateHillshadeAvailability` содержит
+ `if (zoom < 9)` (а не `< 10`).
+- В `src/web/index.html` элемент `#terrain-hillshade-hint` содержит
+ текст «Зум 9+» (а не «Зум 10+»).
+
+## AC-02 — Vector-source `terrain-hillshade-source` имеет minzoom=9
+
+**Given** test-среда после деплоя ET-013, включены оба чекбокса слоёв рельефа
+**When** в DevTools выполнить
+```js
+window._map.getSource('terrain-hillshade-source').minzoom
+```
+**Then** результат — `9`.
+
+## AC-03 — При z=9 hillshade доступен и виден
+
+**Given** пользователь на test-среде, центр карты над холмистым
+районом (например, юг Москвы / Ока: `[37.6, 54.5]`)
+**When** установить `window._map.setZoom(9)`, открыть `#terrain-popup`,
+включить «Тени рельефа»
+**Then**:
+- `#terrain-hillshade-cb` имеет `disabled === false`.
+- `#terrain-hillshade-hint` имеет `display: 'none'`.
+- `window._map.getLayoutProperty('terrain-hillshade', 'visibility') === 'visible'`.
+- На карте видны тени рельефа.
+
+## AC-04 — Hillshade paint zoom-aware
+
+**Given** включён hillshade на test-среде
+**When** в DevTools выполнить
+```js
+const op = window._map.getPaintProperty('terrain-hillshade', 'raster-opacity');
+const ct = window._map.getPaintProperty('terrain-hillshade', 'raster-contrast');
+const rs = window._map.getPaintProperty('terrain-hillshade', 'raster-resampling');
+```
+**Then**:
+- `Array.isArray(op) && op[0] === 'interpolate'` (zoom-aware opacity).
+- `Array.isArray(ct) && ct[0] === 'interpolate'` (zoom-aware contrast).
+- `rs === 'nearest'`.
+
+## AC-05 — TRI paint zoom-aware
+
+**Given** включён TRI на test-среде
+**When** в DevTools
+```js
+const op = window._map.getPaintProperty('terrain-tri', 'raster-opacity');
+const rs = window._map.getPaintProperty('terrain-tri', 'raster-resampling');
+```
+**Then**:
+- `Array.isArray(op) && op[0] === 'interpolate'`.
+- На z=8 эффективное значение `≈ 0.70` (регрессия).
+- На z=10 эффективное значение `≥ 0.80`.
+- `rs === 'nearest'`.
+
+## AC-06 — Регрессия z8: TRI визуально как было
+
+**Given** test-среда после деплоя
+**When** установить `zoom = 8`, включить ТОЛЬКО «Перепады» (без hillshade)
+**Then**:
+- Скриншот `et013-z8-tri-regress.png` не отличается визуально
+ заметно от состояния до ET-013 (сравнение оператором).
+- Hillshade-слой не присутствует в стиле (`!map.getLayer('terrain-hillshade')`).
+
+## AC-07 — Качественная читаемость z9 (целевой критерий)
+
+**Given** test-среда, центр над Окой / Кашира / Воробьёвы Горы
+**When** `zoom = 9`, включены оба слоя «Тени рельефа» и «Перепады»
+**Then**:
+- На скриншоте `et013-z9-readable.png` явно видны перепады
+ высот: тени по склонам, цветные пятна TRI выделяют шероховатые
+ зоны.
+- Оператор подтверждает: «перепады сопоставимы с z8 или лучше».
+- При отказе — корректировка stops в HILLSHADE_PAINT / TRI_PAINT.
+
+## AC-08 — Качественная читаемость z10
+
+**Given** test-среда, аналогично AC-07
+**When** `zoom = 10`
+**Then**: то же, что AC-07.
+
+## AC-09 — Качественная читаемость z11
+
+**Given** test-среда, аналогично AC-07
+**When** `zoom = 11`
+**Then**: то же, что AC-07.
+
+## AC-10 — Регрессия z14: hillshade не перегрет
+
+**Given** test-среда
+**When** `zoom = 14`, включён hillshade
+**Then**:
+- Эффективные значения `raster-opacity ≈ 0.40`, `raster-contrast ≈ 0`.
+- Скриншот `et013-z14-regress.png` не темнее и не контрастнее, чем
+ до ET-013.
+
+## AC-11 — Hillshade на тёмной теме читается
+
+**Given** test-среда, `theme-dark` активна
+**When** `zoom = 10`, включён hillshade
+**Then**:
+- Тени видны, не сливаются с тёмной подложкой.
+- При отказе (тени «съедают» карту) — открыть ADR
+ «theme-specific hillshade paint» и добавить отдельные stops
+ для dark-theme (см. BRD R-2). В рамках MVP ET-013 это
+ не обязательно, но фиксируется в `13-test-report.md`.
+
+## AC-12 — Hillshade на спутниковой подложке не глушит снимок
+
+**Given** test-среда, переключена подложка `#base-btn-satellite`
+**When** `zoom = 10`, включён hillshade
+**Then**:
+- На спутниковом снимке видны и детали поверхности (рельеф
+ улавливается уже через тени снимка), и hillshade-оверлей.
+- Оверлей не превращает снимок в «серую плёнку».
+- Подтверждается оператором по TC-UI-08-Z10-SAT-Q.
+
+## AC-13 — Hillshade на мобильном (375×667)
+
+**Given** Playwright mobile viewport, включён hillshade
+**When** `zoom = 9`
+**Then**:
+- Тени видны, читаемы.
+- Чекбоксы и hint работают корректно.
+
+## AC-14 — Persistence не сломан
+
+**Given** включены оба чекбокса
+**When** перезагрузить страницу (`location.reload()`)
+**Then**:
+- `localStorage.getItem('terrain-hillshade') === '1'`.
+- `localStorage.getItem('terrain-tri') === '1'`.
+- После загрузки слои восстановлены, на z=9 hillshade автоматически
+ активен.
+
+## AC-15 — Unit-тесты paint-выражений зелёные
+
+**Given** ветка
+**When** `pytest tests/unit/test_terrain_paint.py -v`
+**Then** все тесты проходят (UT-PAINT-*, UT-REG-*).
+
+## AC-16 — Integration smoke z9 тайлов
+
+**Given** ветка, наличие данных в test-среде или CI fixture
+**When** `pytest tests/integration/test_terrain_z9_tiles.py -v`
+**Then**:
+- При наличии тайлов `data/terrain/hillshade/9/*` — тесты
+ проходят: 200 на существующий тайл, 404 на невалидный zoom.
+- При отсутствии тайлов в CI — тесты `skipped` с reason.
+
+## AC-17 — Регрессионные тесты ET-007 / PH-6
+
+**Given** ветка
+**When** `pytest tests/unit/ tests/integration/ -v`
+**Then**:
+- Все существующие тесты ET-007 (переключатель Схема/Спутник)
+ и PH-6 проходят без регрессий.
+- Никакие тесты grandfather'ов не отвалились.
+
+## AC-18 — `make lint` и `make test` зелёные
+
+**Given** ветка
+**When** `make lint && make test`
+**Then** exit-code 0 на обе команды.
+
+## AC-19 — Pre-deploy проверка наличия тайлов z9-z11
+
+**Given** ветка готова к merge
+**When** на test-среде
+```bash
+curl -sI https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/9/308/158.png
+curl -sI https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/10/617/317.png
+curl -sI https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/11/1234/635.png
+```
+**Then** все три запроса возвращают HTTP 200. Если 404 на любой —
+merge приостанавливается, открывается PH-6 follow-up (догенерить
+тайлы).
+
+## AC-20 — Документация полная
+
+**Given** репо после слияния ET-013
+**When** проверка `docs/work-items/ET-013/`
+**Then** существуют:
+- `00-business-request.md`
+- `01-brd.md`
+- `02-trz.md`
+- `03-acceptance-criteria.md`
+- `04-test-plan.yaml`
+- `04b-ui-test-cases.md`
+- `12-review.md` (после Review)
+- `13-test-report.md` (после Тестирования)
+- `14-deploy-log.md` (после Деплоя)
+
+## AC-21 — Сетевая регрессия (M-10)
+
+**Given** test-среда
+**When** сценарий: открыть карту, центр над Окой, выполнить
+zoom-последовательность z=8 → z=9 → z=10 → z=11 → z=10 → z=9 → z=8
+с включёнными обоими слоями
+**Then**:
+- Суммарный network-traffic PNG-тайлов рельефа ≤ 135% от того же
+ сценария до ET-013 (зафиксированного как baseline в
+ `13-test-report.md`).
+- Никаких сторонних запросов (например, 4xx или 5xx) не возникает.
+
+## AC-22 — Контракт `applyTerrainLayer` обратно-совместим
+
+**Given** ветка
+**When** unit-тест UT-PAINT-COMPAT-01
+**Then**:
+- Вызов `applyTerrainLayer(id, url, true, 0.5, 8, 14)`
+ (старый контракт — число) собирает paint:
+ `{ 'raster-opacity': 0.5, 'raster-resampling': 'linear' }`.
+- Вызов с object'ом передаёт paint как есть.
diff --git a/docs/work-items/ET-013/04-test-plan.yaml b/docs/work-items/ET-013/04-test-plan.yaml
new file mode 100644
index 0000000..0ee4ba8
--- /dev/null
+++ b/docs/work-items/ET-013/04-test-plan.yaml
@@ -0,0 +1,336 @@
+---
+type: test-plan
+work_item_id: ET-013
+title: "Test Plan: Перепады высот на z9-z11"
+version: 1
+status: draft
+created_at: 2026-06-04
+updated_at: 2026-06-04
+authors:
+ - "agent:analyst"
+related:
+ - "PH-6.terrain"
+ - "ET-007"
+
+scope_note: >
+ ET-013 — frontend-калибровка: понижает UI-минзум hillshade с 10 до 9
+ и переводит paint-параметры (raster-opacity, raster-contrast,
+ raster-resampling) hillshade и TRI в zoom-aware форму. Backend
+ и pipeline растровых тайлов не трогаются. Тест-план фокусируется
+ на:
+ (1) корректности новых zoom-tier paint-выражений;
+ (2) обратной совместимости applyTerrainLayer;
+ (3) визуальной читаемости перепадов на z9-z11;
+ (4) регрессии z8 (TRI не изменился), z14 (hillshade не перегрет);
+ (5) совместимости с тёмной темой и спутниковой подложкой;
+ (6) что network-объём не уплыл больше +35%.
+
+test_suites:
+
+ - name: unit-terrain-paint
+ type: unit
+ description: "Структура paint-выражений HILLSHADE_PAINT и TRI_PAINT"
+ cases:
+ - id: UT-PAINT-HS-OPACITY
+ name: "HILLSHADE_PAINT: raster-opacity — interpolate с правильными stops"
+ input: |
+ Python-парсер: чтение src/web/app.js, regex по блоку
+ HILLSHADE_PAINT = { ... }; вытаскивание raster-opacity.
+ expected: |
+ Тип: ['interpolate', ['linear'], ['zoom'], ...].
+ Stops содержат: (9, 0.65), (10, 0.60), (11, 0.55),
+ (12, 0.50), (14, 0.40). Допустимо отклонение значений ±0.05
+ (калибровка) — но порядок монотонно убывающий от 9 к 14.
+
+ - id: UT-PAINT-HS-CONTRAST
+ name: "HILLSHADE_PAINT: raster-contrast — пик на z9, 0 на z14"
+ input: |
+ Тот же парсер.
+ expected: |
+ Тип interpolate. Значение на z=9 ≥ 0.30. Значение на z=14
+ ≤ 0.10. Монотонно убывает.
+
+ - id: UT-PAINT-HS-RESAMPLING
+ name: "HILLSHADE_PAINT: raster-resampling = 'nearest'"
+ input: |
+ Парсер.
+ expected: |
+ Строка 'nearest' (не 'linear').
+
+ - id: UT-PAINT-TRI-OPACITY-Z8
+ name: "TRI_PAINT: на z8 opacity = 0.70 (регрессия)"
+ input: |
+ Парсер по TRI_PAINT.
+ expected: |
+ Stop (8, 0.70) присутствует ровно (без округления).
+
+ - id: UT-PAINT-TRI-OPACITY-PEAK
+ name: "TRI_PAINT: пик на z9-z11"
+ input: |
+ Парсер.
+ expected: |
+ Stops содержат (10, X) с X ≥ 0.80 и (11, Y) с Y ≥ 0.80.
+
+ - id: UT-PAINT-TRI-RESAMPLING
+ name: "TRI_PAINT: raster-resampling = 'nearest'"
+ input: |
+ Парсер.
+ expected: |
+ 'nearest'.
+
+ - id: UT-PAINT-COMPAT-01
+ name: "applyTerrainLayer обратно-совместим с числовым opacity"
+ input: |
+ Вызов с opacity=0.5 (Node + JSDOM-mock карты).
+ expected: |
+ Внутри map.addLayer передан paint:
+ { 'raster-opacity': 0.5, 'raster-resampling': 'linear' }.
+ notes: |
+ Если запуск JS-теста не настроен — заменить на статический
+ grep по src/web/app.js: проверить ветвление
+ 'typeof opacityOrPaint === "number"'.
+
+ - id: UT-PAINT-COMPAT-02
+ name: "applyTerrainLayer принимает paint-объект"
+ input: |
+ Вызов с opacityOrPaint = { 'raster-opacity': 0.4,
+ 'raster-contrast': 0.2, 'raster-resampling': 'nearest' }.
+ expected: |
+ Этот объект передан в map.addLayer paint как есть.
+
+ - id: UT-REG-MINZOOM-9
+ name: "updateHillshadeAvailability порог = 9"
+ input: |
+ grep по src/web/app.js: 'if (zoom < 9)' внутри функции
+ updateHillshadeAvailability.
+ expected: |
+ Совпадение найдено; 'if (zoom < 10)' отсутствует.
+
+ - id: UT-REG-HINT-TEXT
+ name: "Hint текст обновлён до 'Зум 9+'"
+ input: |
+ grep по src/web/index.html: '#terrain-hillshade-hint'
+ содержит 'Зум 9+'.
+ expected: |
+ Совпадение найдено; 'Зум 10+' отсутствует.
+
+ - id: UT-REG-CALLERS
+ name: "applyTerrainLayer вызывается ровно дважды в onTerrainCheckbox"
+ input: |
+ regex 'applyTerrainLayer\s*\(' в src/web/*.js — count.
+ expected: |
+ Минимум 2 вызова в src/web/app.js. Все они находятся
+ внутри функции onTerrainCheckbox.
+
+ - name: integration-terrain-tiles
+ type: integration
+ description: "Endpoint /terrain/{layer}/{z}/{x}/{y}.png на z9-z11"
+ cases:
+ - id: IT-TILE-Z9-01
+ name: "Тайл z=9 для hillshade: 200 или skipped если данных нет"
+ input: |
+ Test-среда или CI с TERRAIN_DIR. Найти первый существующий
+ тайл z9 в директории hillshade, выполнить GET.
+ expected: |
+ Если data/terrain/hillshade/9/ существует:
+ status 200, content-type image/png, тело > 0.
+ Иначе:
+ test skipped с reason 'PH-6 data not in repo'.
+
+ - id: IT-TILE-Z10-01
+ name: "Тайл z=10 для hillshade: 200 или skipped"
+ input: |
+ То же, что IT-TILE-Z9-01 для z=10.
+ expected: |
+ status 200 или skipped.
+
+ - id: IT-TILE-Z11-01
+ name: "Тайл z=11 для hillshade: 200 или skipped"
+ input: |
+ То же для z=11.
+ expected: |
+ status 200 или skipped.
+
+ - id: IT-TILE-TRI-Z9
+ name: "TRI на z9 доступен (минзум 5, тайлы должны быть)"
+ input: |
+ GET tiles/9/X/Y.png под TRI.
+ expected: |
+ 200 или skipped (если данных нет на CI).
+
+ - id: IT-TILE-INVALID-LAYER
+ name: "Неизвестный layer → 404 (регрессия)"
+ input: |
+ GET /terrain/unknown/9/0/0.png
+ expected: |
+ status 404.
+
+ - id: IT-TILE-MISSING
+ name: "Несуществующий тайл → 404 (регрессия)"
+ input: |
+ GET /terrain/hillshade/9/99999/99999.png
+ expected: |
+ status 404.
+
+ - id: IT-TILE-CACHE-HEADER
+ name: "Cache-Control: immutable сохраняется"
+ input: |
+ GET существующего тайла.
+ expected: |
+ Header 'Cache-Control' содержит 'immutable' и max-age=31536000.
+
+ - name: regression-existing
+ type: regression
+ description: "Регрессия ET-007 / PH-6 / общих unit-тестов"
+ cases:
+ - id: RG-UNIT-ALL
+ name: "Все unit-тесты проекта зелёные"
+ input: "pytest tests/unit/ -v"
+ expected: "exit-code 0"
+
+ - id: RG-INTEG-ALL
+ name: "Все integration-тесты проекта зелёные"
+ input: "pytest tests/integration/ -v"
+ expected: "exit-code 0"
+
+ - id: RG-LINT
+ name: "Линтеры зелёные"
+ input: "make lint"
+ expected: "exit-code 0"
+
+ - name: ui-playwright
+ type: ui
+ description: "Playwright UI-тесты на test-среде"
+ reference: "04b-ui-test-cases.md"
+ cases:
+ - id: UI-LINK-01
+ name: "См. 04b-ui-test-cases.md — TC-UI-01..TC-UI-12"
+ expected: |
+ Каждый TC выполняется; check-visual подтверждается
+ оператором либо визуальным diff-инструментом
+ (baseline до ET-013 vs текущий).
+
+ - name: manual-deploy-validation
+ type: e2e
+ description: "Ручная проверка в test-среде после деплоя"
+ marker: "manual"
+ cases:
+ - id: E2E-PRE-DEPLOY-01
+ name: "Pre-deploy: тайлы z9-z11 на test-среде доступны"
+ steps:
+ - "curl -sI https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/9/308/158.png | head -1"
+ - "curl -sI https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/10/617/317.png | head -1"
+ - "curl -sI https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/11/1234/635.png | head -1"
+ - "Все три — HTTP/1.1 200 OK. При 404 — стоп, открыть PH-6 follow-up."
+ - "Зафиксировать в 14-deploy-log.md."
+
+ - id: E2E-DEPLOY-01
+ name: "Hillshade доступен на z=9"
+ steps:
+ - "Открыть https://openclaw.mva154.duckdns.org/enduro/"
+ - "localStorage.clear(); location.reload()"
+ - "Click #terrain-toggle"
+ - "В Console: window._map.setZoom(9); window._map.setCenter([37.6, 54.5])"
+ - "Wait 2s"
+ - "Кнопка #terrain-hillshade-cb имеет disabled=false"
+ - "Hint #terrain-hillshade-hint имеет display:none"
+ - "Click #terrain-hillshade-cb"
+ - "Wait 3s"
+ - "На карте видны тени"
+ - "Screenshot et013-deploy-z9.png"
+ - "Зафиксировать в 14-deploy-log.md"
+
+ - id: E2E-DEPLOY-02
+ name: "Network-объём: рост ≤ 35%"
+ steps:
+ - "Открыть DevTools Network, фильтр /terrain/"
+ - "Очистить network log"
+ - "В Console: window._map.setZoom(8); ждать 3s; setZoom(9); ждать 3s; setZoom(10); ждать 3s; setZoom(11); ждать 3s"
+ - "Замерить суммарный transferred size в фильтре /terrain/"
+ - "Сравнить с baseline (записан в 13-test-report.md до ET-013): рост ≤ 135%"
+ - "Зафиксировать"
+
+ - id: E2E-DEPLOY-03
+ name: "Регрессия z=8 (TRI выглядит как до ET-013)"
+ steps:
+ - "localStorage.clear(); location.reload()"
+ - "Включить только #terrain-tri-cb (без hillshade)"
+ - "window._map.setZoom(8); setCenter([37.6, 54.5])"
+ - "Screenshot et013-deploy-z8-tri-regress.png"
+ - "Визуально сравнить с baseline из 13-test-report.md до ET-013 — не отличается заметно."
+
+ - id: E2E-DEPLOY-04
+ name: "Регрессия z=14 (hillshade не перегрет)"
+ steps:
+ - "Включить #terrain-hillshade-cb"
+ - "window._map.setZoom(14); setCenter([37.6, 54.5])"
+ - "Screenshot et013-deploy-z14-regress.png"
+ - "Эффективное raster-opacity ≈ 0.40, raster-contrast ≈ 0"
+ - "В Console: window._map.getPaintProperty('terrain-hillshade', 'raster-opacity')"
+ - "(вернёт interpolate-выражение — proof zoom-aware)"
+
+ - id: E2E-DEPLOY-05
+ name: "Спутник + hillshade на z=10 (R-3)"
+ steps:
+ - "Включить hillshade, переключить #base-btn-satellite"
+ - "window._map.setZoom(10); setCenter([37.6, 54.5])"
+ - "Screenshot et013-deploy-z10-sat.png"
+ - "Визуальная приёмка: hillshade видим, не глушит снимок"
+ - "При проблеме — задача отправляется на корректировку stops"
+
+ - id: E2E-DEPLOY-06
+ name: "Тёмная тема + hillshade на z=10 (R-2)"
+ steps:
+ - "Click #btn-theme (переключить в тёмную)"
+ - "window._map.setZoom(10)"
+ - "Screenshot et013-deploy-z10-dark.png"
+ - "Визуальная приёмка: hillshade читается, не сливается с тёмной подложкой"
+
+ - id: E2E-DEPLOY-07
+ name: "Persistence: F5 не теряет состояние"
+ steps:
+ - "Включить оба чекбокса"
+ - "location.reload()"
+ - "Чекбоксы остаются включёнными"
+ - "На текущем zoom оба слоя восстановлены"
+
+test_data:
+ fixtures_dir: "tests/fixtures/terrain/"
+ fixtures:
+ - name: "hillshade-z9-sample.png"
+ description: |
+ Опционально: один валидный PNG-тайл из data/terrain/hillshade/9/
+ для CI-окружения без полного набора данных. Скопировать любой
+ тайл над ЦФО, переименовать. ~10 KB.
+ - name: "hillshade-z10-sample.png"
+ description: "То же для z10."
+ - name: "tri-z10-sample.png"
+ description: "TRI sample для z10."
+ notes:
+ - "Если на CI нет TERRAIN_DIR с данными — IT-TILE-* тесты skipped (REQ-F-15)."
+ - "Сравнения 'до/после' визуальные — baseline скриншоты лежат в 13-test-report.md и фиксируются до начала ET-013."
+ - "Для unit-тестов paint никаких fixture не нужно — парсинг исходника."
+
+test_environment:
+ unit:
+ - "Python 3.12, pytest"
+ - "regex-парсер src/web/app.js (Вариант B в TRZ REQ-F-13)"
+ - "Опционально Node + JSDOM, если в проекте появятся JS-тесты"
+ integration:
+ - "FastAPI TestClient против src.api.main:app"
+ - "TERRAIN_DIR через env или skip-if-missing"
+ performance:
+ - "Не требуется специально: NFR-01/02 говорят о невидимом изменении render-time"
+ - "Сетевой объём — ручной замер в DevTools Network (E2E-DEPLOY-02)"
+ e2e:
+ - "Test-среда https://openclaw.mva154.duckdns.org/enduro/"
+ - "Playwright (см. 04b-ui-test-cases.md)"
+
+ci_gates:
+ - "Unit UT-PAINT-* и UT-REG-* — обязательны (AC-15)"
+ - "Integration IT-TILE-* — обязательны (с skipif для отсутствующих данных) (AC-16)"
+ - "Регрессия RG-UNIT-ALL, RG-INTEG-ALL, RG-LINT — обязательны (AC-17, AC-18)"
+ - "Pre-deploy E2E-PRE-DEPLOY-01 — ручной gate перед merge (AC-19)"
+ - "UI-тесты Playwright — после деплоя, фиксация в 13-test-report.md"
+ - "E2E-DEPLOY-01..07 — ручные шаги в 14-deploy-log.md"
+---
diff --git a/docs/work-items/ET-013/04b-ui-test-cases.md b/docs/work-items/ET-013/04b-ui-test-cases.md
new file mode 100644
index 0000000..8a80899
--- /dev/null
+++ b/docs/work-items/ET-013/04b-ui-test-cases.md
@@ -0,0 +1,386 @@
+---
+type: ui-test-cases
+work_item_id: ET-013
+title: "UI Test Cases: Перепады высот на z9-z11"
+version: 1
+status: draft
+created_at: 2026-06-04
+updated_at: 2026-06-04
+authors:
+ - "agent:analyst"
+related:
+ - "PH-6.terrain"
+ - "ET-007"
+---
+
+# UI Test Cases — ET-013: Перепады высот на zoom z9-z11
+
+Базовый URL: `https://openclaw.mva154.duckdns.org/enduro/`
+
+ET-013 — frontend-калибровка: hillshade и TRI используют
+zoom-aware paint, UI-минзум hillshade понижен с 10 до 9. UI-тесты
+проверяют:
+
+1. На z9 чекбокс «Тени рельефа» активен, hint скрыт, hillshade виден.
+2. На z9-z11 перепады «бросаются в глаза» (качественно).
+3. На z8 регрессии нет (TRI выглядит как было).
+4. На z14 hillshade не «перегрет» (регрессия).
+5. Тёмная тема и спутник совместимы.
+6. Мобильный viewport работает.
+7. Persistence (localStorage) переживает F5.
+
+Селекторы (из текущего `index.html`):
+- `#terrain-toggle` — кнопка попапа слоёв рельефа (правая панель).
+- `#terrain-popup` — сам попап со списком чекбоксов.
+- `#terrain-hillshade-cb` — чекбокс «Тени рельефа».
+- `#terrain-hillshade-hint` — hint «Зум 9+» (ET-013) / «Зум 10+» (до ET-013).
+- `#terrain-tri-cb` — чекбокс «Перепады».
+- `#base-btn-satellite` — кнопка спутника.
+- `#btn-theme` — переключатель тёмная/светлая.
+- `#map` — карта.
+
+Все тесты выставляют zoom программно через `page.evaluate`:
+```js
+window._map.setZoom(N);
+window._map.setCenter([37.6, 54.5]); // юг МО / Ока, холмистый район
+```
+
+Координата `[37.6, 54.5]` (юг Москвы / Кашира / Ока) выбрана как
+«заведомо холмистая зона ЦФО» с явным TRI/hillshade.
+
+Скриншоты складываются в `docs/work-items/ET-013/screenshots/`
+и пришиваются к `13-test-report.md`. Для качественных AC-07/08/09
+оператор сравнивает с baseline скриншотами «до ET-013» (тоже в
+`screenshots/baseline/`).
+
+---
+
+### TC-UI-01-Z9 — На z=9 hillshade доступен и виден
+
+- тип: ui
+- viewport: desktop
+
+шаги:
+1. navigate: https://openclaw.mva154.duckdns.org/enduro/
+2. wait: 5000
+3. evaluate: localStorage.clear();
+4. wait: 500
+5. evaluate: location.reload();
+6. wait: 5000
+7. evaluate: window._map.setZoom(9); window._map.setCenter([37.6, 54.5]);
+8. wait: 3000
+9. click: "#terrain-toggle"
+10. wait: 800
+11. screenshot: "et013-01-z9-popup"
+12. check-visual: "В попапе #terrain-popup чекбокс «Тени рельефа» (#terrain-hillshade-cb) НЕ disabled, текст не серый. Hint #terrain-hillshade-hint имеет display:none (текст «Зум 9+» не виден). Чекбокс «Перепады» (#terrain-tri-cb) также доступен."
+13. click: "#terrain-hillshade-cb"
+14. click: "#terrain-tri-cb"
+15. wait: 4000
+16. screenshot: "et013-01-z9-tracks-visible"
+17. check-visual: "На карте при zoom=9 виден район юга Москвы / Оки. Поверх подложки нарисованы тени рельефа (hillshade) — тёмные склоны заметны на холмах вдоль реки. TRI («Перепады») рисует цветные пятна шероховатых зон. Оба слоя читаются, рельеф выразительный."
+
+---
+
+### TC-UI-02-Z8-REGRESS — Регрессия z=8: TRI выглядит как до ET-013
+
+- тип: ui
+- viewport: desktop
+
+шаги:
+1. navigate: https://openclaw.mva154.duckdns.org/enduro/
+2. wait: 5000
+3. evaluate: localStorage.clear();
+4. wait: 500
+5. evaluate: location.reload();
+6. wait: 5000
+7. click: "#terrain-toggle"
+8. wait: 800
+9. click: "#terrain-tri-cb"
+10. wait: 2000
+11. evaluate: window._map.setZoom(8); window._map.setCenter([37.6, 54.5]);
+12. wait: 4000
+13. screenshot: "et013-02-z8-tri-regress"
+14. check-visual: "На z=8 виден слой «Перепады» в опубликованном виде PH-6: opacity ~0.70, ресемпл «жёсткий» (граница 30-метровых клеток SRTM может быть видна, но это норма после ET-013). Слой hillshade выключен. Сравнение с baseline скриншотом 'before-ET-013-z8.png' — визуально близко, без явных регрессий."
+
+---
+
+### TC-UI-03-Z9-Q — Качественная читаемость перепадов на z=9
+
+- тип: ui
+- viewport: desktop
+- условие: оба слоя включены
+
+шаги:
+1. navigate: https://openclaw.mva154.duckdns.org/enduro/
+2. wait: 5000
+3. evaluate: localStorage.clear();
+4. wait: 500
+5. evaluate: location.reload();
+6. wait: 5000
+7. click: "#terrain-toggle"
+8. wait: 500
+9. click: "#terrain-hillshade-cb"
+10. click: "#terrain-tri-cb"
+11. wait: 2000
+12. evaluate: window._map.setZoom(9); window._map.setCenter([37.6, 54.5]);
+13. wait: 5000
+14. screenshot: "et013-03-z9-readable"
+15. check-visual: "На z=9 рельеф читается явно: тени по склонам холмов, цветные пятна TRI выделяют шероховатые зоны (склоны вдоль Оки, овраги). Не должно быть впечатления 'плоской карты'. Оператор сравнивает с baseline 'before-ET-013-z9.png' и подтверждает: 'перепады стали выразительнее' или 'минимум не хуже z8'. При отказе — фиксировать в 13-test-report.md и итеративно корректировать stops в HILLSHADE_PAINT/TRI_PAINT."
+
+---
+
+### TC-UI-04-Z10-Q — Качественная читаемость на z=10
+
+- тип: ui
+- viewport: desktop
+
+шаги:
+1. navigate: https://openclaw.mva154.duckdns.org/enduro/
+2. wait: 5000
+3. evaluate: localStorage.clear();
+4. wait: 500
+5. evaluate: location.reload();
+6. wait: 5000
+7. click: "#terrain-toggle"
+8. click: "#terrain-hillshade-cb"
+9. click: "#terrain-tri-cb"
+10. wait: 2000
+11. evaluate: window._map.setZoom(10); window._map.setCenter([37.6, 54.5]);
+12. wait: 5000
+13. screenshot: "et013-04-z10-readable"
+14. check-visual: "На z=10 в фокусе несколько холмов с явными склонами. Hillshade рисует тени с выраженным контрастом (raster-contrast 0.35 в paint-выражении). TRI выделяет шероховатости. Сравнение с baseline 'before-ET-013-z10.png' — стало явно выразительнее. Подложка под слоями ещё читается (opacity 0.60 + 0.85 не превращают карту в кашу)."
+
+---
+
+### TC-UI-05-Z11-Q — Качественная читаемость на z=11
+
+- тип: ui
+- viewport: desktop
+
+шаги:
+1. navigate: https://openclaw.mva154.duckdns.org/enduro/
+2. wait: 5000
+3. evaluate: localStorage.clear();
+4. wait: 500
+5. evaluate: location.reload();
+6. wait: 5000
+7. click: "#terrain-toggle"
+8. click: "#terrain-hillshade-cb"
+9. click: "#terrain-tri-cb"
+10. wait: 2000
+11. evaluate: window._map.setZoom(11); window._map.setCenter([37.6, 54.5]);
+12. wait: 5000
+13. screenshot: "et013-05-z11-readable"
+14. check-visual: "На z=11 виден небольшой район (несколько км в кадре). Перепады «прорисованы», отдельные склоны различимы. Сравнение с baseline 'before-ET-013-z11.png' — выразительнее. Дороги/грунтовки/POI остаются читаемыми поверх рельефа (z-order: terrain ниже trails/POI, проверено по applyTerrainLayer)."
+
+---
+
+### TC-UI-06-Z14-REGRESS — Регрессия z=14: hillshade не перегрет
+
+- тип: ui
+- viewport: desktop
+
+шаги:
+1. navigate: https://openclaw.mva154.duckdns.org/enduro/
+2. wait: 5000
+3. evaluate: localStorage.clear();
+4. wait: 500
+5. evaluate: location.reload();
+6. wait: 5000
+7. click: "#terrain-toggle"
+8. click: "#terrain-hillshade-cb"
+9. wait: 2000
+10. evaluate: window._map.setZoom(14); window._map.setCenter([37.6, 54.5]);
+11. wait: 5000
+12. screenshot: "et013-06-z14-regress"
+13. check-visual: "На z=14 hillshade выглядит так, как до ET-013: лёгкая «плёнка» теней с opacity ≈ 0.40 и raster-contrast ≈ 0. Никакого перегретого контраста. Подложка отчётливо видна. Сравнение с baseline 'before-ET-013-z14.png' — без отличий."
+
+---
+
+### TC-UI-07-Z9-MOBILE — Hillshade на мобильном viewport на z=9
+
+- тип: ui
+- viewport: mobile
+
+шаги:
+1. navigate: https://openclaw.mva154.duckdns.org/enduro/
+2. wait: 5000
+3. evaluate: localStorage.clear();
+4. wait: 500
+5. evaluate: location.reload();
+6. wait: 5000
+7. evaluate: window._map.setZoom(9); window._map.setCenter([37.6, 54.5]);
+8. wait: 3000
+9. click: "#terrain-toggle"
+10. wait: 800
+11. screenshot: "et013-07-z9-mobile-popup"
+12. check-visual: "На мобильном viewport (375×667) попап рельефа открыт, чекбокс «Тени рельефа» доступен, hint скрыт. Чекбокс «Перепады» доступен. Layout не сломан."
+13. click: "#terrain-hillshade-cb"
+14. click: "#terrain-tri-cb"
+15. wait: 4000
+16. screenshot: "et013-07-z9-mobile-tracks"
+17. check-visual: "На мобильном на z=9 видны тени рельефа и пятна TRI. Перепады читаются. Layout верхней/нижней панелей не перекрывает карту."
+
+---
+
+### TC-UI-08-Z10-SAT-Q — Спутник + hillshade на z=10
+
+- тип: ui
+- viewport: desktop
+
+шаги:
+1. navigate: https://openclaw.mva154.duckdns.org/enduro/
+2. wait: 5000
+3. evaluate: localStorage.clear();
+4. wait: 500
+5. evaluate: location.reload();
+6. wait: 5000
+7. click: "#terrain-toggle"
+8. click: "#base-btn-satellite"
+9. wait: 4000
+10. click: "#terrain-hillshade-cb"
+11. wait: 2000
+12. evaluate: window._map.setZoom(10); window._map.setCenter([37.6, 54.5]);
+13. wait: 5000
+14. screenshot: "et013-08-z10-sat"
+15. check-visual: "На спутниковой подложке поверх космоснимка видны тени hillshade. Подложка под ними различима — деревья, реки, поля по-прежнему читаются. Hillshade не превращает снимок в «серую плёнку». При отказе (слой глушит снимок) — открыть итерацию: либо снизить opacity на спутнике через отдельный layer-paint, либо документировать как known issue."
+
+---
+
+### TC-UI-09-Z10-DARK-Q — Тёмная тема + hillshade на z=10
+
+- тип: ui
+- viewport: desktop
+
+шаги:
+1. navigate: https://openclaw.mva154.duckdns.org/enduro/
+2. wait: 5000
+3. evaluate: localStorage.clear();
+4. wait: 500
+5. evaluate: localStorage.setItem('theme', 'dark'); location.reload();
+6. wait: 5000
+7. click: "#terrain-toggle"
+8. click: "#terrain-hillshade-cb"
+9. click: "#terrain-tri-cb"
+10. wait: 2000
+11. evaluate: window._map.setZoom(10); window._map.setCenter([37.6, 54.5]);
+12. wait: 5000
+13. screenshot: "et013-09-z10-dark"
+14. check-visual: "На тёмной теме при z=10 видны и hillshade, и TRI. Тени не сливаются с тёмной подложкой. Цвета TRI читаются. Если визуально слои «съедают карту» — фиксируется как известная проблема для будущей итерации (theme-specific paint, ADR-0001 в follow-up)."
+
+---
+
+### TC-UI-10-PERSIST — Состояние слоёв переживает F5
+
+- тип: ui
+- viewport: desktop
+
+шаги:
+1. navigate: https://openclaw.mva154.duckdns.org/enduro/
+2. wait: 5000
+3. evaluate: localStorage.clear();
+4. wait: 500
+5. evaluate: location.reload();
+6. wait: 5000
+7. click: "#terrain-toggle"
+8. click: "#terrain-hillshade-cb"
+9. click: "#terrain-tri-cb"
+10. wait: 1500
+11. evaluate: window._map.setZoom(10); window._map.setCenter([37.6, 54.5]);
+12. wait: 4000
+13. screenshot: "et013-10a-before-reload"
+14. check-visual: "Оба слоя видны на z=10."
+15. evaluate: location.reload();
+16. wait: 6000
+17. evaluate: window._map.setZoom(10); window._map.setCenter([37.6, 54.5]);
+18. wait: 4000
+19. screenshot: "et013-10b-after-reload"
+20. check-visual: "После reload оба слоя автоматически восстановились (через restoreTerrainState). Чекбоксы в #terrain-popup всё ещё checked. localStorage 'terrain-hillshade'='1', 'terrain-tri'='1'."
+
+---
+
+### TC-UI-11-NETWORK-Q — Сетевой объём (M-10)
+
+- тип: ui (network)
+- viewport: desktop
+- инструмент: DevTools Network
+
+шаги:
+1. navigate: https://openclaw.mva154.duckdns.org/enduro/
+2. wait: 5000
+3. evaluate: localStorage.clear();
+4. wait: 500
+5. evaluate: location.reload();
+6. wait: 5000
+7. open: DevTools Network, filter "/terrain/"
+8. clear network log
+9. click: "#terrain-toggle"
+10. click: "#terrain-hillshade-cb"
+11. click: "#terrain-tri-cb"
+12. evaluate: window._map.setZoom(8); window._map.setCenter([37.6, 54.5]);
+13. wait: 3500
+14. evaluate: window._map.setZoom(9);
+15. wait: 3500
+16. evaluate: window._map.setZoom(10);
+17. wait: 3500
+18. evaluate: window._map.setZoom(11);
+19. wait: 3500
+20. record: суммарный transferred size в Network
+21. check-visual: "Сравнение с baseline 'before-ET-013-network-z8-z11.txt' (записанным до начала ET-013): рост ≤ 135%. Если выше — анализ: какие тайлы добавились, оправдано ли. Фиксация в 13-test-report.md."
+
+---
+
+### TC-UI-12-Z9-PAN — Панорамирование на z=9 без лагов
+
+- тип: ui
+- viewport: desktop
+
+шаги:
+1. navigate: https://openclaw.mva154.duckdns.org/enduro/
+2. wait: 5000
+3. evaluate: localStorage.clear();
+4. wait: 500
+5. evaluate: location.reload();
+6. wait: 5000
+7. click: "#terrain-toggle"
+8. click: "#terrain-hillshade-cb"
+9. click: "#terrain-tri-cb"
+10. wait: 2000
+11. evaluate: window._map.setZoom(9); window._map.setCenter([37.6, 54.5]);
+12. wait: 5000
+13. evaluate: window._map.panBy([400, 0]);
+14. wait: 3000
+15. evaluate: window._map.panBy([0, 400]);
+16. wait: 3000
+17. evaluate: window._map.panBy([-400, 0]);
+18. wait: 3000
+19. screenshot: "et013-12-z9-pan"
+20. check-visual: "После трёх pan-шагов карта показывает соседние регионы. Тайлы догружены, нет 'белых дыр' в hillshade/TRI. Возврат к исходному центру — мгновенный (browser cache). UI не блокируется, нет визуальных лагов."
+
+---
+
+### Заметки по запуску
+
+- TC-UI-03..05 (Q-критерии) — качественные. Оператор сравнивает
+ скриншот с baseline («до ET-013»). Baseline записывается **до**
+ начала разработки ET-013 и кладётся в
+ `docs/work-items/ET-013/screenshots/baseline/`.
+- TC-UI-08 (SAT-Q) и TC-UI-09 (DARK-Q) — допустимо «known issue»
+ с фиксацией в `13-test-report.md`. Если визуальная регрессия
+ обнаружена — открывается follow-up задача по theme/sat-specific paint.
+- При отказе TC-UI-03/04/05 — корректировка stops в
+ `HILLSHADE_PAINT`/`TRI_PAINT`, новый прогон. Это калибровка, а не баг.
+- При отказе TC-UI-06 (z14 регрессия) — баг калибровки stops,
+ должен быть исправлен.
+- TC-UI-11 (NETWORK-Q) — pre/post замеры; baseline записывается
+ до старта работ над ET-013.
+
+### Координаты для тестов
+
+| Координаты | Регион | Зачем |
+|---|---|---|
+| `[37.6, 54.5]` | юг МО / Кашира / Ока | холмистый, выраженный hillshade и TRI |
+| `[37.6, 55.7]` | центр Москвы | плоский, контроль «город всё равно читается» (опционально) |
+| `[38.6, 54.0]` | Тула | холмы юга ЦФО, альтернатива для AC-08 |
+
+По умолчанию все TC используют `[37.6, 54.5]`.