Files
houseplan-card/docs/reviews/CODE-REVIEW-74-r1.md
T
2026-08-30 13:38:16 +00:00

320 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CODE-REVIEW-74-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/74
- **Этап:** код-ревью (PROCESS.md §2.7)
- **Материал:** `git log --oneline origin/dev..HEAD` и `git diff origin/dev...HEAD` на SHA
`fc63f3bf36687cd98c9777ce683d2f2e49d39ff6` (ветка `issue/74-device-position-undo`).
Ветка приведена конвейером к `dev` до ревью (легло 10 коммитов `dev`, `d0e1a4b0` → `fc63f3bf`) —
по §7.2 это другой код, разбор полный, не по дельте.
- **Заход:** r1 (первый код-ревью; предыдущий вердикт в issue — по ТЗ, `SPEC-REVIEW-74-r1`,
зелёный, бюджет код-ревью не расходовал)
- **Вердикт:** жёлтый
- **High:** 0 · **Medium:** 1 (в скоупе, чинится в этой же задаче) · **Low:** 2 (записаны, не блокируют)
## Скоуп разбора
Первый заход код-ревью — полный разбор диапазона `origin/dev..HEAD` (6 коммитов:
спецификация, актуализация, документ спек-ревью, реализация, применение замечаний спек-ревью,
выравнивание регрессионных смоков), всех 14 AC из `docs/specs/074-device-position-undo.md`,
трейлеров, changelog, и гейтов из PROCESS.md §8/§10.2, соразмерно объёму задачи (новая
independent history для позиций устройств; backend/схема не менялись — подтверждено:
`git diff --stat` не содержит `custom_components/**/*.py`).
## Как проверялось
Зелёного Validate на этом SHA нет, поэтому дешёвые и часть требуемых по диффу гейтов прогнаны
самостоятельно:
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | OK, без ошибок |
| Unit | `npm test` | `tests 1634, pass 1633, fail 0, skipped 1` |
| Build | `npm run build` | OK, `dist` собран |
| Bundle sync | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | совпадает; `npm run bundle:sync` не создал diff в git — дерево уже синхронизировано в коммите |
| Bundle budget | `npm run bundle:budget` | `initial View 281796 B gzip` (бюджет 300000 B, запас 18204 B) |
| Docs fingerprint | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 10 external links)` |
| `any`-гейт | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | `Новых any нет` (380 добавленных строк в 3 файлах) |
| Coordinate write barrier | `node scripts/coordinate-write-barrier-guard.mjs` | OK (обновлённый инвариант: 3 канонических `layout/update`) |
| Process gate | `node scripts/process-gate.mjs --base origin/dev --head HEAD --issues` | `гейт пройден, предупреждений 0` (6 коммитов в диапазоне) |
| Smoke selection | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 35 прямых совпадений, 21 слабая связь (полный вывод — раздел «Выбор смоков» ниже) |
| Targeted smokes (AC1-AC12, AC5, AC9, AC14) | `node demo/smoke_device_position_history.mjs` | OK, все 27 полей `true` |
| | `node demo/smoke_drag_bounds.mjs` | OK |
| | `node demo/smoke_modes.mjs` | OK |
| | `node demo/smoke_pan_any_zoom.mjs` | OK |
| | `node demo/smoke_grid_snap.mjs` | OK |
| | `node demo/smoke_editor_tabs.mjs` | OK |
| | `node demo/smoke_layout_sync.mjs` | OK |
| Мутационная проверка «тест умеет падать» | `node scripts/mutation-gate.mjs --id=device-position-cancel-routed-to-commit` | чистый прогон OK, мутант «pointercancel → commit» — `smoke_device_position_history.mjs` **краснеет**, как обязан |
| | `node scripts/mutation-gate.mjs --id=stale-space-position-guard-removed` | чистый прогон OK, мутант краснеет, как обязан |
| Golden (диагностика, не приёмка) | `node demo/golden/run.mjs --mode=capture --scenario=geometry-devices-editor-dark` | `different` — см. находку Medium-1 |
| | `node demo/golden/run.mjs --mode=capture --scenario=geometry-plan-editor-dark` | `passed` (использован для локализации причины Medium-1, см. ниже) |
| Model invariants | не прогонялся | diff не трогает геометрию комнат/стен/`marker.space`/`open_spans` — только `houseplan/layout` (позиции устройств), это вне предмета `scripts/model-invariants.mjs` (проверено чтением скрипта: инварианты про полигоны комнат и толщину стен) |
| `python -m pytest tests_backend` | не прогонялся | diff не содержит `custom_components/**/*.py` (подтверждено `git diff --stat`) |
### Выбор смоков — обоснование по каждой строке
`smoke-select.mjs` вернул 35 прямых совпадений. Прогнаны все, что относятся к изменённым путям
исполнения (drag/preview/persist/toolbar/keyboard/layout-sync), а не только упомянутые в тексте
AC14:
- **Прогнаны** (7): `smoke_device_position_history` (новый, основной AC-смок),
`smoke_drag_bounds`, `smoke_modes`, `smoke_pan_any_zoom`, `smoke_grid_snap` (все изменены самим
диффом — обязаны быть перепроверены целиком, а не доверием к тому, что автор их поправил),
`smoke_editor_tabs` (проверяет количество кнопок панели и доступные имена — прямое AC13/AC9),
`smoke_layout_sync` (совпал по `_sentPos`/`_serverStorage`, которые diff меняет по типу — точечный
update/delete tombstone теперь может быть `null`).
- **Не прогнаны, решение — false positive** (`redoName`/`undoName` совпадения в
`smoke_furniture.mjs`, `smoke_unified_wall_tool.mjs`, `smoke_v8_draft_write.mjs`): проверено
чтением — это геттеры **другого**, не тронутого экземпляра `CommandStack`
(`_geometryHistory.undoName/redoName`, `src/houseplan-editor-runtime.ts`), совпадение по имени
свойства класса, не по факту зависимости от нового кода. `_geometryHistory` не тронут этим
диффом (не входит в `git diff --stat`), новый `_devicePositionHistory` — отдельный самостоятельный
экземпляр без общего состояния.
- **Остальные 28 из 35** (`smoke_junction_limits`, `smoke_active_chain_ink`, `smoke_align_guides`,
`smoke_controls`, `smoke_device_inbox`, `smoke_editor_gestures`, `smoke_fixed_floor`,
`smoke_help_affordance`, `smoke_infinite_canvas`, `smoke_junction_holes`,
`smoke_lattice_write_barrier`, `smoke_long_press_gesture`, `smoke_orphan_space_references`,
`smoke_partition_openings`, `smoke_plan_snap_overlay`, `smoke_room_cards`, `smoke_room_resize`,
`smoke_tap_ctx`, `smoke_wallthick_standalone`, `smoke_ws_resilience`,
`smoke_zero_wall_migration_unblocked`, `smoke_zoom_out`, `smoke_decor`,
`smoke_dialog_footer_width`, `smoke_optimize_coordinate_canonicalization`,
`smoke_optional_space_model`, `smoke_v8_draft_write` (частично)) — не прогнаны: совпадение по
общим символам верхнего уровня (`NORM_W`, `_mode`, `_showToast`, `_serverStorage`, `_drag`,
`_suppressClick`, `_selId`, `_commitSpace`, `_holdFired`, `_restoreZoom`, `_savePos` через
room-label путь), ни один из которых этот diff не меняет по семантике — прогон всех 35 не
соразмерен задаче (PROCESS.md §8, «полные наборы — предрелизный гейт»). Автор в хендоффе также
прогонял более широкий список (`smoke_room_cards`, `smoke_ws_resilience`,
`smoke_long_press_gesture`, `smoke_fixed_floor`) — не переисполнялись повторно, доверие к
зафиксированной команде и результату оправдано, поскольку это чистые "прочитал/не менял" пути.
- **21 слабая связь** (все — `_mode`, самый частый символ проекта): не прогонялись, `_mode`
проверяется по значению `'devices'` в изменённом коде, но сам механизм переключения режимов не
менялся (проверено чтением `_setMode`/`_mode` — единственное новое место, где `_mode === 'devices'`
влияет на поведение, это `_onKey`, уже покрыт прогоном AC9).
- `golden`/`performance_smoke` полным набором — не прогонялись намеренно: предрелизный гейт
(PROCESS.md §8, §11.4), не гейт код-ревью. Один сценарий прогнан диагностически (см. ниже).
## Находки
### Medium-1 — новый Undo/Redo toolbar рендерится без иконок из-за неактуального `demo/srv/assets/icons.js`; сломан именно опубликованный в этом же коммите скриншот `docs/images/06-device-editor.png`
**В скоупе задачи** — картинка и golden-сценарий, которые проваливаются, это собственная поставка
#74 (новый Device-editor toolbar), обновлённые в том же коммите `bdf81fad`.
**Воспроизведение:**
```
node demo/golden/run.mjs --mode=capture --scenario=geometry-devices-editor-dark
# → different
```
Диагностический diff-кадр (`artifacts/golden/diff/geometry-devices-editor-dark.png`) подсвечивает
розовым ровно область панели устройств справа от «Icon rules» и в подписи вкладки — крупная
геометрия плана снизу идентична существующему baseline. Кроп фактического кадра
(`artifacts/golden/actual/geometry-devices-editor-dark.png`, область x=950-1130,y=108-160,
увеличено ×5) показывает две пустые скруглённые кнопки без единого видимого штриха иконки — рядом
с полностью корректно отрисованной иконкой `mdi:close`.
То же самое видно в уже закоммиченном как часть этого диффа пользовательском скриншоте
`docs/images/06-device-editor.png` (обновлён коммитом `bdf81fad` именно для показа новых кнопок):
кроп той же области подтверждает пустые кнопки без иконок рядом с корректной `mdi:close`. Это
опубликованная картинка из `docs/USER-GUIDE.md`, а не только внутренний тестовый артефакт.
**Причина** (проверено чтением и точечным экспериментом, изменения не закоммичены):
`demo/srv/demo.html` эмулирует `<ha-icon>` через `window.__ICONS[iconName]`, заполняемый
сгенерированным `demo/srv/assets/icons.js` (генератор — `demo/gen_icons.mjs`, сканирует все
`mdi:*` в `src/`+`demo/` и берёт SVG-path из пакета `@mdi/js`). В закоммиченном
`demo/srv/assets/icons.js` **нет** записей для `mdi:undo-variant`/`mdi:redo-variant`
(`grep -c` → 0 совпадений), хотя `@mdi/js` их экспортирует (`mdiUndoVariant`, `mdiRedoVariant`
существуют). Диагностический прогон `node demo/gen_icons.mjs` (не закоммичен, откачен обратно
`git checkout -- demo/srv/assets/icons.js`) подтверждает: после регенерации файл вырастает с 33424
до 42629 байт и обе иконки появляются в карте (`155 из 157` вместо меньшего числа), то есть
единственная причина — устаревший сгенерированный файл, не отсутствие иконки в принципе.
**Это не регрессия конкретно #74 по корню, но именно #74 — первая задача, которая закоммитила
видимое проявление.** Тот же дефект уже присутствует на `origin/dev` до этой ветки: диагностический
`node demo/golden/run.mjs --mode=capture --scenario=geometry-plan-editor-dark` вернул `passed`
(рендер идентичен уже принятому baseline), но кроп самого `demo/golden/baselines/geometry-plan-editor-dark.png`
в области persistent Undo/Redo кнопок Plan-редактора (`src/houseplan-editor-runtime.ts:11737-11745`
на `origin/dev`, тот же `mdi:undo-variant`/`mdi:redo-variant`, тот же паттерн `.btn.ghost`) показывает
точно такие же пустые кнопки без иконок — значит, `icons.js` был неактуален уже на `dev` **до**
этой ветки, и никто это не заметил, потому что до сих пор ни один поддерживаемый в актуальном
`docs/images/*` кадр не показывал Plan-редактор с открытой панелью инструментов крупным планом.
#74 — первая задача, которая (а) добавляет **второе** место с тем же сломанным паттерном и
(б) коммитит **новый** документационный скриншот именно этой области, тем самым превращая
молчаливый локальный гэп в опубликованный дефект.
**Почему Medium, а не High:** реальные пользователи Home Assistant не увидят проблему — их
браузер резолвит `<ha-icon>` через полный набор MDI из фронтенда HA, а не через
`demo/srv/assets/icons.js` (это стенд-заглушка только для демо/тестового харнесса, подтверждено
чтением `demo/srv/demo.html:21-40`). Функционально кнопки работают полностью корректно (все 27
проверок `smoke_device_position_history.mjs`, включая доступные имена и disabled-состояние, зелёные)
— это дефект качества визуального тестового/документационного артефакта, а не поведения продукта.
**Почему в скоупе, а не отдельный issue:** сломанный артефакт — `docs/images/06-device-editor.png`
и golden-сценарий `geometry-devices-editor-dark` — оба созданы/обновлены именно этим коммитом ради
показа новых кнопок из #74; по смыслу задачи AC13 требует «Golden + DOM/a11y assertions» на этот
самый toolbar, и golden-часть доказательства сейчас недостоверна. Возврат на правку: `node
demo/gen_icons.mjs`, зафиксировать обновлённый `demo/srv/assets/icons.js`, пересобрать бандл-стенд,
пересъёмка `docs/images/06-device-editor.png` каноническим `Docs screenshots` workflow +
`npm run docs:accept -- --reviewed`, обновление golden-эталона `geometry-devices-editor-dark` через
`npm run golden:accept -- --reviewed` на полном Linux CI артефакте (PROCESS.md §8, §13). Побочный
эффект — тот же коммит заодно чинит уже существующий на `dev` дефект Plan-редактора; это не
расширение скоупа, а тот же самый generated-файл одной командой, но если автор предпочтёт не трогать
несвязанный `geometry-plan-editor-dark` baseline в этой ветке — это не блокирует #74: обязателен
только фикс, покрывающий заявленный в AC13 сценарий этой задачи.
### Low-1 — AC2 «два устройства в LIFO-порядке» не продемонстрирован реальным перетаскиванием двух устройств подряд
`docs/specs/074-device-position-undo.md:246` требует для AC2 браузерный смок, показывающий exact
before/after «для двух устройств... в LIFO-порядке». `demo/smoke_device_position_history.mjs`
перетаскивает только один `deviceId` повторно (несколько drag одного устройства — эта часть AC2
покрыта, см. `noopKeepsRedo`/повторные `drag()` вызовы); межпространственный `otherDevice` в конце
файла (строки 246-274) не перетаскивается по-настоящему, а получает команду через
`c._devicePositionHistory.push(...)` вручную, в пустой на тот момент стек — ни разу оба устройства
не оказываются в стеке одновременно, чтобы проверить порядок отмены между ними.
**Решение ревьюера:** снимаю как Low без возврата в цикл. LIFO-механика — свойство самого
`CommandStack` (`src/command-stack.ts`, не тронут этим диффом), уже доказано для разнородных
пейлоадов существующим `test/command-stack.test.mjs` («undo and redo preserve names…», строки
14-25: два разных именованных пуша, `undo()` возвращает последний). Каждая `DevicePositionCommand`
самодостаточна (`deviceId`/`spaceId` хранятся внутри команды, не выводятся из внешнего состояния на
момент undo) — проверено чтением `_pointerUp`/`_runDevicePositionHistory`
(`src/houseplan-card.ts:6617-6628`, `:5145-5177`): архитектурно перепутать устройства при
чередующемся undo/redo невозможно, не только «пока тесты не поймали». Автору стоит перед следующим
изменением в этой области добавить реальный двух-device drag-сценарий в смок, но отдельного цикла
это не требует.
### Low-2 — AC13 «доступные имена на поддерживаемых ширинах» проверены на одной ширине/теме
Golden-сценарий `geometry-devices-editor-dark` (`demo/golden/matrix.mjs:381`) — единственный кадр
для нового toolbar, `viewport: 1180×900`, тема `dark`; DOM-проверка
(`demo/smoke_editor_tabs.mjs:158-165`, `out.deviceHistoryControlsArePersistent`) подтверждает
наличие кнопок, доступные имена (`title`/`aria-label`) и исходное disabled-состояние, но не на
нескольких ширинах. AC13 говорит «на поддерживаемых ширинах» во множественном числе.
**Решение ревьюера:** снимаю как Low. Device editor — desktop-first по `docs/TOUCH-SUPPORT.md`,
«поддерживаемые ширины» здесь исторически означают диапазон десктопных окон, а не мобильные
брейкпоинты (сравнимые персистентные кнопки — `barclose` — тоже проверяются на одной ширине в
существующей практике проекта). Кнопки — фиксированного `.btn.ghost` размера, идентичного другим
icon-only элементам панели, реальный риск переполнения на разумных десктопных ширинах низкий.
Не блокирует.
## Что проверено и признано корректным
- **AC1** (один drag — одна команда/запись): `smoke_device_position_history` —
`previewDoesNotPersist`, `oneDragOneWrite`, `oneDragOneCommand` — все `true`; 10 `pointermove`
между down/up не создают запись — проверено и чтением (`_previewDevicePlacement` не трогает
`_dirtyPos`/`_persistLayout`, `src/houseplan-card.ts:5076-5079`).
- **AC2** (exact before/after, LIFO): проверено чтением + существующим `command-stack.test.mjs`
(см. Low-1) + `undoRestoresExactStart`/`redoRestoresExactEnd`/`keyboardUndoWorks`/
`keyboardRedoWorks` в смоке — все `true`.
- **AC3** (auto→manual→Undo=delete, Redo=update): `autoUndoDeletesExplicitPlacement`,
`autoRedoRestoresPlacement` — `true`, включая проверку `writes[...]type === 'delete'/'update'`.
- **AC4** (no-op не создаёт команду/не чистит Redo): `noopKeepsRedo` — `true`; чтением подтверждено
в `_pointerUp` (`src/houseplan-card.ts:6607`): `sameDevicePlacement(after, drag.start)` возвращает
раньше `_persistDevicePlacement`/push.
- **AC5** (cancel/lost capture/Escape/mode switch/disconnect/second pointer → 0 записей): все шесть
сценариев — `escapeAbortsWithoutWrite`, `modeSwitchAbortsWithoutWrite`, `cancelRestoresWithoutWrite`,
`lostCaptureRestoresWithoutWrite`, `secondPointerAbortsWithoutWrite` — `true`; disconnect —
`disconnectedCallback` вызывает `_cancelDeviceDrag()` (`src/houseplan-card.ts:2672`, проверено
чтением, не исполнением — юнит-тест на `disconnectedCallback` в проекте отсутствует, что ожидаемо
для lifecycle-хука). Мутационный тест (`device-position-cancel-routed-to-commit`) независимо
доказал, что `smoke_device_position_history.mjs` **обязан упасть**, если pointercancel направить
в commit-путь — «тест умеет падать» подтверждено экспериментом, не предположением.
- **AC6** (Undo во время drag только abort): `undoDuringDragOnlyAborts` — `true`; чтением —
`_runDevicePositionHistory` начинается с `if (this._cancelDeviceDrag() || ...) return;`
(`src/houseplan-card.ts:5145`).
- **AC7** (persist failure → rollback + направление stack): `failedDragRollsBack`,
`failedUndoRestoresStackDirection` — `true`. Чтением подтверждена тонкость восстановления
направления: на неудачном Undo вызывается `.redo()` (а не повторный `.undo()`), что верно снимает
побочный эффект предварительного `.undo()` со стека (`src/houseplan-card.ts:5162-5165`) — без
прогона смока эта инверсия легко читается неверно, поэтому проверена и по коду, и по фактическому
результату `canUndo/canRedo`.
- **AC8** (`k: 0`/unknown-поля/room-label): `test/device-position-history.test.mjs` — 4 юнит-теста,
включая явную проверку иммутабельности входа (`assert.notEqual`) и сохранения `k: 0` (falsy,
специально проверяется отдельно от truthy `k`) — тест умеет падать: `deepEqual` откажет при потере
поля, `notEqual` откажет при мутации на месте. `rl_*` использует отдельный `_savePos`-путь через
`_labelMove` (`src/houseplan-editor-runtime.ts:10741-10759`) — не пересекается с
`_devicePositionHistory`, проверено чтением (единственный вызывающий `_savePos` теперь — только
label-путь, устройства используют новый `_devicePlacementForCanvas`/`applyDevicePlacement`).
- **AC9** (кнопки/shortcuts только в Device editor, native field не перехватывается):
`nativeInputHistoryNotIntercepted`, `qwertzCtrlZIsUndo`/`qwertzCtrlYIsRedo` (существующие в
`smoke_editor_tabs`), `deviceHistoryControlsArePersistent` — все `true`; чтением подтверждено, что
`_mode === 'devices'` ветка в `_onKey` — новая и не существовала на `dev` (сравнение с
`origin/dev:src/houseplan-card.ts` — раньше devices проваливался в общий `if (!this._markup)
return`), регресс в decor/plan-ветки исключён (они выше по `if` и не задеты диффом).
- **AC10** (own echo/reconnect сохраняют, remote content чистит): `sameContentReloadKeepsHistory`,
`remoteContentClearsHistory` — `true`; чтением — `_reloadLayoutOnly` теперь сравнивает
`contentFingerprint` **всегда** против текущего `this._layout` (убран фолбэк на потенциально
устаревший кэш, `src/houseplan-card.ts:4801-4809`) — усиление, не ослабление контракта.
- **AC11** (кросс-space команда переключает пространство): `otherSpaceUndoIsVisible` — `true`.
- **AC12** (deleted/rebound/disabled device — fail-closed): `deletedDeviceCommandFailsClosed`,
`reboundDeviceCommandFailsClosed`, `disabledDeviceCommandFailsClosed` — все `true`;
`_devicePositionStateValid` проверяет существование, `space`, `ha_disabled` и наличие
space-модели перед каждым undo/redo (`src/houseplan-card.ts:5136-5142`).
- **AC14** (geometry/decor history, pan/zoom, layout sync не регрессируют): целевые
`smoke_pan_any_zoom`, `smoke_grid_snap`, `smoke_editor_tabs`, `smoke_layout_sync` — зелёные;
`_geometryHistory`/decor-стеки не входят в diff (`git diff --stat` не содержит изменений в
`command-stack.ts`, а `_geometryHistory` в `houseplan-card.ts` не тронут за пределами добавления
соседнего поля) — проверено чтением, не исполнением полного regression-набора (несоразмерно
задаче, см. §8).
- **Трейлеры и changelog:** `bdf81fad` (`feat: add device position undo history`) несёт
`Issue: #74` / `User-Visible: yes` и в одном коммите содержит `docs/CHANGELOG.md` +
`docs/CHANGELOG.ru.md` (по 6 строк, со ссылкой на #74) — оба сразу, как требует правило. Второй
коммит `fc63f3bf` — `User-Visible: no`, трогает только два demo-смока, корректно.
`process-gate.mjs --issues` подтверждает диапазон формально чистым.
- **Backend/схема:** не затронуты — `git diff --stat` не содержит `custom_components/**/*.py`;
`houseplan/layout/update`/`delete` вызываются с той же сигнатурой, что и раньше (третий
сайт-вызова в `_persistDevicePlacement`, инвариант `coordinate-write-barrier-guard.mjs` обновлён
на 3 и проходит).
- **Один источник числа:** новый функционал не вводит ни одной новой пользовательски видимой
величины (позиция маркера не отображается как число где-либо ещё) — правило неприменимо,
`test/single-source-numbers.test.mjs` в общем прогоне `npm test` зелёный без изменений.
- **i18n:** `history.device_move`/`history.device_stale` добавлены во все 4 языка
(`en/ru/de/fr`), плейсхолдер `{name}` согласован с существующим паттерном
`history.undo_named`/`redo_named`.
## Чего не проверял
- **Полный browser-smoke набор** (все ~207 сценариев) и **полный HA backend harness** — не
прогонялись: несоразмерно диффу (PROCESS.md §8), backend не тронут вовсе.
- **`golden:verify` полным набором** — недоступен частично по дизайну (`assertGoldenInvocation`
в `demo/golden/policy.mjs` отказывает при `--scenario` для verify, только `--mode=capture`
разрешает диагностику одного сценария); полный прогон — предрелizный гейт (см. Medium-1, где
диагностика `--mode=capture` на два сценария уже вскрыла дефект без полного прогона).
- **`performance_smoke`** — не прогонялся; в AC не назван явно, спец §12 отмечает влияние на
perf как «объективно снижает нагрузку», что не оспаривается кодом (persist больше не идёт на
каждый `pointermove`, только `_previewDevicePlacement`/локальный layout).
- **Прогон полного `mutation-gate.mjs`** (все ~сотни мутантов) — прогнаны точечно только два новых
мутанта, относящихся к диффу (`--id=`); полный прогон — тяжёлый гейт вне рамок код-ревью этого
масштаба.
- **Ручное тестирование в браузере** (реальный HA, реальный `<ha-icon>`) — не выполнялось; вывод
про «в проде иконки отрисуются нормально» — по чтению кода демо-заглушки, не по факту в реальном
Home Assistant.
- **Реальный факт прогона канонического `Docs screenshots` workflow** для обновлённых
`docs/images/*.png` — не проверялся (нет ссылки на прогон в трейлерах, `check-docs.mjs` сверяет
отпечаток источника, а не байтовую строгую принадлежность конкретному CI-раннеру); дефект
Medium-1 воспроизводится независимо от того, каким Chromium снят кадр, поскольку причина —
отсутствующие данные в `icons.js`, а не отличие рендерера.
## Итог
Функциональная часть задачи выполнена основательно: все 14 AC доказаны либо зелёным,
демонстрируемо-падающим тестом (включая два прогнанных вживую мутационных теста,
подтвердивших, что `smoke_device_position_history.mjs` и regression-guard действительно ловят
регресс, а не просто существуют), либо разобраны по коду с точным указанием строк. Транзакционная
модель drag/preview/commit/abort, fail-closed инвалидация истории, кросс-пространственный undo и
сериализация записи выполнены точно по контракту ТЗ, вплоть до тонких деталей вроде направления
восстановления стека при неудачном Undo.
Единственная блокирующая находка — не в логике фичи, а в её собственном визуальном доказательстве:
опубликованный в этом же коммите скриншот `docs/images/06-device-editor.png` и golden-эталон
`geometry-devices-editor-dark`, оба созданные ради показа новых кнопок Undo/Redo, показывают их
пустыми из-за неактуального `demo/srv/assets/icons.js`. Фикс мелкий и механический
(`node demo/gen_icons.mjs` + пересъёмка/переприёмка двух артефактов), но обязателен в этой же
задаче, поскольку AC13 прямо ссылается на golden как доказательство, а опубликованная
документация не должна показывать несуществующие в кадре кнопки той самой фичи, которую иллюстрирует.
**Вердикт: жёлтый.** Возврат автору на устранение Medium-1; High не найдено, задача не пятый заход
и бюджет циклов не исчерпан.