docs: specify the camera transition fixes (#396)

User-Visible: no
Issue: #396
This commit is contained in:
Codex
2026-08-31 01:36:53 +03:00
parent cc6bbdf24c
commit 54f9efb798
+206
View File
@@ -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 в новой редакции.
- Скриншоты не меняются: визуал в статике идентичен.