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

24 KiB
Raw Blame History

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