diff --git a/docs/specs/396-camera-transition-fixes.md b/docs/specs/396-camera-transition-fixes.md new file mode 100755 index 00000000..efe74405 --- /dev/null +++ b/docs/specs/396-camera-transition-fixes.md @@ -0,0 +1,206 @@ +# ТЗ #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) +- Ревизия: 1 (2026-08-31) + +## Сценарий + +Хозяин дома смотрит план на планшете. Крутит колесо (или жмёт «+»), чтобы +разглядеть кухню, и сразу тыкает в лампу — не дожидаясь, пока картинка доедет. +Через минуту уходит на второй этаж и возвращается: масштаб откатился к тому, +что был до зума. Второй случай — трекпад: быстрая серия нотчей уводит из-под +пальца ровно ту точку, которую он держал. + +## Что человек увидит до и после + +**До**: (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` не вызывает, поэтому семь мест отмены — `:1267`, `:1401`, `:1551`, +`:4188`, `:4216`, `:6089`, `:6206` — оставляют показанный масштаб незаписанным. + +Воспроизведение (исполнением, фейковый клок): переход 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`, 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**. После пользовательской отмены перехода (pointerdown по сцене, + начало pan/pinch/draw/drag/selection) сохранённый зум пространства равен + показанному на момент отмены. Доказательство: юнит на контроллер + карту, + сравнение сохранённого значения с `presented`. +- **AC2**. Структурная отмена (смена пространства/режима, adoption, restore, + disconnect) не записывает зум вовсе. Доказательство: тот же юнит, обратный + случай — счётчик записей не растёт. +- **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 в новой редакции. +- Скриншоты не меняются: визуал в статике идентичен.