24 KiB
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.
Как проверялось
- Прочитаны целиком
docs/SCOPE.md,PROCESS.md(действующая редакция, §1, §2.4, §2.5, §2.10, §7.1, §7.2),AGENTS.md. - Прочитано тело issue #396 (единственный комментарий — сам текст issue, других комментариев нет).
- Прочитан
docs/specs/082-smooth-zoom.mdцеликом — канонический документ подсистемы, поскольку #396 правит его §10 и §13 (AC6). - Прочитан весь текст ТЗ
docs/specs/396-camera-transition-fixes.md(206 строк), сверены обязательные разделы §7.1 PROCESS.md — присутствуют все: сценарий, что человек увидит до/после, проблема и контракты по пунктам, скоуп/не-скоуп, UX, модель данных и миграция, i18n, критерии приёмки AC1–AC7 (у каждого назван способ доказательства), план автотестов, риски, откат, release-артефакты, блок принятых технических предположений. - Построчно сверены все технические утверждения ТЗ с фактическим кодом:
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) — совпадает дословно.
- Гейты (
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)») продуктово согласовано с уже принятым §11docs/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