# CODE-REVIEW-531-r1 **Issue:** #531 «Панорама лагает в Firefox: viewBox переписывается каждый кадр» **Материал:** ветка `issue/531-live-pan-transform`, SHA `57b80f08e63040c90ef67ff6ab7b65e4addfdc36` (дерево `2b20dd807a08f98ef09ed50cc0cd04db39138ea3`) **Заход:** r1 · блокирующих циклов израсходовано 0/4 **Вердикт: жёлтый** ## Скоуп ТЗ (спор-ревью зелёный на r2, `docs/reviews/SPEC-REVIEW-531-r2.md`) требует: во время жеста панорамы SVG-сцена двигается CSS-трансформом, а `viewBox` переписывается не чаще, чем позволяет бюджет (`LIVE_VIEWBOX_REFRESH_MS` ≈100 мс либо `LIVE_VIEWBOX_REFRESH_SHIFT` ≈15 % сдвига/масштаба); терминальное примирение не меняется; ничего не пишется в DOM, если строка не изменилась; слой устройств и сцена не расходятся (#451). AC1–AC9 в теле issue. Диапазон диффа к `origin/dev`: `src/live-viewport.ts` (единственный продуктовый файл), `test/live-viewport.test.mjs` (новый), `demo/smoke_live_pan_viewbox.mjs` (новый), `scripts/mutation-gate.mjs` (3 новые записи), `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`, `docs/ARCHITECTURE.md`, плюс три копии сгенерированного бандла (класс D). `src/live-interaction-runtime.ts`, вопреки предположению аналитики о затронутых поверхностях, не тронут — планировщик жеста не менялся, значит и touch-путь (тот же `PointerEvent`) не отличается от мышиного ни для одной из проверок ниже. ## Как проверялось Дешёвые гейты уже зелёные в Validate на этом SHA (ссылка на прогон в задаче ревью), но я перегнал их и сам, поскольку часть проверок ниже требует пересобранного бандла и рабочего дерева на этом же SHA: | Гейт | Команда | Результат | |---|---|---| | Типы + юниты + сборка | `npm run build` (включает `tsc --noEmit`) | green | | Юнит-тесты | `npm test` | 2525 passed, 1 skipped, 0 failed | | Три копии бандла | `npm run bundle:sync` + `cmp` попарно `dist`/`custom_components`/`demo/srv/assets` | идентичны | | Бюджет бандла | `npm run bundle:budget` | initial View 287 284 Б (потолок 288 300 ±2000) — совпадает с заявленным в хендоффе | | Новый `any` | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | 109 добавленных строк, новых `any` нет | | Провенанс коммита | `git show -s --format=full HEAD` | `Issue: #531`, `User-Visible: yes`, ветка/трейлеры на месте | | Документация фронтенда (diff трогает `src/**`) | `node scripts/check-docs.mjs` | **ERROR** — см. находку Medium ниже | | Смок AC5/AC6 | `node demo/smoke_live_pan_viewbox.mjs` | green: `viewBoxWritesDuringGesture=4`, `worstParityPx=0`, совпадает с хендоффом | | Golden (AC7) | `npm run golden:verify` | **169/169 passed, 0 расхождений** — полнее, чем в хендоффе (167 + 2 таймаута песочницы автора); подтверждает, что оба «зависших» сценария у автора не относились к реальному регрессу | | Мутанты (AC8) | см. таблицу «чем краснеет» ниже | 3/3 новых + 1 соседний старый — все проверены **выполнением**, не чтением | | `smoke-select.mjs` | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | НЕОПРЕДЕЛЁННОСТЬ — новые символы (`needsViewBoxRefresh`, `LIVE_VIEWBOX_REFRESH_*` и т.д.) не связаны ни с одним существующим смоком; это ожидаемо для новой сущности, свидетель для неё — именно новый `demo/smoke_live_pan_viewbox.mjs`, названный в AC5/AC6 напрямую. Дополнительных «широких» совпадений выборка не дала (0 смоков) | | Инварианты модели | не запускал | диф не трогает геометрию/`layout`/толщины — не применимо | | `pytest tests_backend` | не запускал | диф не трогает `custom_components/**/*.py` — не применимо | | `performance_smoke` (`large-house-interaction-v1`) | не запускал | явно выведено из-под AC самим ТЗ («Приёмка по скорости — отдельно и не здесь»); Validate на этом SHA его тоже не гонял (job гейтится `heavy`, а этот push — обычный, не кандидат/PR/`workflow_dispatch full=true`) | Рабочее дерево после всех прогонов и намеренных мутаций восстановлено ровно к `57b80f08` (`git status --short` пуст, `git diff HEAD -- src/live-viewport.ts` пуст) — материал ревью не изменён. ## Находки ### Medium (в скоупе задачи) — устаревший отпечаток скриншотов документации `node scripts/check-docs.mjs` на `57b80f08` красный: ``` ERROR screenshot source fingerprint is stale; run npm run docs:capture and accept before the beta candidate (#479) ``` На `origin/dev` (до этого диффа) тот же скрипт зелёный: `Documentation checks passed (7 files, 12 external links)`. Значит именно правка `src/live-viewport.ts` делает отпечаток `docs/images/screenshots.json` устаревшим (отпечаток считается по всему `src/**`, PROCESS.md §8) — а `docs/images/screenshots.json` в этом диффе не трогался (`git diff origin/dev...HEAD -- docs/images/screenshots.json` пуст). Это не всплыло в Validate на этом SHA, потому что `docs`-job на обычном push (не кандидат, не PR, не `workflow_dispatch full=true`) идёт в режиме `warn`, а не `strict` — job зелёный, хотя отпечаток фактически устарел. Ровно тот же сценарий, что увёл `dev` в красный `docs`-job на #230/#234 до следующей задачи. Правка не меняет ни один снятый кадр документации (сама механика жеста — не то, что попадает на статичные скриншоты), и `golden:verify` подтверждает нулевое визуальное расхождение по всей матрице — то есть цена закрытия минимальна: `npm run docs:accept -- --identical` должен пройти чисто (перезаписать только отпечаток исходников, не сами PNG) и закоммититься в этом же issue. Я не выполнял это сам — правка `docs/**` вне git-дерева ревьюера не моя роль, а предмет авторского коммита. **Воспроизведение:** `node scripts/check-docs.mjs` на `57b80f08` (без каких-либо изменений с моей стороны). ## Таблица «чем краснеет» (защитные AC, #435) | AC | чем доказан | чем краснеет | проверено | |---|---|---|---| | AC1 (кадр жеста — трансформ, не `viewBox`) | `test/live-viewport.test.mjs`, тест «#531 AC1» | мутант `live-pan-rewrites-viewbox-every-frame` (`needsViewBoxRefresh(...)` → `true`) | **выполнением**: применил патч, `tsc -p tsconfig.test.json` + `fix-test-build` + `node --test` → 6/8 (AC1 и AC2 падают) | | AC2 (перезапись по бюджету времени, трансформ снимается) | тест «#531 AC2» | тот же мутант | тем же прогоном (см. выше) | | AC2а (сдвиговый триггер срабатывает раньше времени) | тест «#531 AC2а» | мутант `live-pan-shift-threshold-ignored` (удаление сдвиговых веток) | **выполнением**: 7/8, падает именно AC2а | | AC3 (тихий кадр не пишет в DOM) | тест «#531 AC3» | мутант `live-pan-writes-unchanged-viewbox` (снятие проверки тождества в `setViewBox`) **не красит AC3** — в этом кадре `refresh=false`, и `setViewBox` вовсе не вызывается, проверка внутри неё недостижима. Красит **AC4** (см. ниже) | выполнением; уточнение важно само по себе — без него третий столбец для AC3 был бы пуст | | AC4 (терминальное примирение идемпотентно) | тест «#531 AC4» | мутант `live-pan-writes-unchanged-viewbox` | **выполнением**: 7/8, падает AC4 (повторный `force:true` пишет лишний раз) | | AC5 (≤ бюджета перезаписей за 20 кадров жеста) | `demo/smoke_live_pan_viewbox.mjs` | мутант `live-pan-rewrites-viewbox-every-frame`, guard = этот смок (дорогой гейт, повторно не гонял чужую отрицательную пробу — но тот же мутант уже поймал юнит-прогон выше, независимое подтверждение) | смок прогнан чистым: `viewBoxWritesDuringGesture=4` при `framesInGesture=20` | | AC6 (маркер и сцена в пределах 1 px, #451) | тот же смок, `worstParityPx`/`markerFollowsTheScene` | любое расхождение экранных смещений маркера и узла сцены | прогнан: `worstParityPx=0` | | AC7 (осевшие кадры не изменились) | `npm run golden:verify` | любое отличие хотя бы одного пикселя от эталона — это сравнение, не мутация | прогнан: 169/169, 0 расхождений | | AC8 (мутанты реестра) | `scripts/mutation-gate.mjs`, 3 новые записи | сами мутанты | все три плюс соседний `live-viewport-identity-projection-not-recognized` — выполнением, см. выше | | AC9 (документация, трейлеры) | `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`, `docs/ARCHITECTURE.md`, коммит | не защитный AC — обычное сравнение | чтением диффа | ## Что проверено и корректно - **К1/К2/К3/К4 в коде соответствуют ТЗ.** `paintLiveViewport` держит `anchor` (кадр, чей `viewBox` реально в DOM, и момент записи); сцена всегда проецируется от `anchor` к текущему кадру тем же `liveLayerProjection`, что и HTML-слои; `viewBox` переписывается только когда `needsViewBoxRefresh` (время **или** сдвиг **или** масштаб) либо `force`; и `setViewBox`/`setLayerProjection` дедуплицируют запись по строке в обоих случаях. - **К5/#451 не нарушен.** Сцена и HTML-слой двигаются одним и тем же преобразованием на каждом кадре (для сцены — от `anchor`, для слоя — от последнего осевшего кадра Lit, но оба landing point — текущий вид); смок AC6 подтверждает 0 px расхождения после 20 кадров жеста. - **Две базы проекции (сцена от `anchor`, слой от `painted`) — намеренная и корректная асимметрия**, а не путаница: HTML-слой позиционирован в процентах осевшего Lit-кадра и не может проецироваться от `anchor`, у него нет своего атрибута для дедупликации записи. - **Дополнительный триггер по масштабу** (`needsViewBoxRefresh` проверяет не только сдвиг по x/y, но и изменение `w`) — расширение сверх буквального текста К2 (там речь только о сдвиге), но действует строго в сторону усиления защиты от пустой полосы на краю при зуме, не ослабляет ни один AC. Не считаю находкой. - **AC5 в реализации отличается от буквального текста ТЗ** («не больше двух» → формула `max(2, ceil(elapsed/100)+1)` + `< frames/2`). Автор явно вынес это отклонение на решение ревью. Обоснование верное: 20 кадров смока на 60 Гц песочницы — это ≈330 мс, то есть 3–4 бюджетных окна по 100 мс, а не 139 мс (2 окна) как на владельческих 144 Гц; жёсткая «2» сделала бы свидетеля зависимым от частоты кадров машины прогона. Формула сохраняет содержательную проверку (перезаписей заметно меньше, чем кадров) и ловится тем же мутантом. Принимаю без правки: технический спор автора и ревьюера решается вердиктом (§7.2), не отправляю владельцу. - **Провенанс.** Один коммит, `Issue: #531`, `User-Visible: yes`, оба changelog правлены в нём же, `docs/ARCHITECTURE.md` описывает механику. - **Бюджет и типизация** без регрессий (см. таблицу гейтов). ## Чего не проверял и почему - **`performance_smoke` (`large-house-interaction-v1`).** Сам ТЗ выводит миллисекунды за скобки AC («Приёмка по скорости — отдельно и не здесь», единственный судья — профиль с машины владельца); Validate на этом SHA его тоже не гонял, поскольку job `heavy`-гейтится, а push не кандидат/PR/ручной полный прогон. Причинный механизм (сцена не растеризуется каждый кадр) доказан AC1–AC6 напрямую. - **`node scripts/model-invariants.mjs`, `pytest tests_backend`.** Диф не трогает геометрию/толщины/`layout` и не трогает Python — не применимо. - **Полная матрица смоков (243 файла).** `smoke-select.mjs` не нашёл ни прямых, ни зарегистрированных связей (только НЕОПРЕДЕЛЁННОСТЬ на новых символах, для которых свидетель — сам новый смок), «широких» совпадений — 0. Прогон всей матрицы для точечной правки одного модуля камеры избыточен. - **Мутант `live-pan-rewrites-viewbox-every-frame` против самого браузерного смока** (а не только против юнитов, которые тот же мутант тоже красит) — дорогой гейт, свидетеля второй раз не воспроизводил; принял отчёт автора (3/3 пойманы) вместе с независимым фактом, что тот же мутант уже красит юнит-прогон. - **Изометрический путь (риск 3 ТЗ).** Ни один тест (юнит или смок) не использует кадр, где `floor !== view` — то есть заявленное в разделе «Риски» ТЗ «проверяется тем же юнитом на изометрических данных» текстом теста не подтверждается: `frame()`-хелпер в `test/live-viewport.test.mjs` всегда строит `floor` равным `view`, а смок работает в `_setMode('view')` (не изо). Код при этом полностью симметричен для `camera`/`floor` (одна и та же пара функций, различается только поле кадра), так что риск разъехаться именно на изометрии низкий, но эмпирически не закрыт. Не блокирую — это не требование ни одного из AC1–AC9, только пункт из повествовательного раздела «Риски»; фиксирую как наблюдение, а не как находку Medium/Low с обязательной правкой. ## Материал раунда - SHA: `57b80f08e63040c90ef67ff6ab7b65e4addfdc36` - Дерево: `2b20dd807a08f98ef09ed50cc0cd04db39138ea3` - Диапазон: `origin/dev...HEAD` (53 файла, из них продуктовый — только `src/live-viewport.ts`; остальное — тесты/смок/мутанты/документация/бандл) --- ## Материал раунда - Ветка: `issue/531-live-pan-transform`, коммит `57b80f08e630` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала: `2b20dd807a08f98ef09ed50cc0cd04db39138ea3` ``` git log --all --format='%H %T' | grep 2b20dd807a08 ``` - Тело issue: `89e75138a50434ac52f42cef606d8af741a8ae3b022dba382c4b62c1f4887e8a` - Вердикт конвейера: `yellow` · High 0