Files
houseplan-card/docs/specs/396-camera-transition-fixes.md
T
2026-08-31 01:36:53 +03:00

230 lines
17 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЗ #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 в новой редакции.
- Скриншоты не меняются: визуал в статике идентичен.