mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
@@ -0,0 +1,252 @@
|
||||
# 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**
|
||||
Reference in New Issue
Block a user