Files
houseplan-card/docs/reviews/SPEC-REVIEW-396-r1.md
T
2026-08-31 01:36:53 +03:00

253 lines
24 KiB
Markdown
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.
# SPEC-REVIEW-396-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/396
- **ТЗ под ревью:** `docs/specs/396-camera-transition-fixes.md`, коммит `4e0a30a78161a4ff59679454d1f8b5c471e10008` (HEAD ветки `issue/396-camera-transition-fixes`)
- **Этап:** spec (PROCESS.md §2.4)
- **Трек:** обычный (без метки `small`/`trivial`)
- **Заход:** r1 · блокирующих циклов ревью ТЗ израсходовано 0 из 4 (до этого вердикта)
## Скоуп ревью
Issue #396 переносит три находки внешнего аудита (`AUDIT-2026-08-31-v1700beta1.md`,
в репозитории не лежит — внешний документ, ссылка не проверяется) в одно ТЗ,
поскольку все три касаются одной подсистемы (камера-переход из #82):
- **B1 (High)** — прерванный пользователем зум не сохраняется: регресс против
v1.69.0;
- **B2 (High)** — якорь под курсором вычисляется от отстающего кадра при
быстрой серии wheel-событий;
- **M2 (Medium)** — glow-feather не заморожен во время нового camera tween.
Ревьюер не видел устных пояснений автора; материал — только issue #396, ТЗ
`docs/specs/396-camera-transition-fixes.md` и код на `origin/dev`/ветке задачи.
Продуктового кода к этому изменению ещё нет (ветка содержит один doc-коммит),
поэтому единственный вопрос ревью — проверить каждое фактическое утверждение
ТЗ о поведении `src/viewport-transition.ts` и `src/houseplan-card.ts` построчно
против реального кода, ровно так же, как это требует PROCESS.md §2.4 и
прецедент SPEC-REVIEW-197-r1.
## Как проверялось
1. Прочитаны целиком `docs/SCOPE.md`, `PROCESS.md` (действующая редакция,
§1, §2.4, §2.5, §2.10, §7.1, §7.2), `AGENTS.md`.
2. Прочитано тело issue #396 (единственный комментарий — сам текст issue,
других комментариев нет).
3. Прочитан `docs/specs/082-smooth-zoom.md` целиком — канонический документ
подсистемы, поскольку #396 правит его §10 и §13 (AC6).
4. Прочитан весь текст ТЗ `docs/specs/396-camera-transition-fixes.md` (206
строк), сверены обязательные разделы §7.1 PROCESS.md — присутствуют все:
сценарий, что человек увидит до/после, проблема и контракты по пунктам,
скоуп/не-скоуп, UX, модель данных и миграция, i18n, критерии приёмки
AC1–AC7 (у каждого назван способ доказательства), план автотестов, риски,
откат, release-артефакты, блок принятых технических предположений.
5. Построчно сверены все технические утверждения ТЗ с фактическим кодом:
- `src/viewport-transition.ts` (212 строк) прочитан целиком —
`CameraTransitionController.cancel(commitTarget)` (`:189-200`),
`dispose()` (`:202`), `_settle()` (`:204-211`);
- все вызовы `_cancelCameraTransition(...)` и `_cameraTransition.cancel/dispose`
в `src/houseplan-card.ts` пересчитаны поимённо:
`grep -n "_cancelCameraTransition(false)\|_cancelCameraTransition(true)\|_cameraTransition\.\(cancel\|dispose\)" src/houseplan-card.ts`;
- каждый найденный вызов прочитан в контексте функции, которая его содержит
(`_onMotionChange`, `_startCameraTransition`, `_cancelModeTransition`,
`_adoptMode`, `_commitSpace`, `_adoptStructuralResponses` ×2, `_applyView`,
`_refitView`, `_zoomAt`, `_restoreZoom`, `_stagePointerDown`,
`disconnectedCallback`);
- оба вызова `_zoomAt(...)` (`:6517`, `:6897`, внутри pinch-путей
`_stagePointerMove`/`_touchGesture...`) прочитаны с окружением, чтобы
проверить, действительно ли за ними сразу следует `_saveZoom()`;
- `_cameraTargetAt` (`:6262-6279`) и `cameraTargetAtAnchor` (`viewport-transition.ts:94-116`)
прочитаны вместе, чтобы проверить claim B2 (мировая точка считается от
`this._cameraState()`, то есть от представленного кадра, независимо от
того, какой `targetZoom` передал вызывающий код);
- `resolveGlowFeather(...)` вызов на `:10845-10847` сверен с цитатой ТЗ
(`:10842-10847`) — совпадает дословно.
6. Гейты (`typecheck`/`test`/`build`) не прогонялись — на этапе ревью ТЗ
продуктового кода не существует (только doc-коммит), что вне скоупа этапа
(PROCESS.md §2.4/§8).
## Находки
### High-1 — центральное фактическое утверждение B1 («семь мест») не совпадает с реальными вызовами и не покрывает главный сценарий issue
**Файл:** `docs/specs/396-camera-transition-fixes.md`, раздел «(1) B1 — прерванный
зум не сохраняется» и раздел «Скоуп / не-скоуп».
ТЗ утверждает: «`_cancelCameraTransition(false)` зовут семь мест: `:1267`,
`:1401`, `:1551`, `:4188`, `:4216`, `:6089`, `:6206` — pointerdown по сцене,
смена пространства, `_refitView`, adoption, reload, `_zoomAt`, disconnect.»
(позиционное соответствие семи чисел семи описаниям в том же порядке).
Фактическая проверка (`grep -n "_cancelCameraTransition(false)\|_cancelCameraTransition(true)\|_cameraTransition\.\(cancel\|dispose\)" src/houseplan-card.ts`
на HEAD ветки задачи) даёт **десять** мест с `commitTarget=false` плюс одно
место, где котроллер отменяется напрямую, минуя обёртку:
| Строка | Функция | Что это на самом деле |
|---|---|---|
| `:1267` | `_startCameraTransition`, no-op ветка (`sameCameraState(current, target)`) | не «pointerdown», а ранний выход при запросе уже показанного состояния |
| `:1401` | `_cancelModeTransition` | общий helper для смены mode/space, не «смена пространства» отдельно |
| `:1551` | `_commitSpace` | это и есть настоящая «смена пространства» — но в списке ей приписана строка `:1401` |
| `:4188` | `_adoptStructuralResponses` (config adopt) | «adoption» — совпадает |
| `:4216` | `_adoptStructuralResponses` (layout adopt) | второе «adoption», в списке названо «reload» |
| `:6089` | `_applyView` | не «`_zoomAt`» — другая функция |
| `:6206` | `_refitView` | resize, не «disconnect» |
| `:6283` | `_zoomAt` (pinch, immediate) | **отсутствует в списке семи** |
| `:6360` | `_restoreZoom` | **отсутствует в списке семи**, не упомянута нигде в ТЗ |
| `:6387` | `_stagePointerDown` | **отсутствует в списке семи** — это и есть буквальный «pointerdown по сцене» из пользовательского сценария issue |
| `:2745` | `disconnectedCallback` | вызывает `this._cameraTransition.dispose()` напрямую, минуя `_cancelCameraTransition` — не через строку `:6206`, как подразумевает список |
Т.е. ни одна из строк реального `_stagePointerDown` (`:6387`) и реального
`_zoomAt` (`:6283`) не входит в перечисленные семь чисел, хотя проза ТЗ прямо
называет «pointerdown по сцене» и «`_zoomAt`» в качестве двух из этих семи.
Disconnect вообще не проходит через `_cancelCameraTransition` — там отдельный
вызов `.dispose()` на `:2745`.
**Проверено исполнением/чтением, что `:6387` — это настоящий воспроизводящий
сценарий из issue.** `_stagePointerDown` (`houseplan-card.ts:6386-6446`) вызывает
`this._cancelCameraTransition(false)` первой строкой и не содержит ни одного
вызова `_saveZoom()` внутри себя или в путях, которые гарантированно
выполняются после простого клика (не переросшего в pan/pinch). Ровно это —
«колесо/кнопка «+» → сразу клик по плану» из раздела «Пользовательский
сценарий» B1. Сопутствующая проверка: `_zoomAt` (`:6283`) технически тоже
вызывает `_cancelCameraTransition(false)`, но оба его реальных вызывающих
контекста (`:6517` — pinch move, `:6897` — touch multitouch move) сразу же
следом вызывают `_saveZoom()` — то есть через `_zoomAt` состояние фактически
не теряется, вопреки тому, что список подписывает его как один из семи
«теряющих» вызовов.
**Почему это блокирует, а не опечатка в номерах строк.** Раздел «Скоуп /
не-скоуп» ТЗ перечисляет затронутые функции: «`_startCameraTransition`,
`_cameraTargetAt`, `_onWheel`, `_stepZoom`, `_cancelCameraTransition`,
glow-гейт» — и **не называет `_stagePointerDown` ни разу**. DoR (PROCESS.md
§2.5) требует «перечислены затронутые файлы и модули» именно для того, чтобы
разработчик не должен был заново открывать код в поисках места правки. Если
разработчик читает этот список как объявление границы работы (а не как
приглашение заново обыскать весь файл), правка `_stagePointerDown` не
попадёт в реализацию — а именно там лежит буквальный баг из пользовательского
сценария issue. Раздел «Риски» ТЗ («Тонкая грань «пользовательская против
структурной»») сам ссылается на «список мест перечислен в спеке #82 §13» как
на средство защиты от ошибки классификации — но источник (список из этого же
ТЗ) неточен, то есть заявленная мера риска опирается на тот же дефектный
список.
Смягчающее обстоятельство: план автотестов (шаг 5 browser smoke, «Колесо →
pointerdown по плану до окончания перехода → перезагрузка карты →
восстановленный зум равен показанному до клика (AC1 на реальном DOM)») сам по
себе устроен так, что реализация, не тронувшая `_stagePointerDown`, эту
проверку не пройдёт — то есть дефект будет пойман на код-ревью. Но это
означает лишний цикл там, где спек-ревью для того и существует, чтобы поймать
это раньше и дешевле (тот же аргумент, что в SPEC-REVIEW-197-r1 High-1:
разработчик рискует чинить не тот код, опираясь на неверную фактическую базу
раздела).
**Что нужно поправить.** В разделе «(1) B1» — заменить список из семи чисел
на фактическую сверку (реальных мест десять плюс `disconnectedCallback`,
использующий `.dispose()` напрямую), явно отметить, какие из них
пользовательские (как минимум `:6387`), а какие структурные, и без
двусмысленности показать, что `_zoomAt` (`:6283`/`:6517`/`:6897`) уже не
теряет состояние благодаря существующему `_saveZoom()` сразу после — то есть
не требует правки по контракту B1. В разделе «Скоуп / не-скоуп» — добавить
`_stagePointerDown` в список затронутых функций (и, при необходимости,
`_restoreZoom`, если ревью реализации решит, что он тоже нуждается в явной
классификации).
## Что проверено и корректно
- **B1 — контракт и AC1/AC2 сформулированы верно**, несмотря на дефектный
список строк: `CameraTransitionController.cancel(false)`
(`viewport-transition.ts:189-200`) действительно не вызывает `_hooks.settled`
и, следовательно, никогда не проходит через `_settleCameraTransition` →
`_saveZoom()` (`houseplan-card.ts:1232-1244`) — регресс реален и подтверждён
чтением обоих файлов независимо от того, какие конкретно строки его
проявляют. Различение «пользовательская отмена / структурная отмена»
(таблица в разделе «(1)») продуктово согласовано с уже принятым §11
`docs/specs/082-smooth-zoom.md` («Pointerdown, который начинает pan, pinch,
draw, drag или selection, сначала фиксирует представленный кадр и отменяет
tween без скачка» — то есть #82 уже требовал этого поведения, #396 первым
делает его явным контрактом с AC).
- **B2 подтверждён чтением и математически обоснован.** `_cameraTargetAt`
(`houseplan-card.ts:6262-6279`) всегда строит якорь от
`this._cameraState()` (`:6273`) — то есть от представленного, а не целевого
кадра — независимо от того, что `_onWheel` (`:6294-6308`) уже правильно
берёт `baseZoom` из `this._cameraTransition.target?.zoom ?? this._zoom`
(`:6301`). Это ровно расхождение «масштаб — из target, точка — из
presented», которое ТЗ описывает как корень утечки якоря. Контрактная
формулировка («масштаб и мировая точка берутся из одного и того же
состояния») корректно закрывает разрыв, а замечание про `_zoomAt`
(«там tween заведомо отменён, представленное и есть текущее») подтверждено:
`_zoomAt` (`:6282-6292`) вызывает `_cancelCameraTransition(false)` первой
строкой, поэтому к моменту вычисления якоря активного перехода уже нет и
presented/target совпадают по определению.
- **M2 подтверждён дословным совпадением цитаты.** `resolveGlowFeather(...)`
вызов на `houseplan-card.ts:10845-10847` с третьим аргументом
`!this._pinchStart && !this._panStart` совпадает с цитатой ТЗ буквально —
признак «камера движется» не учитывает новый camera tween, только pinch/pan
gesture state.
- **Правка `docs/specs/082-smooth-zoom.md` §10/§13 обоснована.** Прочитан
документ целиком (386 строк): §10 п.3 действительно говорит «в
представленном viewport», а порог ниже — «не более 0.5 CSS px», то есть
спека #82 сама себе противоречит после реализации B2-бага (п.3 выполнен,
порог нарушен) — формулировка AC6 «привести в соответствие» корректна и не
требует продуктового решения владельца (чисто согласование текста с уже
принятым поведенческим контрактом п.2 §10).
- **Структура ТЗ и обязательные разделы (PROCESS.md §7.1)** — все на месте,
включая i18n («Новых строк нет» — верно, ни один из трёх контрактов не
добавляет UI-текст), модель данных/миграция («формат `LS_ZOOM` не меняется»
— верно, персист остаётся тем же ключом/структурой), touch (пункт «Немедленный
`_zoomAt` (pinch) продолжает работать от представленного состояния» —
корректно определяет границу с touch-контрактом), откат (точечный, три
независимых правки, без миграции данных — согласуется с характером задачи).
- **Трек.** Обычный трек (не `small`) обоснован по факту: три независимых
находки на одной подсистеме, правка канонического документа #82 — критерий
§5 PROCESS.md («нет нового UX-контракта» технически спорен, но задача явно
не «одна поверхность» и трогает канон #82, что само по себе исключение из
лёгкого трека).
- **Открытых продуктовых вопросов к владельцу не осталось и не должно
остаться** — единственный найденный пробел (High-1) технический (какие
именно строки/функции меняются), а не продуктовый: ни «что видит
пользователь», ни «объём видимых изменений в issue» здесь не решаются.
Технический спор автора и ревьюера решается вердиктом, не владельцем
(PROCESS.md §7.1).
- **Мутанты названы конкретно** (`camera-anchor-from-presented`,
`camera-cancel-loses-zoom`, `glow-feather-thaws-during-camera`) и каждый
указывает, какой юнит должен покраснеть — соответствует правилу «тест умеет
падать» (PROCESS.md §2.7, применимо и к дизайну теста на этапе ТЗ).
## Чего не проверял
- Код продукта не существует на этой стадии (только ТЗ) — гейты
`typecheck`/`test`/`build`/`check-docs`/`invariants`/смоки/`golden:verify`
неприменимы и не запускались; это нормально для этапа ТЗ, а не пропуск.
- Не проверял содержимое внешнего аудита `AUDIT-2026-08-31-v1700beta1.md` —
файл не лежит в репозитории (только ссылка на него в теле issue), поэтому
его точные измерения (14.5/16.1/10.3 px и т.п.) не перепроверялись
инструментально; для ревью достаточно того, что оба бага (B1, B2)
независимо подтверждены чтением реального кода на `origin/dev`.
- Не прогонял `test-build/viewport-transition.js` с фейковым клоком, на
который ссылается ТЗ («переход 1.0 → 1.15 за 220 мс, обрыв на 120 мс →
`settled` вызван 0 раз, `presented.zoom = 1.1413`») — подтвердил тот же
вывод логическим чтением `CameraTransitionController.cancel()`
(`viewport-transition.ts:189-200`: `commitTarget=false` выходит до вызова
`this._hooks.settled`), числовое значение `1.1413` не пересчитывал.
- Не искал дополнительные вызовы `_cancelCameraTransition`/`_cameraTransition.cancel`
за пределами `src/houseplan-card.ts` (например, в `src/houseplan-editor-runtime.ts`)
— раздел «Скоуп» ТЗ ограничивает камеру ядром `houseplan-card.ts`, и
`grep -n "_cameraTransition\b" src/houseplan-editor-runtime.ts` не выполнялся;
при исправлении High-1 автору стоит перепроверить и этот файл на всякий
случай, хотя #82 §19 явно оставляет бизнес-логику zoom в core.
## Вердикт
Красный. High: 1 (B1 — список мест «`_cancelCameraTransition(false)`»
фактически неверен и не включает `_stagePointerDown`/`_zoomAt`, а раздел
«Скоуп» не называет `_stagePointerDown` как затрагиваемую функцию — риск, что
буквальный пользовательский сценарий issue останется незачинённым). Medium: 0.
Контракты B1/B2/M2 и AC1–AC7 по существу верны и не требуют продуктового
решения владельца; правка текстовая — исправить раздел «(1) B1» и «Скоуп /
не-скоуп» по фактической сверке выше, без изменения AC. После правки —
повторный заход по дельте (PROCESS.md §2.10): проверить перечень мест и то,
что `_stagePointerDown` явно назван местом правки, остальные разделы
(B2/M2, AC3-AC7, риски §10/§13) наследуются без повторной проверки.
**Вердикт: красный · заход r1 · блокирующих циклов 1/4 · High: 1 · Medium: 0
→ в задаче · Документ: docs/reviews/SPEC-REVIEW-396-r1.md**