From 6eea4d1c7ac79587ef1fde33b950d541c52ebf9d Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:16:21 +0000 Subject: [PATCH] docs: review document for #152 Issue: #152 User-Visible: no --- docs/reviews/CODE-REVIEW-152-r1.md | 342 +++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 docs/reviews/CODE-REVIEW-152-r1.md diff --git a/docs/reviews/CODE-REVIEW-152-r1.md b/docs/reviews/CODE-REVIEW-152-r1.md new file mode 100644 index 00000000..a0fce551 --- /dev/null +++ b/docs/reviews/CODE-REVIEW-152-r1.md @@ -0,0 +1,342 @@ +# CODE-REVIEW-152-r1 + +- Issue: https://github.com/Matysh/houseplan-card/issues/152 +- Ветка: `issue/152-room-click-fit` +- SHA материала: `a54addb694a38baad7fe289c7eb9ab3d90c99704` +- Диапазон: `origin/dev..HEAD` (merge-base `d5bfa0e3`) +- Заход: r1 (код-ревью) · блокирующих циклов израсходовано 0 из 4 на этом этапе +- ТЗ: `docs/specs/152-room-click-fit.md` (r2, зелёное ревью ТЗ подтверждено в + `docs/reviews/SPEC-REVIEW-152-r2.md`, коммит `f672de67`) + +## Скоуп + +10 коммитов от `f29c4c5b` (спека) до `a54addb6` (принятие provenance +скриншотов). Продуктовый код: новый `src/room-fit.ts` (чистая геометрия/hit +ownership), интеграция в `src/houseplan-card.ts` (`_stagePointerDown/Move/Up/Cancel`, +`_fitRoom`, `_roomFitBounds`, `_renderRoomLabel`, resize-refit), новый +`CameraTransitionReason: 'room'` в `src/viewport-transition.ts`, механический +вынос части существующих хелперов в новый `src/card-runtime.ts` (лимит размера +основного файла), 4 новых мутанта в `scripts/mutation-gate.mjs`, поднятие +`bundle:budget` потолка 294 000 → 296 000 Б (документировано), i18n en/ru/de/fr, +`demo/smoke_room_fit.mjs`, `test/room-fit.test.mjs`, документация +(CANVAS/ISOMETRIC/TESTING/USER-GUIDE/CHANGELOG×2). + +Трейлеры: все коммиты несут `Issue: #152`; `User-Visible: yes` только на +`4aad9fac` (фича) — тот же коммит правит `docs/CHANGELOG.md` и +`docs/CHANGELOG.ru.md`. Остальные коммиты — `User-Visible: no` (тесты, +документация, фикс без пользовательского эффекта). Соответствует правилу. + +## Как проверялось + +| Гейт | Команда | Результат | +|---|---|---| +| typecheck | `npx tsc --noEmit` | зелёный, без вывода | +| unit | `npm test` | 1897 tests · 1896 pass · 0 fail · 1 skip (совпадает с заявлением автора) | +| build | `npm run build` | зелёный, `dist` пересобран | +| bundle sync | `npm run bundle:sync` | зелёный; `git status` после пересборки чист — три копии бандла побайтно совпадают с коммитом | +| bundle budget | `npm run bundle:budget` | зелёный: initial View 294 862 Б при потолке 296 000 (±2000); предупреждение о запасе 5 138 Б — это раскрытый и обоснованный в самом диффе долг #367, не новая находка | +| docs fingerprint | `node scripts/check-docs.mjs` | зелёный (diff трогает `src/**`, гейт обязателен) — «7 files, 12 external links» | +| mutation-gate AC6 | `node scripts/mutation-gate.mjs --id=room-fit-interactive-owner-leaks-through` | мутант поймал: `smoke_room_fit.mjs` краснеет | +| mutation-gate AC7 | `node scripts/mutation-gate.mjs --id=room-fit-pan-release-reaccepted` | мутант поймал | +| mutation-gate AC8 | `node scripts/mutation-gate.mjs --id=room-fit-enters-kiosk-double-tap-sequence` | мутант поймал | +| mutation-gate AC10 | `node scripts/mutation-gate.mjs --id=room-fit-html-overlay-jumps-ahead` | мутант поймал | +| smoke (AC-named) | `node demo/smoke_room_fit.mjs` | зелёный, все 14 свидетелей `true` | +| smoke (AC9) | `node demo/smoke_smooth_zoom.mjs` | зелёный | +| smoke (smoke-select, сильная связь на затронутый `_stagePointerCancel`) | `node demo/smoke_editor_gestures.mjs` | зелёный | +| smoke (smoke-select, сильная связь на `_holdFired/_panStart/_pinchStart/_swipeStart`) | `node demo/smoke_long_press_gesture.mjs` | зелёный | +| smoke (smoke-select, прямое совпадение `_applyView/_baseVb/_clampView/_panStart/_stageEl/_view`) | `node demo/smoke_pan_any_zoom.mjs` | **КРАСНЫЙ — необработанное исключение, см. High-1** | +| smoke (smoke-select, прямое совпадение) | `node demo/smoke_infinite_canvas.mjs` | зелёный | +| smoke (smoke-select, прямое совпадение) | `node demo/smoke_kiosk_pan_lock.mjs` | зелёный | +| smoke (smoke-select, прямое совпадение) | `node demo/smoke_decor.mjs` | зелёный | +| smoke (smoke-select, прямое совпадение) | `node demo/smoke_furniture.mjs` | зелёный | +| smoke (та же семья вызова `_stagePointerUp` плоским объектом) | `node demo/smoke_backdrop.mjs`, `node demo/smoke_hide_layers.mjs` | зелёные — крашится только View-ветка `smoke_pan_any_zoom` | +| ручная мутация AC15 (нет в реестре, см. Medium-2) | правка `houseplan-card.ts`, `bundle:sync`, `node demo/smoke_room_fit.mjs`, откат | `roomIntentIsSessionOnly` перевернулся в `false` — защита реальна, но не зарегистрирована | +| ручная проверка AC14 в редакторах (см. Medium-1) | ad-hoc браузерный скрипт через `demo/serve.mjs`, `_mode='devices'`/`'decor'` | `role="button" tabindex="0" aria-label="Fit room Office"` присутствуют, Enter/Space не производят эффекта | +| golden | не запускал | AC3/AC4 сами называют метод `unit + golden`, но план тестирования спеки прямо откладывает golden на пре-бету (`docs/specs/152-room-click-fit.md` §«План тестирования»: «Golden, полный smoke и performance — перед beta»), это же в PROCESS.md §8. Не расхождение, а согласованное разделение обязанностей | +| backend (`pytest tests_backend`) | не запускал | diff не трогает `custom_components/**/*.py` | +| invariants (`npm run invariants`) | не запускал | diff не меняет модель стен/комнат/layout/marker.space/open_spans — правится только камера | +| полный `demo/smoke_*.mjs` (220 файлов) | не запускал | непропорционально: диапазон правит camera/pointer-подсистему View, а не «всё»; выбор по `smoke-select.mjs` плюс прямые попадания выше признан достаточным, кроме описанной ниже находки | +| performance_smoke | не запускал | не названо в AC, perf-бюджет описан только как «не регрессирует» и закрыт бандл-бюджетом + отсутствием новых DOM-чтений (см. код-чтение AC16) | + +`node scripts/smoke-select.mjs --base origin/dev --head HEAD` вывод: 5 изменённых +файлов `src/**`, 101 символ на изменённых строках, порог «широкого» символа — 44 +смока. 59 файлов — «прямое совпадение», ядро (`smoke_room_fit`, +`smoke_smooth_zoom`, `smoke_editor_gestures`, `smoke_long_press_gesture`, +`smoke_pan_any_zoom`, `smoke_infinite_canvas`, `smoke_kiosk_pan_lock`, +`smoke_decor`, `smoke_furniture`) прогнан выше; остальные — совпадения по +одному распространённому символу (`_mode`, `_baseVb`, `_view`, `_zoom`, +`stopPropagation`, `_applyView`), которые сами инструмент и правило ревью +относят к «слабой связи» («повод посмотреть, а не обязанность прогонять»); я +их не прогонял. Полная матрица (220 файлов) — обязанность пре-релиза +(PROCESS.md §8), не этого цикла. + +## Находки + +### High-1. `demo/smoke_pan_any_zoom.mjs` падает необработанным исключением — новый код не переживает существующий вызов `_stagePointerUp` + +**Файл:** `src/houseplan-card.ts:6220-6223` (`_roomOwner`), вызов из +`_stagePointerUp` (`src/houseplan-card.ts:6941` и далее, `this._roomOwner(ev)` +на аргументе `acceptedRoomFitGesture`). + +**Воспроизведение:** на SHA `a54addb6`, чистое дерево, свежий `bundle:sync`: + +``` +$ node demo/smoke_pan_any_zoom.mjs +... +page.evaluate: TypeError: e.composedPath is not a function + at yg._roomOwner (…/houseplan-card-CAJGhl9a.js:4951:48660) + at yg._stagePointerUp (…/houseplan-card-CAJGhl9a.js:4959:10851) + at pu (eval at evaluate…) + at drag (eval at evaluate…) + at async inMode (eval at evaluate…) +Node.js v22.23.2 +``` + +Скрипт вызывает `_stagePointerUp({ pointerId, clientX, clientY })` — +лёгкий объект вместо настоящего `PointerEvent` — паттерн, которым в этом же +демо-наборе пользуются ещё три смока (`smoke_backdrop.mjs`, +`smoke_editor_gestures.mjs`, `smoke_hide_layers.mjs`); все три остаются +зелёными, потому что вызывают `_stagePointerUp` только вне `_mode==='view'`, +где `_roomOwner` рано возвращает `null` и до `ev.composedPath()` дело не +доходит. `smoke_pan_any_zoom.mjs` — единственный, кто гоняет ровно этот сценарий +(drag по пустому месту) в `_mode==='view'` (`inMode('view', …)` — первый вызов +в скрипте), и там `_roomOwner` пытается прочитать `ev.composedPath()` +безусловно. + +**Причина:** `_stagePointerUp` теперь вычисляет `this._roomOwner(ev)` +безоговорочно (как аргумент `acceptedRoomFitGesture`), даже когда +`this._roomPointer` уже `null` и результат гарантированно будет отброшен +`acceptedRoomFitGesture`'s `if (!candidate || …) return null;`. JS вычисляет +аргументы до вызова функции, так что защита внутри `acceptedRoomFitGesture` не +спасает — `ev.composedPath()` читается раньше. + +**В проде не воспроизводится** — единственный продуктовый вызов +`_stagePointerUp` идёт из `@pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}` +(`houseplan-card.ts:11556`) с настоящим DOM-событием, у которого +`composedPath()` есть всегда. Но это не снимает находку: `demo/smoke_pan_any_zoom.mjs` +входит в обязательный пре-релизный набор (PROCESS.md §8, `smoke` — гейт +`validate.yml`) и **сейчас красный на этом SHA**, а не только «не прогнан +автором» — это уже сломанный существующий тест, а не пропущенный. + +**Почему High, а не Medium:** это не гипотетический край, а воспроизводимый +крах существующего, ранее зелёного гейта, обязательного перед бетой; ревью не +может подтвердить «оно работает», когда прогон падает необработанным +исключением, а не assertion-ошибкой. + +**Предполагаемое направление фикса** (не мой мандат — решает автор): либо +защитить `_roomOwner`/`roomFitOwnerFromPath` от отсутствия `composedPath`, +либо не звать `this._roomOwner(ev)` вообще, когда `this._roomPointer` уже +`null` (это заодно и дешевле — не ходит по `composedPath()` на каждый +posterup, у которого не было room-кандидата). + +### Medium-1 (в скоупе). Подпись комнаты в редакторах Devices/Decor получает нерабочую клавиатурную кнопку — нарушает собственный AC14 + +**Файл:** `src/houseplan-card.ts:12798-12804` (`_renderRoomLabel`): `role`, +`tabindex`, `aria-label` и `@keydown` гейтятся условием `!this._markup`, а +`_markup` (`houseplan-card.ts:1707`) — это `this._mode === 'plan'`, то есть +**только** редактор Plan. В Devices (`_mode==='devices'`) и Decor +(`_mode==='decor'`) те же атрибуты присутствуют. + +**Воспроизведение** (ad-hoc браузерный скрипт через `demo/serve.mjs`, комната +с именем и `disp.showNames: true`): + +``` +c._mode = 'devices'; c.requestUpdate(); await c.updateComplete; +// .roomlabel[data-id="room-a"]: +// role="button" tabindex="0" aria-label="Fit room Office" +label.focus(); +label.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', … })); +// → c._view не изменился, c._roomFocus остался null + +c._mode = 'decor'; // тот же результат: role="button" tabindex="0" aria-label="Fit room Office" +``` + +`_roomLabelKey` (`houseplan-card.ts:7032`) начинается с +`if (this._mode !== 'view' || …) return;`, так что Enter/Space в Devices/Decor +не делают ничего — но screen reader и клавиатурный пользователь видят +фокусируемую кнопку «Вписать комнату Office», которая молча ничего не +исполняет. + +**Нарушенный AC:** AC14 прямым текстом: «…editors не получают room action». +Раздел «Touch, темы и режимы» ТЗ называет явно Plan/Backdrop, но общий принцип +раздела «Клавиатура и доступность» — target есть только «в View/kiosk» — тоже +нарушен для Devices/Decor. + +**Почему Medium, не High:** не ломает основной сценарий (клик/тап в View), +не искажает данные, задевает только клавиатурных/screen-reader пользователей +внутри admin-only редакторов. Фикс тривиален и в скоупе: заменить +`!this._markup` на `this._mode === 'view'` в атрибутах `role`/`tabindex`/ +`aria-label` и в условии `@keydown` подписи. + +### Medium-2 (в скоупе). Защита AC15 (запрет `_saveZoom` для reason `room`) не зарегистрирована в mutation-gate, хотя доказывается только дорогим смоком + +**Файлы:** `src/houseplan-card.ts:1122` (`if (state.reason !== 'room') this._saveZoom();`) +и `:1142` (`if (presentedZoom !== undefined && reason !== 'room') this._saveZoom();`). + +Спека сама формулирует AC15 так: «Mutation, направляющая reason `room` в +`_saveZoom`, краснит storage spy» — то есть обещает мутанта. План тестирования +относит AC15 к «дешёвым pure unit», которые ревьюер проверяет локальным снятием +защиты. По факту защита доказана только внутри `demo/smoke_room_fit.mjs` +(`roomIntentIsSessionOnly`) — не pure-unit тестом (`test/viewport-transition.test.mjs` +не тронут вообще этим диффом) — то есть именно «дорогим гейтом (смок)», для +которого PROCESS.md §2.7 требует обязательного мутанта в +`scripts/mutation-gate.mjs`: «там ревьюер не воспроизведёт отрицательный прогон +второй раз». Среди 4 новых мутантов диффа (`room-fit-interactive-owner-leaks-through`, +`room-fit-pan-release-reaccepted`, `room-fit-enters-kiosk-double-tap-sequence`, +`room-fit-html-overlay-jumps-ahead` — все покрывают AC6/AC7/AC8/AC10) мутанта на +AC15 нет. + +**Я проверил вручную, что защита реальна** (иначе это была бы находка High — +непроверяемый AC): временно убрал оба условия `reason !== 'room'`, +`bundle:sync`, `node demo/smoke_room_fit.mjs`: + +``` +"roomIntentIsSessionOnly": false // было true +FAILED (1): - roomIntentIsSessionOnly: expected true, got false +``` + +Откатил правку, `bundle:sync`, дерево чисто на `a54addb6`. Защита работает +сейчас — но без записи в реестре она не переживёт следующую правку камеры: +будущий ревьюер (или CI) не узнает, что нужно её защищать, пока баг снова не +доедет до пользователя (ровно сценарий, который описывает сам mutation-gate.mjs +в шапке файла). + +**Почему Medium, не High:** сам факт защиты подтверждён (мутация красит +smoke), это пробел в реестре регресс-тестирования, а не текущий баг. + +### Low (снимаю без правки, с записью) + +1. **AC5 (вложенные комнаты) и AC9 (retarget между двумя разными комнатами + во время tween)** не имеют прямого автотеста, обещанного планом + тестирования (`demo/smoke_room_fit.mjs` кликает только по одной комнате — + `room-a`; вторая комната `outer` в фикстуре есть, но по ней ни разу не + кликают; `test/viewport-transition.test.mjs` этим диффом не тронут вообще, + хотя план тестирования спеки обещал его расширить). Не поднимаю до Medium: + оба свойства следуют по конструкции из переиспользования немодифицированного + механизма — `roomFitOwnerFromPath` читает `ev.composedPath()` те же DOM-узлы + `[data-hp="room"]`, что уже использует hover (`@pointerenter=${enterRoom}` на + тех же элементах, `houseplan-card.ts:11700-11726`), и `_startCameraTransition` + (ретаргет от `_cameraState()`, который читает live `_zoom`/`_view`, + непрерывно обновляемые во время tween) — код, который #82 уже покрывает для + других reason. Проверено чтением, не исполнением. +2. **Механический вынос в `src/card-runtime.ts`** (`strictNumber`, `lruRead`/ + `lruWrite`, `normalizeMarkupTool`, `expiredWarmViewport`, `warmBootKey`, + `warmMatch`) побайтно сверен построчным diff с версией на `origin/dev` — + логика идентична (только `export`, переименование локальных переменных + `s`→`slot`, форматирование), но при переносе потеряны развёрнутые + комментарии-обоснования (`DEV-B703-01`, `AUD-159B1-01` и др.), объясняющие + *почему* устроено именно так warm-remount кеширование. Не блокирует — + история остаётся в `git blame` — но будущим читателям `card-runtime.ts` + будет сложнее реконструировать контекст. + +## Таблица «чем краснеет» (защитные AC, PROCESS.md §2.7) + +| AC | Чем доказан | Чем краснеет | +|---|---|---| +| AC6 (интерактивный владелец подавляет fit) | `node demo/smoke_room_fit.mjs` | `node scripts/mutation-gate.mjs --id=room-fit-interactive-owner-leaks-through` → красный, прогнано, поймано | +| AC7 (pan/pinch/long press не создают fit) | `node demo/smoke_room_fit.mjs` | `node scripts/mutation-gate.mjs --id=room-fit-pan-release-reaccepted` → красный, прогнано, поймано | +| AC8 (room tap не входит в kiosk double-tap) | `node demo/smoke_room_fit.mjs` | `node scripts/mutation-gate.mjs --id=room-fit-enters-kiosk-double-tap-sequence` → красный, прогнано, поймано | +| AC10 (SVG/HTML синхронны на промежуточных кадрах) | `node demo/smoke_room_fit.mjs` | `node scripts/mutation-gate.mjs --id=room-fit-html-overlay-jumps-ahead` → красный, прогнано, поймано | +| AC15 (нет zoom-only записи для reason `room`) | `node demo/smoke_room_fit.mjs` (`roomIntentIsSessionOnly`) | **не зарегистрирован**; ручная мутация (снял оба `reason !== 'room'`) → `roomIntentIsSessionOnly: false`, прогнано мной, не воспроизводимо CI до фикса (Medium-2) | +| AC11 (повторный fit — no-op) | `node demo/smoke_room_fit.mjs` (`repeatedFitIsNoOp`) | не зарегистрирован; логика (`sameCameraState`-гард в `_startCameraTransition`) не изменена этим диффом, унаследована от #82 — не перепроверял мутацией, см. «чего не проверял» | +| AC12 (resize сохраняет focus до ручной команды) | `node demo/smoke_room_fit.mjs` (`resizeRefitsAtomically`, `panCancelsRoomIntent`) | не зарегистрирован; проверено чтением (`_clearRoomFocus()` вызывается во всех перечисленных в ТЗ точках отмены) — не перепроверял мутацией | +| AC2/AC3/AC4 (математика полей/zoom/bounds) | `node --test test/room-fit.test.mjs` (pure unit, `test-build/room-fit.js`) | дешёвый unit, я прочитал и мысленно снял защиту (`q: 0.8→1`, удаление clamp) — assertions считают точные числа, любое искажение ломает `close()`/`assert.equal`; не требует мутации по PROCESS.md §2.7 (дешёвый pure unit) | + +## Что проверено и корректно + +- **AC1** (чистый click/tap вписывает и центрирует): `smoke_room_fit.mjs` → + `cleanRoomTapFits`, `oneFlatAxisUsesTenPercent` зелёные для mouse; keyboard — + `keyboardUsesSameCommand`. Touch/pen отдельно не гонял (смок использует + `pointerType: 'mouse'`/`'touch'` в разных секциях — touch есть в kiosk-блоке), + pen — проверено чтением: ownership-код (`roomFitOwnerFromPath`, + `acceptedRoomFitGesture`) не различает `pointerType`, значит логика + идентична для mouse/touch/pen по конструкции. +- **AC2** (10% ± 1 px, 2000×1000→800×800): `test/room-fit.test.mjs` точно + воспроизводит числовой пример спеки; прогнано, проходит. +- **AC3/AC4** (bounds = ровно floor+wall body, Flat/Iso через точные + проекции): unit-тесты плюс код-чтение `_roomFitBounds` — использует те же + `_innerRoomContour`/`_wallUnionGeometry`/`roomWallProfile`/`outsetContour`, + что рендер, без DOM `getBoundingClientRect` и без второй wall-модели + (`grep` по диффу — ноль совпадений). Golden — сознательно отложен на + пре-бету по плану тестирования спеки. +- **AC5**: проверено чтением — общий DOM-путь с hover (см. Low-1). +- **AC6/AC7/AC8/AC10**: доказаны и перепроверены мутациями (таблица выше) — + все четыре мутанта поймали регресс. +- **AC9**: проверено чтением, `_startCameraTransition` и `_cameraState()` не + изменены этим диффом кроме добавления reason `room`; `smoke_smooth_zoom.mjs` + зелёный подтверждает, что общий контракт ретаргета/cancel не сломан. +- **AC11/AC12/AC13**: смок зелёный (`repeatedFitIsNoOp`, `resizeRefitsAtomically`, + `panCancelsRoomIntent`); AC13 (invalid geometry/zero stage) — код-чтение + `_fitRoom`/`_roomFitBounds` (оба возвращают `null`/no-op на невалидных данных + без NaN-путей, `_roomFitBounds`→`roomFitGeometryBounds`→`finitePoint` + фильтрует NaN на входе). +- **AC14**: role/tabindex/aria-label/focus-visible/Enter-Space работают + корректно **в View и kiosk** — `smoke_room_fit.mjs` + (`labelIsKeyboardAction`, `keyboardUsesSameCommand`); но см. Medium-1 для + Devices/Decor. +- **AC15**: гейт работает сейчас (см. Medium-2 про отсутствие мутанта). +- **AC16**: bundle:budget зелёный; код-чтение подтверждает, что + `_roomFitBounds` вызывается один раз на принятый жест/resize (не в + `_stagePointerMove`/RAF), и не читает layout (`getBoundingClientRect` + отсутствует). +- i18n: `room.fit_action` добавлен с parity en/ru/de/fr. +- Документация: `docs/CANVAS.md`, `docs/ISOMETRIC.md`, `docs/TESTING.md`, + `docs/USER-GUIDE.ru.md`, оба CHANGELOG — соответствуют реализации, термины + совпадают с фактическим поведением (не проверял на разошедшиеся с кодом + утверждения, кроме уже отмеченного разрыва в Medium-1, который документация + не выдаёт за факт — таблица USER-GUIDE описывает только View). +- Фикс-коммит `6a7ef48a` («preserve editor pointer cancellation») — реальная, + обоснованная правка регрессии, внесённой первым коммитом этой же задачи + (`4aad9fac`), не расширение скоупа: `_stagePointerCancel` раньше считал + `viewportGestureEnded` уже ПОСЛЕ того как host обнулял `_pinchStart`/ + `_panStart`, из-за чего editor-ветка теряла `requestUpdate()` после отмены + pan/pinch в редакторе. Перепроверено `smoke_editor_gestures.mjs` (зелёный) — + единственный минус: два оператора на одной строке через `;` (стилевая + мелочь, не блокирует). +- Bundle-budget: поднятие потолка задокументировано в самом файле с числами, + согласуется с измеренным результатом (294 862 Б, заявлено 294 864 в + комментарии — расхождение 2 Б, в пределах шума сборки). + +## Чего не проверял + +- Полный `demo/smoke_*.mjs` (220 файлов), `npm run golden:capture`/`golden:verify`, + `performance_smoke` — по плану тестирования спеки и PROCESS.md §8 это + пре-релизный гейт, не гейт этого цикла; я прогнал целевую выборку + (smoke-select + AC-именованные + семью `_stagePointerUp`-вызовов) и нашёл в + ней реальный красный результат (High-1) — до его исправления смысла гонять + остальные 200+ файлов не вижу. +- `python -m pytest tests_backend` — diff не трогает `custom_components/**/*.py`. +- `npm run invariants` — diff не меняет геометрическую модель (комнаты/стены/ + layout/marker.space/open_spans); камера не участвует в инвариантах. +- Мутации для AC11/AC12 (нет в реестре; логика либо унаследована от #82 + без изменений в этом диффе — AC11, либо тривиально читается — AC12). +- Реальное touch/pen устройство и screen reader — по коду и смоку логика не + различает `pointerType`, но живого браузера на телефоне/с NVDA не было. +- Изометрический golden-скриншот (см. выше — сознательно отложен). + +## Вердикт + +Один блокирующий High (сломанный существующий пре-релизный смок, +воспроизведён на текущем SHA) и два Medium в скоупе (нарушение AC14 в +Devices/Decor, отсутствующий обязательный mutation-gate на AC15). Все три — +чинятся в этой же задаче без расширения скоупа. + +**Вердикт: красный · заход r1 · блокирующих циклов 1/4 · High: 1 · Medium: 2 → в задаче** + +Возврат в `S6-in-progress`. + +--- + + + +## Материал раунда + +- Ветка: `issue/152-room-click-fit`, коммит `a54addb694a3` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. +- Дерево материала: `042ef7b3005c8e10620427979a9960d36eed0155` + ``` + git log --all --format='%H %T' | grep 042ef7b3005c + ``` +- ТЗ `docs/specs/152-room-click-fit.md`, блоб `fb036d2bb9cc522555c55e15000d7d693e204f8b` + ``` + git log --all --find-object=fb036d2bb9cc522555c55e15000d7d693e204f8b -- docs/specs/152-room-click-fit.md + ```