docs: review document for #74

Issue: #74
User-Visible: no
This commit is contained in:
claude[bot]
2026-08-30 13:38:16 +00:00
parent a6b0850a0c
commit 4b5c9f2382
+319
View File
@@ -0,0 +1,319 @@
# 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 не найдено, задача не пятый заход
и бюджет циклов не исчерпан.