# ТЗ #396 — Плавная камера: сохранение прерванного зума, честный якорь, заморозка feather - Issue: https://github.com/Matysh/houseplan-card/issues/396 - Приоритет: P1, bug (регресс против v1.69.0); полный трек — класс A, три находки на одной поверхности (камера), плюс правка формулировок принятой спеки #82 (`docs/specs/082-smooth-zoom.md` §10, §13) - Ревизия: 3 (2026-08-31) — по SPEC-REVIEW-396-r2 (Medium-1: имена функций в таблице) ## Сценарий Хозяин дома смотрит план на планшете. Крутит колесо (или жмёт «+»), чтобы разглядеть кухню, и сразу тыкает в лампу — не дожидаясь, пока картинка доедет. Через минуту уходит на второй этаж и возвращается: масштаб откатился к тому, что был до зума. Второй случай — трекпад: быстрая серия нотчей уводит из-под пальца ровно ту точку, которую он держал. ## Что человек увидит до и после **До**: (1) зум, прерванный любым касанием плана, не запоминается — при возврате на пространство карта показывает старый масштаб; (2) при быстром зуме трекпадом точка под курсором уезжает на 10–16 px; (3) во время плавного зума свечение пересобирает размытие на каждом кадре. **После**: показанный масштаб и есть сохранённый; точка под курсором стоит на месте при любой частоте событий; свечение во время движения камеры ведёт себя как при pinch/pan. ## Проблема и контракты по пунктам ### (1) B1 — прерванный зум не сохраняется `_saveZoom()` на пути перехода вызывается ровно из `_settleCameraTransition` (`src/houseplan-card.ts:1237`). До перехода на анимацию запись делали сами команды: `_onWheel`, `_stepZoom`, `_resetZoom` (`v1.69.0:6009,6016,6030`). `CameraTransitionController.cancel(false)` (`src/viewport-transition.ts:189`) `settled` не вызывает, поэтому показанный масштаб остаётся незаписанным. Полный перечень мест, где переход отменяется, и их классификация (сверено `grep`'ом по `src/houseplan-card.ts` на HEAD, каждое прочитано): | Место | Что это | Класс | |---|---|---| | `:6387` `_stagePointerDown` | **буквальный сценарий issue**: касание плана поверх собственного зума | **пользовательская — сохранять** | | `:6283` `_zoomAt` | немедленный pinch | правки не требует: оба вызывающих контекста сразу зовут `_saveZoom()` | | `:1267` `_startCameraTransition` | цель совпала с текущим состоянием (no-op) | сохранять нечего — зум уже равен сохранённому | | `:1159` `_onMotionChange`, `:2363` `_pageVisibility` | `cancel(true)`, цель коммитится | уже сохраняется через `settled` | | `:1401` `_cancelModeTransition` | смена режима | структурная | | `:1551` `_commitSpace` | смена пространства | структурная | | `:4188`, `:4216` `_adoptStructuralResponses` | adoption конфига и layout | структурная | | `:6089` `_applyView` | программная установка вида | структурная | | `:6206` resize-обработчик | новая геометрия сцены | структурная | | `:6360` `_restoreZoom` | восстановление сохранённого зума | структурная (пишет то, что и читает) | | `:2745` `_cameraTransition.dispose()` | `disconnectedCallback`, минуя обёртку | структурная | Правка нужна ровно в одном месте пользовательского класса (`:6387`) плюс сам механизм различения; остальные строки перечислены, чтобы граница была явной и следующая правка не расширила её молча. Воспроизведение (исполнением, фейковый клок): переход 1.0 → 1.15 за 220 мс, обрыв на 120 мс → `settled` вызван 0 раз, `presented.zoom = 1.1413`. **Корень — в спеке #82, а не только в коде.** §13 говорит «structural cancellation не сохраняет stale camera target», §11 — «pointerdown … фиксирует представленный кадр». Спека не различает два случая отмены, а они разные: | Вид отмены | Что на экране | Что сохранять | |---|---|---| | **Пользовательская**: pointerdown по сцене, начало pan/pinch/draw/drag/selection поверх собственного зума | представленный кадр фиксируется и остаётся видимым | **сохранять представленное** — оно и есть текущее намерение | | **Структурная**: смена пространства/режима/проекции, resize, adoption конфига или layout, continuity recovery, viewport restore, disconnect | вид заменяется целиком другим контрактом | не сохранять ничего: цель устарела вместе с видом | Контракт: **сохранённый зум пространства равен показанному после любой пользовательской отмены**. Структурная отмена по-прежнему не пишет ничего. Требуется правка `docs/specs/082-smooth-zoom.md` §13: разделить два вида отмены явно, вместо одной строки про «stale target». ### (2) B2 — якорь считается от отстающего кадра `_cameraTargetAt` (`src/houseplan-card.ts:6272`) передаёт в `cameraTargetAtAnchor` состояние `this._cameraState()` — представленный кадр. Пока tween не доиграл, он отстаёт от цели, и мировая точка под курсором вычисляется в другом viewport, чем тот, к которому строится новая цель. Замер (6 нотчей шагом 1.15, сравнение «якорь от presented» против «якорь от целевого состояния»): | интервал | смещение точки под курсором | |---|---| | 8 мс | 14,5 px | | 16 мс | 16,1 px | | 33 мс | 10,3 px | | 220 мс | 0,0 px | Спека #82 §10 при этом требует обе несовместимые вещи сразу: п.3 — «world-point … в представленном viewport», абзац ниже — «anchor остаётся на месте с ошибкой не более 0.5 CSS px». Реализация выполнила п.3 и нарушила AC. Контракт: **масштаб и мировая точка берутся из одного и того же состояния — цели текущего перехода** (при её отсутствии — из представленного, оно же текущее). Это ровно принцип §10 п.2, распространённый на якорь. Ошибка якоря без clamp — **0 px** (точное равенство мировой точки до плавающей погрешности 1e-9), а не «не более 0.5». Требуется правка `docs/specs/082-smooth-zoom.md` §10: п.3 переформулировать на «в целевом viewport running tween», порог заменить на точное равенство. Немедленный `_zoomAt` (pinch) продолжает работать от представленного состояния: там tween заведомо отменён, представленное и есть текущее. ### (3) M2 — feather не заморожен во время перехода `src/houseplan-card.ts:10842-10847`: `resolveGlowFeather(…, !this._pinchStart && !this._panStart)`. Признак «камера движется» подменён признаками жеста, а новый tween ни одного из них не выставляет: `perUnit` меняется каждый кадр, регион размытия пересобирается на каждом. Контракт: feather заморожен, **пока камера движется любым способом** — pinch, pan или анимированный переход. ## Скоуп / не-скоуп **В скоупе**: `src/viewport-transition.ts`, камера-путь `src/houseplan-card.ts` (`_startCameraTransition`, `_cameraTargetAt`, `_onWheel`, `_stepZoom`, `_cancelCameraTransition`, **`_stagePointerDown`**, glow-гейт), правка §10 и §13 в `docs/specs/082-smooth-zoom.md`, тесты и мутанты. **Не в скоупе**: поведение pinch/pan persistence (свой контракт, §13 #82), длительности и кривые анимации, kiosk double-tap, изменение формата `LS_ZOOM`, editor zoom (не персистится по решению #82). ## UX Видимых изменений в оформлении нет. Меняется только то, что показанный масштаб сохраняется, а точка под курсором не уезжает. ## Модель данных и миграция Формат `LS_ZOOM` и warm viewport memo не меняются. Миграции нет. ## i18n Новых строк нет. ## Критерии приёмки - **AC1**. После пользовательской отмены перехода (`_stagePointerDown` — касание плана, с которого начинается pan/pinch/draw/drag/selection) сохранённый зум пространства равен показанному на момент отмены. Доказательство: юнит на контроллер + карту, сравнение сохранённого значения с `presented`. - **AC2**. Структурная отмена (смена пространства/режима, `_applyView`, resize, adoption, `_restoreZoom`, disconnect — строки таблицы выше) не записывает зум вовсе. Доказательство: тот же юнит, обратный случай — счётчик записей не растёт. Отдельно: `_zoomAt` (pinch) остаётся без изменений и продолжает сохранять через вызывающий контекст. - **AC3**. Серия из шести wheel-событий с интервалом 8 мс оставляет мировую точку под курсором на месте с точностью 1e-9 единиц плана (без clamp). Доказательство: юнит с фейковым клоком; при clamp смещение допускается только по ограниченной оси. - **AC4**. Накопление масштаба в серии не меняется: шесть нотчей дают тот же итоговый зум, что и сегодня (1.15⁶ с точностью 1e-12) — фикс якоря не трогает арифметику масштаба. - **AC5**. Во время анимированного перехода `resolveGlowFeather` вызывается с замороженным признаком: число пересборок региона размытия за один зум не больше, чем на pinch-пути. - **AC6**. `docs/specs/082-smooth-zoom.md` §10 и §13 приведены в соответствие с реализуемым поведением; ни один другой параграф не меняется. - **AC7**. Регресс не возвращается: reduced-motion по-прежнему даёт ноль запросов rAF и точный таргет, `dispose()` снимает rAF, анимационное состояние не попадает в конфиг. ## План автотестов **Unit** (`test/viewport-transition.test.mjs`, `test/camera-persistence.test.mjs`): 1. `cancel(false)` после частичного перехода → хук сохранения вызван с представленным состоянием (AC1); структурная отмена → не вызван (AC2). 2. Серия из шести нотчей с интервалом 8/16/33 мс → мировая точка под курсором неизменна до 1e-9 (AC3), итоговый зум равен 1.15⁶ (AC4). 3. Clamp у границы: смещение только по ограниченной оси, вторая ось точна. 4. `reducedMotion` → 0 rAF, один кадр, точный таргет (AC7). **Browser smoke** (`demo/smoke_smooth_zoom.mjs`, дополнение): 5. Колесо → pointerdown по плану до окончания перехода → перезагрузка карты → восстановленный зум равен показанному до клика (AC1 на реальном DOM). 6. Смена пространства во время перехода → в `localStorage` для исходного пространства осталось прежнее значение (AC2). **Perf-контракт** (`demo/performance/*`): 7. Число вызовов пересборки feather за один анимированный зум не превышает значение pinch-пути (AC5). **Мутанты** (`scripts/mutation-gate.mjs`): - `camera-anchor-from-presented`: вернуть якорь на представленный кадр → юнит AC3 красный. - `camera-cancel-loses-zoom`: убрать сохранение при пользовательской отмене → юнит AC1 красный. - `glow-feather-thaws-during-camera`: снять признак движения камеры из гейта feather → перф-контракт AC5 красный. ## Риски - **Двойная запись зума.** Сохранение при отмене плюс сохранение в `settle` может дать две записи на один жест. Смягчение: запись идемпотентна по значению (тот же зум не переписывает storage — §11 #82 «no-op не переписывает localStorage»), контракт проверяется счётчиком записей в юните. - **Тонкая грань «пользовательская против структурной».** Ошибка классификации перевернёт поведение. Смягчение: классификация задаётся одним аргументом на вызове отмены (а не выводится из состояния), оба случая покрыты юнитами, список мест перечислен в спеке #82 §13. - **Изменение якоря затрагивает pinch.** Смягчение: `_zoomAt` явно оставлен на представленном состоянии, отдельный юнит фиксирует, что pinch-путь не изменился численно. ## Откат Точечный: три изменения независимы. Откат якоря — вернуть `_cameraState()` в `_cameraTargetAt`; откат персиста — убрать сохранение из ветки отмены; откат feather — вернуть гейт на признаки жеста. Формат хранения не меняется, поэтому откат не оставляет следов в данных пользователя. ## Release-артефакты - `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: один пункт про то, что прерванный зум запоминается и точка под курсором не уезжает (User-Visible). - `docs/specs/082-smooth-zoom.md`: §10 и §13 в новой редакции. - Скриншоты не меняются: визуал в статике идентичен.