# 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` эмулирует `` через `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 не увидят проблему — их браузер резолвит `` через полный набор 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, реальный ``) — не выполнялось; вывод про «в проде иконки отрисуются нормально» — по чтению кода демо-заглушки, не по факту в реальном 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 не найдено, задача не пятый заход и бюджет циклов не исчерпан.