# Manual testing checklist ## Правила для новых тестов (issue #85) — обязательны Зелёный тест в этом проекте несколько раз означал «ничего не проверено»: смок непрерывности не заметил удаления механизма, который защищает; golden-сцена, заведённая под #71, была пустой; смок теней был зелёным, пока тени физически не рисовались. Общее у всех случаев — **тест ни разу не проверяли на способность падать**. Отсюда правила. 1. **Наличие атрибута, класса или узла — не проверка поведения.** Такой ассерт допустим только рядом с пиксельным либо поведенческим: атрибут доказывает, что код выполнился, а не что механизм сработал. 2. **Golden-сцена, заведённая под конкретную задачу, несёт семантический ассерт** (`warmPixelRegion` и родственные в `demo/golden/run.mjs`): сцена обязана падать, если перестала показывать то, ради чего заведена. Пиксельный дифф с эталоном этого не заменяет — пустая сцена совпадает со своим пустым эталоном идеально. 3. **Фикстура содержит слои, которые тест защищает.** Смок непрерывности без подложки, Glow и декора проверяет пустую страницу; солнце с азимутом, при котором луч не достигает единственного окна (#89), — та же ошибка в геометрии. 4. **Тест, охраняющий механизм, сопровождается мутантом** в `scripts/mutation-gate.mjs`: 2–5 строк патча, воспроизводящего поломку, против которой тест заведён, и тест обязан на ней краснеть. Чистым функциям с обычными юнитами мутант не нужен. 5. **Тавтологический ассерт — читающий то же свойство, которое код только что выставил, — не пишется вовсе.** Он может упасть только при удалении строки, но не при её неработоспособности. Проверка: `node scripts/mutation-gate.mjs --check` — якоря патчей живы; полный прогон — workflow `mutation-gate.yml` (четыре чересполосных шарда, `--shard=i/4`) каждую ночь по расписанию (01:00 UTC); в цикле разработки и в релизном гейте он не участвует — проверяет тесты, а не продукт; отказ сам заводит issue с отчётом (#472, #513). Дешёвая половина идёт с юнитами: `test/mutation-gate.test.mjs`. Локально для дельты задачи — `node scripts/mutation-gate.mjs --changed origin/dev..HEAD`: гоняются только мутанты, чьи patch-файлы или **входы гарда** задеты диффом (#332, #475, #492). Входы гарда — это не только файлы, названные в команде: обёртка (`scripts/*-guard.mjs`) объявляет запускаемые тесты в `export const GUARD_INPUTS` (умолчание `backend-test-guard.mjs` — `tests_backend/test_ha_import_export.py`, действует без третьего аргумента), а от каждого файла гарда берётся замыкание импортов и путей: смок тянет `demo/serve.mjs`, compat-хелперы и фикстуры, pytest-модуль — `conftest.py`. `src/**` в замыкание не входит — это сторона патча (§6.4 ТЗ #492). Дифф, трогающий сам реестр, дополнительно отбирает добавленные и изменённые определения относительно реестра базы (`git show :scripts/mutation-gate.mjs`). Бандл собирается только мутантам с браузерным гвардом; компиляция тестов в worktree стартует с тёплого `test-build/` основного дерева. В CI `changed_mutants` бежит не на каждом пуше, а по запросу (#510): `workflow_dispatch validate.yml -f mutants=true` (его делают ревью-конвейер на материале ревью и слияние на кандидате), `full=true` (ночь, кнопка), PR и кандидат беты (трейлер `Release:`). Обычный push в ветку задачи обходится дешёвыми гейтами: за 08–09.09 мутанты на промежуточных пушах стоили 48 из 56 часов job-минут и в основном отменялись следующим пушем. Доказательство мутантов для ревью — именно dispatch-прогон на точном SHA; зелёный push-прогон им не является. `changed_mutants` добавляет `--ledger=<файл>` — журнал пойманных свидетелей (#481): после каждого пойманного мутанта в файл пишется отпечаток его входов (файлы патча, все входы гарда по замыканию выше, объявление мутанта; строка версии продукта нормализована), и мутант с тем же отпечатком в следующем прогоне не гоняется. Журнал живёт в кэше Actions по шарду, сохраняется при любом исходе шага, так что отменённый пуш или таймаут не пропадают даром. Полный прогон и `--id` журнал не читают; `--ledger` без `--changed` — ошибка. ## E2E на реальном Home Assistant (#514) Репозиторий `Matysh/houseplan-e2e`: настоящий HA в docker, House Plan из `houseplan.zip` релиза, 13 сценариев Playwright (боковая панель, дашборды, роли, телефон, PDF, рестарт HA, первый запуск, обновление). Ночью — по расписанию на последней бете; для **стабильного** релиза `release.yml` запускает его на теге и ждёт зелёного (`scripts/e2e-gate.mjs`): красный — ассеты не публикуются. В цикле разработки и на бетах не участвует. ## Версия в кадрах и попиксельная приёмка (#512) Golden-кадры и скриншоты документации не должны меняться от bump версии. Для golden отображаемая версия идёт через seam `displayVersion()` (`src/card-version.ts`, глобальная `__HP_VERSION_OVERRIDE__` — только для харнесов; продукт её не задаёт), и харнес фиксирует `0.0.0-golden`. Для docs-скриншотов есть локальная приёмка `npm run docs:accept -- --identical`: кадры сравниваются по декодированным пикселям, и при полном совпадении обновляется только отпечаток исходников. ## Manifest входов: какие job запускать и что хешировать (#492) Один модуль, `scripts/check-inputs.mjs`, объявляет каждую проверку Validate (`preflight`, `frontend`, `changed_mutants`, `integration`, `smoke`, `golden`, `performance_smoke`, `backend`) через корни и точки входа, а остальное вычисляет: от точек входа берётся замыкание — импорты транзитивно, строковые пути как листья, каталог по строке — все текстовые файлы под ним. Из этого manifest читают и `classify-changes.mjs` (job `changes`: job запускается, если дифф задел хотя бы один её вход), и `gate-reuse.mjs` (ключ реюза = хеш содержимого всех входов job). Два места не могут разойтись: до #492 у бэкенда ключ не знал relay, converter и schema, а golden/perf — `serve.mjs`, `demo.html` и compat-хелперов. Правила, которые стоит знать: - `validate.yml` — вход toolchain каждой job: правка workflow гоняет всё; - `src/**` — вход только браузерных job; backend от UI не зависит, а `custom_components/houseplan/manifest.json` (версия) держит правило «кандидат релиза прогоняет всё» и для него; - **неизвестный исполняемый вход** — файл под `scripts/`, `demo/`, `test/`, `tests_backend/`, `.github/`, `custom_components/`, `src/`, которого нет в manifest ни одной проверки, — расширяет прогон до полного набора и называется в summary. Лист покрытия (`node scripts/check-inputs.mjs --coverage`, `test/check-inputs.test.mjs`) требует, чтобы каждый такой файл был чьим-то входом либо стоял в `NOT_AN_INPUT` с причиной: новый скрипт без записи — красный юнит, не вечное расширение прогонов; - `--check=` печатает входы, `--why=<файл>` — цепочку, по которой файл стал входом. Отрицательные пробы (`test/gate-reuse.test.mjs`, `test/classify-changes.test.mjs`, `test/check-inputs.test.mjs`) держат представителей каждой категории входов и обратную пробу для UI ↔ backend; мутанты `manifest-drops-workflow-input`, `classify-unknown-input-is-unaffected`, `reuse-backend-hashes-ui`, `guard-inputs-ignore-wrapper-defaults`, `registry-diff-not-selected`, `merge-pushes-unvalidated-candidate`, `merge-ignores-lease-rejection`, `nightly-does-not-wait` держат сам протокол. Чистые Python-контракты канонизации, которым не нужен Home Assistant, находятся в `tests_backend/test_coordinate_canonicalization_pure.py`. Они обязаны реально исполняться в локальном `pytest tests_backend`, а не исчезать за module-level `importorskip`; schema/store/virtual-light проверки остаются в HA-зависимом `test_coordinate_canonicalization.py`. Поведение static-card capability доказывается unit/smoke-счётчиками и состоянием runtime, а не regex по исходнику (#440). ## PDF export polish (#482) - [ ] Нормализация контура сначала схлопывает соседние и шовные дубли в физическом допуске 1 мм и только затем удаляет коллинеарные точки; ложная диагональная хорда из почти совпавших вершин не появляется [unit: `test/pdf-dimensions.test.mjs`]. - [ ] Размеры выводятся только для горизонтальных/вертикальных граней с каноническим допуском 0,25°. Одна локальная пара противоположных граней комнаты или связного наружного кольца получает одну подпись; одинаковые длины в разных комнатах, осях и несвязных кольцах сохраняются [unit: `test/pdf-dimensions.test.mjs`, `test/pdf-scene.test.mjs`]. - [ ] Подписи центрированы на своей стене и сдвигаются от кладки целыми размерными дорожками без касательного джиттера; вогнутые комнаты выбирают внутреннюю нормаль по геометрии, а не по центроиду. Bbox текста и все сегменты полосы проверяются точными пересечениями, включая отверстия, вырожденные отрезки и разные направления ring [unit: `test/pdf-collision.test.mjs`, `test/pdf-scene.test.mjs`, golden: PDF scene]. - [ ] Стены, перегородки и колонны имеют точную заливку `#7f7f7f`, общую привязанную к странице штриховку 45° с шагом 3 мм и чистые even-odd вырезы всех проёмов; стены нулевой толщины не получают штриховку [unit: `test/pdf-writer.test.mjs`, `test/pdf-scene.test.mjs`, golden]. - [ ] Ориентация и первый подходящий стандартный масштаб выбираются по bbox полной сцены вместе с размерами, выносками, декором и растром. Вся сцена центрирована в доступном поле листа с допуском 0,5 мм; тяжёлая геометрия пространства вычисляется один раз [unit: `test/pdf-layout.test.mjs`, `test/pdf-scene.test.mjs`]. - [ ] Векторный компас правильно поворачивается для 0/90/180/270°, а текст PDF не содержит удалённой легенды `wall · door · window` [unit: `test/pdf-compass.test.mjs`, `test/pdf-scene.test.mjs`]. - [ ] Реальный диалог при ширине View 320 px не имеет горизонтального scroll, действия остаются внутри окна, имеют высоту не менее 44 px и на узком экране идут вертикально; сохранение PDF работает во всех четырёх локалях [auto: `demo/smoke_pdf_export.mjs`]. - [ ] Каждый поведенческий барьер выше защищён соответствующим мутантом, а обновлённый PDF golden принят только после визуальной проверки Linux CI [mutation: `scripts/mutation-gate.mjs`; golden: `demo/golden/`]. ## Многоэтажный робот: карты и пространства (#162) - [ ] Чистый резолвер разбирает все шесть исходов на общей фикстуре и не зависит от порядка списка маршрутов; усыновление легаси-прогона даёт ровно три исхода, оба отрицательных — fail-closed [unit: `test/vacuum-routes.test.mjs`, `tests_backend/test_vacuum_routes.py`, фикстуры `test/fixtures/vacuum-routes/*.json`]. - [ ] Живой оверлей рисуется в пространстве активного маршрута, док остаётся в своём; прошлый прогон виден в пространстве СВОЕГО маршрута, а легаси-конфиг сохраняет прежнее правило [unit: `test/vacuum-routes.test.mjs`]. - [ ] Рекордер подписывается на источники всех маршрутов и пишет прогон под маршрутом, который его породил; смена маршрута начинает новый прогон даже при том же `map_id` [backend: `tests_backend/test_trail_recorder.py`, `test_trails.py`]. - [ ] Правка маршрутов проверяется семантически на `config/set`, optimize и обоих импортах, нетронутые легаси/будущие данные не блокируют чужое сохранение [backend: `tests_backend/test_vacuum_route_validation.py`]. - [ ] Удаление пространства называет число чужих карт и уносит только их маршруты; экспорт одного пространства отбрасывает кросс-пространственные маршруты и считает их в `dropped_marker_links` [unit: `test/space-deletion.test.mjs`, backend: `tests_backend/test_ha_import_export.py`]. - [ ] Восемь мутантов краснеют: `vacuum-route-ambiguity-takes-the-first`, `vacuum-route-missing-space-falls-back-to-dock`, `vacuum-route-unmapped-draws-anyway`, `vacuum-route-identity-duplicates-allowed`, `vacuum-legacy-run-adopts-the-first-candidate`, `vacuum-overlay-ignores-the-rendered-space`, `vacuum-previous-run-follows-the-robot`, `vacuum-route-warning-stays-silent`, плюс серверные `vacuum-run-forgets-its-route`, `vacuum-retargeted-route-keeps-its-old-trails`, `vacuum-route-validation-accepts-a-dead-space` и `space-delete-keeps-foreign-vacuum-routes` [mutation: `scripts/mutation-gate.mjs`]. - [ ] Продакшен-бандл показывает робота на этаже активной карты, а на этаже дока не показывает; предупреждение у дока появляется на движущемся роботе с несопоставленной картой; донастройка предложения с высоким residual открывается на этаже маршрута, а не дока [auto: `demo/smoke_vacuum_multifloor.mjs`]. - [ ] Отдельная camera на карту: источник без читаемого id карты маршрута не создаёт и объясняет причину [ручная проверка блока «Карты и этажи»]. ## Vacuum trail smoothing (#209) - [ ] Pure `smoothVacPath` tests prove exact endpoints, separate subpaths, degenerate/reversal fallbacks, the 17.5 cm sampled deviation bound and the 64-subpath/4000-point `≤2N` command budget [unit: `test/vacuum.test.mjs`]. - [ ] The production bundle renders current and previous runs through paired case/core `` elements with quadratic commands, unchanged source authority/modes, live-target trim and puck tip [auto: `demo/smoke_vacuum.mjs`]. - [ ] Flat and dormant-Iso projections retain the same curved live layer on touch and across HA updates [auto: `demo/smoke_isometric_live_touch.mjs`]. - [ ] `vacuum-trail-smoothing-dark` records the 17.5 cm default in fixture data, contains two current subpaths plus a stored previous run, and its semantic guard requires two `M` sections, quadratic commands and byte-identical case/core geometry before PNG comparison. The baseline is accepted only from reviewed Linux CI [golden: `demo/golden/matrix.mjs`]. - [ ] Replacing the quadratic corner with a straight vertex makes the focused unit guard red [mutation: `vacuum-trail-smoothing-disabled`]. ## Stable wall-segment identity (#282) - [ ] Wall junction limits (#329): drawing refuses an apex under 15°, a seventh wall in one node, a wall shorter than 20 cm or than its own thickness, nodes closer than 5 cm and a room with under 25 cm² of interior, each through the surface's own channel — a toast naming the rule for drawing and Thickness, a stopped wall for Resize. A T-joint stays legal, a short filler atom compensating a thickness step stays legal (length is measured along the collinear same-thickness wall run), and an inherited violation never blocks an unrelated edit [unit: junction-limits; auto: smoke_junction_limits, smoke_island_rooms; mutants: junction-limit-angle-not-enforced, junction-limit-write-gate-removed, degenerate-apex-bevelled-again]. - [ ] A degenerate sharp apex renders as ONE point on both faces — no flat chamfer, no bow-tie fold, no jags between the inner and outer vertex; the room ring of the #329 fixture triangle has exactly three distinct vertices [unit: junction-limits §4]. - [ ] Shared fixture `test/fixtures/282-wall-identity-parity.json` produces the same exact v8 catalog, room references, opening host and draft IDs in TypeScript and Python. - [ ] Initial v7 migration is deterministic and idempotent; new post-v8 atoms use UUIDs. A split/promoted draft keeps one documented carrier ID, while reserved/colliding deterministic IDs receive stable `-2`, `-3` suffixes. - [ ] The three structural writer families — interactive commit, Undo/Redo restore and Optimize — are enumerated by the source guard and each has an independent bypass mutant in `scripts/mutation-gate.mjs`. A rejected migration changes neither config, Undo history nor revision. - [ ] Full/space imports cover v7→v7 (no upgrade), v7→v8 and v8→v8; copy/merge remaps every ID and reference together. A byte-equivalent legacy-client round-trip of v8 is accepted, while a structural legacy change is rejected. - [ ] Resize, Split/Merge, opening edit and Optimize browser smokes retain wall thickness and ownership across reload. Performance gate: `npm run benchmark:wall-model` materialises 10,000 atoms with p95 below 500 ms on the reference Windows machine. - [ ] Local commands: `npm test`, `npm run typecheck`, `npm run benchmark:wall-model`; backend parity/schema tests run through `tests_backend/test_wall_segment_model.py` and `tests_backend/test_validation.py`. HA import/export coverage runs in the normal Linux/CI Home Assistant harness when unavailable natively. ## Legacy draft migration and atomic wall-chain writes (#314, #478) - [ ] `demo/smoke_v8_draft_write.mjs` (compatibility filename) proves that a model-v9 draft migrates once to ordinary partitions, preserves existing edge IDs/thicknesses and leaves no `room_drafts` in model v10. - [ ] The same fake-WS smoke proves each accepted current wall reaches storage, a later edge preserves earlier identities, Undo removes only the terminal wall, and a closed chain creates a durable room without carrier debris. - [ ] A rejected in-flight physical write synchronously rolls back its whole pending batch: active path, session partition IDs, pending map and command history are empty, so no optimistic ghost wall survives. - [ ] Frontend/backend model tests consume the same `478-room-draft-migration-vectors.json`: missing/null IDs, colliding IDs, numeric strings, `null`/boolean thickness and epsilon-length edges must produce the same exact partitions or rejection reason in both runtimes. Backend coverage also rejects a stale v9 `room_drafts` write over v10. - [ ] `demo/smoke_unified_wall_tool.mjs` accepts a room over a partially coincident older partition, preserves only its outside tail and an unrelated partition, then proves an immediate Optimize reports zero reconciliation and no config change. A forced missing post-mutation wall model restores the whole room transaction. - [ ] Local commands: `npm test`, `npm run bundle:sync`, `node demo/smoke_v8_draft_write.mjs`, targeted backend pytest and `node scripts/check-docs.mjs --external`. - [ ] Mutation `current-rejected-physical-write-keeps-optimistic-wall` proves the browser rollback scenario fails when rejection recovery is bypassed. - [ ] Mutation `room-accept-leaves-coincident-partitions` proves the real editor smoke fails if accepted room carriers stop being consumed atomically. ## Current-writer fixed point (#477) - [ ] `test/writer-fixed-point.test.mjs` proves seed-bounded repeated collinear merge, safe positive coincident reconciliation/opening rehost, identical fail-closed cases, exact room-reference rewrite/restore and the executable writer-owner manifest. - [ ] `demo/smoke_writer_fixed_point.mjs` finishes a real straight Walls chain through the production bundle, then proves the immediate, post-Undo and post-Redo Optimize previews are no-ops. The same smoke deletes a room and restores/reapplies direct and cross-space vacuum references without overwriting unrelated marker fields. - [ ] `demo/benchmark_wall_draw_click.mjs` keeps the #461 terminal-click budgets and structural counters, then separately proves finish uses one bounded physical/junction transaction, one write, no extra history and sublinear scaling when unrelated rooms are added. - [ ] `test/align-grid.test.mjs` proves Optimize leaves complete furniture/image transforms byte-equivalent while an ordinary decor rectangle remains grid-bound. - [ ] The `writer-*` mutations in `scripts/mutation-gate.mjs` remove one finish owner, pre-adoption safety, history normalization, direct/vacuum reference rewrite, free-transform exclusion, terminal-click separation, seed merge and seed reconciliation; each named witness must turn red. ## Resize: реальный pointer pipeline (#293) - [ ] `demo/smoke_resize_pointer_real_plan.mjs` загружает tracked fixture второго этажа обычным `houseplan/config/get`, включает Resize кнопкой и двигает доступную общую стену только реальными `page.mouse` событиями. Прямые вызовы приватных resize-методов в этом smoke запрещены source guard-юнитом. - [ ] На десятом шаге сетки обе комнаты имеют видимый preview, а server config ещё байт-в-байт исходный. Pointerup создаёт одну history-команду и одну запись; wall count и набор толщин сохраняются; Ctrl+Z возвращает исходную геометрию. - [ ] Pointer capture продолжает жест минимум в двух диаметрах и 120 px от хэндла, чужой pointer id игнорируется, а Escape и `lostpointercapture` возвращают DOM и config без дополнительной записи. - [ ] Невозможная физическая preview-геометрия останавливает стену на последней безопасной позиции и показывает один локализованный toast за жест. Отдельно проверяется отказ финального preflight без commit/history. - [ ] Мутанты `resize-pointer-delta-zeroed`, `resize-shared-seam-not-coalesced`, `resize-pointer-capture-removed`, `resize-preview-reject-silent` и `safe-resize-commit-preflight-bypassed` обязаны красить соответствующие unit/production smoke guards. - [ ] Fixed-topology wall records (#298): moving-wall breakpoints translate rigidly, side-wall interior endpoints never scale proportionally, the exact first-floor 49→52 gesture ends on 17/52/57/101, unrelated records remain byte-equivalent, and a full-span carrier/lattice proof rejects gaps before preview. Key-only legacy records move only by one whole-edge identity; partial midpoint ambiguity produces no preview, history or config write [unit: `wall-thickness.test.mjs`; auto: `smoke_resize_pointer_real_plan`, `smoke_resize_wall_thickness`, six `smoke_edit_walk` runs; mutation: `safe-resize-wall-endpoints-affine-scaled`, `safe-resize-legacy-midpoint-fail-open`]. ## Decor composition order (#231) - [ ] All six decor kinds render in one `.decorlayer` after opaque room/data fill, active room-hover fill, opening tunnels and Glow-base rooms/tunnels, but before live Glow, sun, physical walls, opening symbols and the HTML device/room-label layer [auto: `smoke_decor_layer_order.mjs`, `smoke_glow.mjs`]. - [ ] Pixel probes through an opaque room and a filled opening tunnel stay the decor colour before/after hover and over Glow base. Restoring the old DOM order makes those probes red [auto: `smoke_decor_layer_order.mjs`; mutation: `decor-restored-below-room-fills`]. - [ ] The complete #231 golden impact set is reviewed before baseline acceptance. The two dedicated Light/opaque-hover and Dark/Glow-base scenes contain all five decor types and semantic probes in both rooms and the shared doorway. The three existing large-house scenes also change because their dense decor grid now renders above Glow-base room fills. Reviewed baselines are accepted only from the Linux release artifact [golden: `decor-over-opaque-hover-light`, `decor-over-glow-base-dark`, `isometric-large-warm-remount-dark`, `large-house-zoom-250-dark`, `large-house-warm-remount-dark`]. - [ ] `hide_decor`, the Background editor override and stored config remain unchanged; no per-object under-plan compatibility flag is introduced. ## Custom decor images (#51) - [ ] Background has one **Image** button. PNG/JPEG/WebP/safe SVG upload opens the reusable palette; picking a file shows an exact one-shot preview and one click creates a 100 cm wide aspect-preserving object (height capped at 200 cm), then returns to Select [unit: `decor-assets.test.mjs`; auto: `smoke_decor_images.mjs`]. - [ ] Image move/continuous resize/four side handles/crossing mirrors/free rotation/`Shift` 45° match furniture, while placement and movement have no wall magnet. The complete rotated rectangle remains selectable through transparent pixels [auto: `smoke_decor_images.mjs`]. - [ ] Full View and `houseplan-space-card` paint the same signed raster/SVG under live Glow/walls/devices and obey `hide_decor`. A missing or hash-mismatched file paints nothing in View and a selectable crossed repair placeholder only in Background [unit: `decor-assets.test.mjs`, `test_decor_assets.py`; auto: `smoke_decor_images.mjs`]. - [ ] Upload rejects wrong magic, corrupt decode, oversized dimensions/files, forbidden namespaces/elements/attributes/URLs, DTD/entities, processing instructions and local-reference cycles. Responses have exact MIME, `nosniff` and SVG sandbox CSP [pure backend: `test_decor_assets.py`; HA harness: `test_ha_import_export.py` and endpoint tests]. - [ ] Identical canonical bytes reuse one SHA-256 id. Resolve is deduplicated and batched at 200; deletion rechecks all spaces and refuses an in-use file. Export v2 has hashes but no bytes/signed URLs; v1 remains readable, and confirmed missing imports preserve image geometry [unit/pure/HA: `decor-assets.test.mjs`, `test_decor_assets.py`, `test_ha_import_export.py`]. ## Opening symbol centreline (#242, #250) - [ ] Unit and browser checks prove that door/window/gate stay on the wall centreline for both `flip_v` values, door/window flips change only their direction, and gate `flip_v` reverses the first-leaf 10° turn on shared room walls, independent partitions and hidden Iso without translating the gate [unit: `opening-symbol.test.mjs`, `iso-openings.test.mjs`; auto: `smoke_wall_thickness.mjs`, `smoke_isometric_contract.mjs`; mutations: `opening-symbol-flip-restores-edge-offset`, `opening-gate-flip-cancels-turn`]. - [ ] Matrix v37 adds four dedicated semantic scenes. Before PNG comparison they assert the saved flip value, wall centreline, visible-group offset, full jamb depth, window glass membership and opposite gate turn signs: `opening-symbol-room-wall-light`, `opening-symbol-diagonal-partition-dark`, `opening-symbol-flip-pairs-light`, `isometric-opening-symbol-parity-dark`. - [ ] #250 reuses those four scenes and requires `offset: center` for every door/window/gate entry, including flipped pairs. The semantic guard must fail before PNG comparison if any saved flip restores a wall-face offset. - [ ] The exact existing golden impact set below contains **67** scenes. It was measured by comparing `actualSha256` for HEAD and `origin/dev` under the same Chromium build; baseline status alone is not used because `dev` already has unrelated pending pre-release candidates. Every listed frame uses a shared fixture containing an affected opening or retains that plan behind an editor/dialog. No other existing frame changed: `isometric-geometry-view-dark`, `isometric-geometry-view-light`, `isometric-live-layers-dark`, `isometric-no-borders-dark`, `isometric-touch-kiosk-dark`, `isometric-large-warm-remount-dark`, `geometry-view-dark-fit`, `geometry-view-light-fit`, `room-label-parity-view-dark`, `room-label-parity-plan-dark`, `room-label-parity-view-light`, `room-label-parity-plan-light`, `day-cycle-dawn-dark`, `day-cycle-day-dark`, `day-cycle-dusk-dark`, `day-cycle-night-dark`, `geometry-plan-editor-dark`, `space-tab-drop-before-light`, `space-tab-drop-after-dark`, `plan-snap-endpoint-light`, `plan-snap-line-gaps-dark`, `junction-patch-resilience-plan-dark`, `opening-placement-door-thick-wall-dark`, `opening-placement-passage-thick-wall-dark`, `opening-placement-passage-thick-wall-light`, `geometry-devices-editor-dark`, `geometry-decor-editor-dark`, `tray-wide-selection-en`, `tray-wide-tool-ru`, `tray-medium-group-en`, `tray-medium-selection-ru`, `tray-narrow-palette-en`, `tray-narrow-tool-ru`, `geometry-diagonal-45-opening-dark`, `openings-thick-wall-dark`, `lighting-glow-sun-dark`, `device-value-badge-positions-dark`, `device-icon-state-table-light`, `device-icon-state-table-dark`, `device-text-shell-long-light`, `device-text-shell-long-dark`, `lighting-sun-window-state-only-dark`, `lighting-fill-light-axis-split-dark`, `lighting-fill-temp-axis-split-dark`, `lighting-fill-lqi-axis-split-dark`, `lighting-temp-glow-dark`, `lighting-temp-glow-light`, `lighting-custom-glow-dark`, `lighting-opaque-glow-two-doorways-dark`, `lighting-custom-glow-light`, `lighting-temp-glow-no-sources-dark`, `lighting-temp-glow-room-override-dark`, `lighting-manual-auto-spill-overlap-dark`, `hover-over-glow-dark`, `hover-nested-room-dark`, `large-house-zoom-040-dark`, `large-house-zoom-250-dark`, `large-house-warm-remount-dark`, `device-dialog-desktop-en`, `device-help-popover-light-ru`, `decor-color-popover-desktop-en`, `general-color-popover-desktop-en`, `space-room-color-popover-desktop-ru`, `backup-full-preview-desktop-en`, `backup-plan-only-export-desktop-en`, `optimize-preflight-dialog-dark-en`, `optimize-preflight-dialog-light-ru`. - [ ] Baselines for the 67 existing and four dedicated scenes are accepted only from the reviewed full Linux pre-beta artifact. Local `golden:accept` remains forbidden. ## Device icon design package (#179) - [ ] Pure presentation tests cover lock/unlock, exact marker-only LQI bands `0/40/41/179/180`, unchanged room gradient, package pulse defaults, semantic colors and reduced motion [unit: `device-presentation.test.mjs`, `device-pulse.test.mjs`]. - [ ] Shared face tests cover shell/core DOM, four Double positions, a third legacy section, deterministic font fitting, full text and safe CSS color variables [unit: `device-face.test.mjs`]. - [ ] The browser renders the exact shell ratio and shadow color, Light/Dark cores without backdrop blur, state/LQI colors, 3.6 s presence pulse, unavailable no-hover, full Text/Double values, 44×44 target, View and Device-editor keyboard paths, and no Plan tab stop [auto: `smoke_device_icon_design.mjs`]. - [ ] Full plan, preview and static card preserve the same face after the DOM redesign; static mode, state/value and disabled-device contracts stay green [auto: `smoke_device_preview_parity.mjs`, `smoke_static_icon.mjs`, `smoke_state_value.mjs`, `smoke_disabled_device.mjs`]. - [ ] Restoring unavailable hover, shifting the LQI boundary, restoring value ellipsis or bypassing `_clickDevice()` makes its guard red [mutation: `device-unavailable-hover-restored`, `device-marker-lqi-low-boundary-shifted`, `device-long-value-ellipsis-restored`, `device-keyboard-bypasses-click-path`]. - [ ] Pre-beta golden reviews desktop/mobile Light/Dark states, combo states, Text, four Double positions, long and legacy values, LQI, reduced motion, sizes 32/56/96 and colored backgrounds. Full smoke/golden/performance remains a Linux release gate. ## Device marker polish and pointer modality (#212) - [ ] Shared icon geometry applies one 0.9 visual factor after card/per-marker sizing, keeps the saved centre and 44×44 hit floor unchanged, and gives wide Text cores a radius equal to half their height [unit: `device-marker-polish-contract.test.mjs`; auto: `smoke_device_icon_design.mjs`]. - [ ] Only a genuinely dispatched toggle/run action produces one `1 → .95 → 1` feedback cycle lasting 200 ms. Info, editor, confirmation before acceptance, unavailable/secure/no-target and cancelled gestures do not; reduced motion has no scale tween [auto: `smoke_device_icon_design.mjs`]. - [ ] Pointer authority is isolated per card. Touch/pen and compatibility mouse input clear JS/CSS hover, while a later real mouse restores it only on fine/hover hardware; mode/space/visibility/disconnect cleanup remains bounded [unit: `pointer-modality.test.mjs`; auto: `smoke_feedback_v2.mjs`]. - [ ] Pre-beta visual review covers Light/Dark desktop and touch matrices for ordinary, Text, Double, unavailable and vacuum markers; full golden and performance gates remain release work. ## Empty-space lifecycle (#113) - [ ] Active selection keeps active-or-first compatibility, while an empty model returns `undefined`; exact lookup of a stale saved id never falls back to another space [unit: `space-model-selection.test.mjs`]. - [ ] There are no unguarded `_spaceModel().…` dereferences, explicit-id calls use `_spaceModelById()`, and marker/position persistence validates its target before config/file/WS side effects [unit: `optional-space-model-contract.test.mjs`]. - [ ] Delete the last space while an editor gesture and debounced write are active: the empty card renders, View is restored, pointer/draft/dialog state is cleared, the pending write is cancelled and Add space still opens Create. Recreate a plan, then receive an empty WS config and repeat under a theme/resize/read-only tick [auto: `smoke_optional_space_model`]. - [ ] Removing the authoritative empty-state cleanup makes that smoke red [mutation: `empty-space-cleanup-disabled`]. ## Fixed card space (#210) - [ ] `floor` resolves exact stable IDs and zero-based finite integer indexes; quoted numeric strings stay IDs, and explicit empty, unknown, fractional, negative or out-of-range values fail closed [unit: `initial-load.test.mjs`]. - [ ] Three coexisting instances (fixed ID, fixed index and unpinned) keep independent authority while sharing the legacy navigation key. Fixed cards ignore hash, tabs, guarded internal transitions, warm remount, kiosk swipe/cycle/dots and never read or write saved navigation. Invalid config renders an accessible error without a spatial stage, while the unpinned card still restores legacy navigation [auto: `smoke_fixed_floor.mjs`, `smoke_nav_persist.mjs`, `smoke_kiosk.mjs`]. - [ ] The GUI offers stable IDs only, preserves an existing numeric YAML value during unrelated edits and deletes the `floor` property when cleared [unit: `fixed-floor-contract.test.mjs`; auto: `smoke_fixed_floor.mjs`]. - [ ] Bypassing the shared transition guard makes the focused browser scenario red; before-fix evidence also records that `origin/dev` has no fixed resolver or guarded transition [mutation: `fixed-floor-transition-guard-bypassed`]. ## Toggle confirmation state (#103) - [ ] Every executable `ToggleNextEffect` formats current and expected lines without deriving direction from the state label; `toggle` names Home Assistant as the authority and a no-operation intent produces no lines [unit: `device-toggle.test.mjs`]. - [ ] All-off/mixed/partial groups use only executable targets for their active/total count and show skipped targets as a separate line [unit: `device-toggle.test.mjs`]. - [ ] EN/RU confirmation renders prompt → current → expected → skipped before the buttons, wraps a long name at 390 px and has no horizontal scroll [auto: `smoke_toggle_confirmation.mjs`]. - [ ] A state race with the same target executes the newly resolved direction; a changed target set makes zero service calls and shows the existing retry toast [auto: `smoke_toggle_confirmation.mjs`]. - [ ] Cover, virtual-light, HA-control and Run confirmations keep their existing actuation/cancel contracts [auto: `smoke_cover_tap`, `smoke_virtual_light_toggle`, `smoke_ha_controls`, `smoke_controls`, `smoke_tap_run`]. ## Unified color and opacity picker (#57) - [ ] RGB↔HSV round trips stay within one RGB channel; 3/6-digit HEX input is normalized and invalid drafts never become persisted colors [unit: `color-picker.test.mjs`]. - [ ] Every existing `hp-color-opacity` consumer receives per-card localized labels through the unchanged color/opacity event contract, and the shared component contains no native `input[type=color]` [unit: `color-picker.test.mjs`]. - [ ] The localized full-width OK button is the final picker control, remains at least 40 CSS px high in native and fallback surfaces, and closes without a duplicate value event or click-through to the parent card [unit: `color-picker.test.mjs`, auto: `smoke_color_picker.mjs`, `smoke_help_affordance.mjs`]. - [ ] Invalid HEX keeps the picker open and focused on its error even after a repeated OK; only new valid HEX input unlocks confirmation, while live color/opacity updates and the existing outside/trigger/Escape close paths retain their values [auto: `smoke_color_picker.mjs`]. - [ ] At 390 px the one surface exposes hue, saturation, brightness, HEX and opacity without horizontal overflow; keyboard Shift+Arrow, touch pointer cancellation, invalid HEX recovery, Escape focus return and disabled mode remain safe [auto: `smoke_color_picker.mjs`]. - [ ] The color-only Glow consumer keeps the same picker without an opacity row, and native/fallback floating surfaces remain mutually exclusive with help [auto: `smoke_help_affordance.mjs`]. - [ ] The Hue range keeps `0…359`, step 1 and its existing input events while its WebKit/Blink and Gecko tracks expose the same cyclic spectrum. A dual theme-aware ring keeps the native thumb distinct from every hue, while forced-colors falls back to system track/thumb rendering [unit: `color-picker.test.mjs`, auto: `smoke_color_picker.mjs`]. - [ ] The dark mobile and light desktop open-picker goldens are reviewed from the complete Linux artifact before a beta; baseline acceptance is not part of the implementation loop [golden: `decor-color-popover-mobile-ru`, `decor-color-popover-desktop-en`]. ## Unified picker coverage for every color field (#180) - [ ] A recursive source contract rejects every native `input[type=color]` in product TypeScript and fixes the complete shared-component inventory at 13 template instances [unit: `color-picker.test.mjs`]. - [ ] The 11 general light/temperature/LQI/Glow/wall palettes and the space room colour move their existing opacity into the unified picker; color and alpha update one parent draft atomically [unit: `color-picker.test.mjs`, auto: `smoke_color_picker_consumers.mjs`]. - [ ] Global background, space background and activity ripple are color-only; opening/closing does not materialize an inherited/default value, and Default/Inherit restore `null` without adding alpha [auto: `smoke_color_picker_consumers.mjs`]. - [ ] General settings keep one exclusive picker open among 12 swatches; marker activity colour and ripple size use separate, non-overlapping mobile rows, ripple size remains independent, and cancelling the space dialog writes no color draft [unit: `color-picker.test.mjs`, auto: `smoke_color_picker_consumers.mjs`]. - [ ] The three new dialog families are reviewed from the complete Linux artifact before a beta; implementation does not accept their baselines [golden: `general-color-popover-desktop-en`, `device-ripple-color-popover-mobile-ru`, `space-room-color-popover-desktop-ru`]. ## Open passage (#157) - [ ] Подменю и диалог показывают четвёртый тип в порядке Окно / Дверь / Открытый проём / Ворота; новый проём имеет ширину 90 см. [auto: open-passage-contract, opening-placement] - [ ] В Plan/View у passage отсутствуют створка, дуга, рамка и пунктир, но сохраняются hitbox, wall cut и room-coloured tunnel. [auto: opening-symbol, smoke_open_passage] - [ ] Static вырезает и заполняет тоннель только для passage, не меняя старые door/window/gate. [auto: space-geometry, smoke_open_passage] - [ ] Внутренний passage пропускает Glow, внешний и неизвестный будущий тип остаются fail-dark. [auto: light-visibility, smoke_open_passage] - [ ] Passage в скрытой изометрии имеет full-height cut и zero leaves. [auto: iso-openings, golden] - [ ] Смена типа предупреждает о датчике/замке; Save удаляет пять неприменимых ключей, Cancel не меняет config. [auto: open-passage-contract, smoke_open_passage] - [ ] Full/space import отвергает forged binding до preview, а старое битое значение можно прочитать и очистить. [auto: test_validation, test_ha_import_export] - [ ] Пять passage-мутантов из `scripts/mutation-gate.mjs` пойманы своими guards до передачи в review. [auto: mutation-gate] ## Independent-wall openings and structural axes (#132, #185) - [ ] Door/window/gate/passage placement on a finished independent wall stores `host.kind/id/t`; a coincident room wall chooses that explicit host, while crossing or duplicate-host ties are rejected. [auto: opening-placement, partition-openings] - [ ] Every hosted type cuts only its host full-depth in Plan/View/Static/Iso; exact composite room masonry is also cut, nearby bodies remain intact, and malformed hosts fail dark. [auto: physical-geometry, smoke_partition_openings] - [ ] Rigid host drag preserves `t` and updates projections atomically; delete lists hosted openings, Cancel changes nothing, Confirm cascades in one Undo/Redo command. [auto: partition-openings, smoke_partition_openings] - [ ] Contact/lock actions keep existing security rules; passage stays inert; windows and exterior passages stay opaque to Glow, and partition windows produce no sun wedge. [auto: runtime contracts, smoke_glow] - [ ] Door/window/gate/passage presentation gaps do not split the structural axis used by the Walls face graph; real `open_spans` still do. [auto: plan-snap-overlay, smoke_room_autoclose, smoke_partition_openings] - [ ] Backend rejects missing host references, out-of-range `t`, non-fitting or overlapping hosted openings and stale host stripping; exports round-trip the host. [auto: test_validation, test_ha_import_export] - [ ] The exact #276 Optimize candidate is shared by frontend and backend tests: Python independently proves the removed partition, two-room solid wall, envelope, opening identity and non-overlap; config/set and every partial or mutated candidate remain rejected. Linux HA WS persists and reloads the implicit opening, then Undo restores the partition and explicit host. [auto: coincident-partitions, test_validation, test_ha_websocket] ## Independent-wall opening jamb margin (#186) - [ ] Strict resolver and placement reserve half the host depth for door/window/gate/passage at both endpoints, including exact-boundary, diagonal, reversed, thickness and scale matrices; room-wall placement keeps its zero-jamb rule. [auto: partition-openings, opening-placement] - [ ] Direct drag, dialog length edits and rebind share the same formatted RU/EN guidance; a rejected edit writes neither config nor history. [auto: smoke_partition_openings] - [ ] Backend config/set and optimize reject a new/direct invalid geometry with `invalid_partition_opening_jamb_margin`, while unrelated writes, rigid translation and full backup restore preserve a legacy near-end record. [auto: test_validation, test_ha_websocket, test_ha_import_export] ## Device value badge (#90) - [ ] An untouched legacy thermometer/humidity marker remains pixel-identical; saving another field does not materialize `value_badge`. [auto: device-presentation] - [ ] Explicit on/off overrides the legacy temperature gate; zero, false and off remain visible, while missing/unknown/unavailable render a stable `—`. [auto: device-presentation] - [ ] State, every allowlisted attribute, derived LQI and `marker:` light state resolve identically on the full plan, static space card and preview. [auto: smoke_device_preview_parity] - [ ] Opening the editor explicitly selects the persisted source and position, even when they are not the first dynamic options, without touching config. [auto: smoke_device_preview_parity] - [ ] Right, bottom, left and top update live in the editor; bottom stacks above system LQI and derived LQI suppresses the duplicate system row. [auto: device-presentation] - [ ] Browser bounding boxes stay inside `.previewstage` with a safe gap for all positions, a long value, scale ×3 and the maximum activity ring. [auto: smoke_device_preview_parity] - [ ] Rebind resets the source, delete leaves a missing diagnostic reference, and space import remaps internal refs or disables/counts external refs. [auto: test_ha_import_export] - [ ] `static_icon` suppresses but preserves the setting; live-state and room label toggles do not suppress an explicit badge. [auto: device-presentation] > **Policy:** this checklist is updated **in the same commit** as any functional > change (like CHANGELOG.md). For a pre-release, build the production bundle and > run the smallest unit/smoke subset that covers its changed surfaces. Run the > complete local frontend, backend and smoke gates only before a stable release. > The exact-SHA GitHub Validate remains mandatory for publication. Items marked > `[manual]` are covered by unit tests or the headless smokes in `demo/` — they still > deserve an occasional eyeball. File every failure as a GitHub issue before fixing. > **What `[manual]` means (since 2026-07-27).** A named check exists that FAILS > when the behaviour breaks — in `npm test` or in the smoke suite, both of which > run in CI on every push. Where the check lives is written next to the marker > (`[auto: smoke_modes]`). Before this date the marker described an intention: > the smoke suite printed values and always exited 0, so 96 markers guarded > nothing (external audit T1/T3). If you add a checklist line marked `[manual]`, > add the failing check in the same commit. > **⚠ Rule: a new scrollable list inside a dialog is tested by GEOMETRY, never > by the DOM.** Any new scrolling box or `overflow` container added to a dialog > MUST get a smoke that measures **the container's own height and the visible > position of its first item** (`getBoundingClientRect`, and the item's rect > against the box's rect) — counting rendered rows, or asserting that the nodes > exist, proves nothing. The failure mode is always the same and always > invisible to a DOM check: a scrolling box is a flex item whose automatic > minimum size is zero (`min-height: auto` → 0 for an `overflow` child), and a > dialog body is a flex column with a height cap, so the box is the one child > that can be squeezed to a sliver while every row inside it renders happily. > It has bitten us twice already: the **target search results** in the tap > action dialog (v1.53.1 — 26 matching automations rendered into a 1 px > stripe; the smoke counted rows and passed) and the **«Already uploaded»** > plan picker (dev, unreleased — rows present, box 14 px tall, same story). > Both smokes measure heights now; write the third one that way from the start. ## HA-disabled binding gate The source-of-truth matrix is `docs/superpowers/specs/2026-08-08-ha-disabled-devices-design.md` §17. `test/ha-binding-status.test.mjs` covers full/limited registry decisions and the active-only state projection. The standalone demo exposes complete `disabled_by` rows through both registry list WS commands plus `window.__setRegistryDisabled(kind, id, disabledBy)` and `window.__setRegistryAccess(mode)` for browser scenarios. - [ ] A saved device/entity marker disappears from View, room data, Glow, controls, live text, openings and vacuum overlays after its registry row becomes disabled; config/layout remain byte-for-byte unchanged. - [ ] Device editor → Hidden and disabled shows a labelled grey ghost. Show is refused, metadata/Delete/Open in HA remain available, and the ghost is not draggable. - [ ] Reactivating the same ID restores its metadata/layout without a false new-device event; an explicitly user-hidden marker stays hidden. - [ ] A never-seen auto device disabled before discovery appears as new only after its first activation. - [ ] All disabled child entities make an otherwise active device disabled; one disabled auxiliary entity never suppresses active functional rows. - [ ] If full registry WS access is denied, positive live evidence stays active, an unknown binding is `unverified`, and no false disabled/orphaned ghost or service call is produced. - [ ] Two full cards plus a static space card share one registry fetch and one subscription pair per HA connection; registry events invalidate all of them without a reload. - [ ] `houseplanDiagnostics()` reports only redacted registry access/age/error and binding-status counts; it contains no names, states or marker data. ## HA Area marker relocation (#126) - [ ] `test/device-area-relocation.test.mjs` covers direct-binding authority, same/cross-space transitions, conservative legacy backfill, explicit and composite exclusions, rebind, malformed metadata and delete-first provenance. - [ ] `test/space-geometry.test.mjs` proves both marker-position paths can suppress one stale saved point without changing the stored layout input. - [ ] `demo/smoke_area_relocation.mjs` changes an authoritative registry Area against the production bundle, checks one serialized layout delete plus config/attention persistence, and proves the read-only hosted card moves immediately without writes. - [ ] Area-provenance cleanup ignores the filtered display roster, preserves empty Device/Entity Registry namespaces, accepts exact live entity states as existence evidence, requests only one confirmation refresh and removes an orphan only after two distinct non-empty authoritative revisions. - [ ] A rejected confirmed-cleanup config write remains retryable on the next rebuild; the same revision, state ticks and repeated empty frames never become extra confirmation or a registry reload loop. - [ ] Backend validation and import/export tests cover the 20,000-entry bound, exact entry schema, same-source preservation and cross-source removal. ## Device display preview and face parity The behaviour matrix is defined in `docs/superpowers/specs/2026-08-08-device-display-preview-design.md` §22. Pure source/value/presentation rules live in `test/device-presentation.test.mjs`. `demo/smoke_device_preview_parity.mjs` compares the same live fixture across the interactive plan, `hp-device-preview` and `houseplan-space-card`, including semantic classes, icon/value/badges, scale variables, provider text and the public binding-status hook. - [ ] Every binding/display/icon/size/angle/control/temperature draft change updates the preview before Save; Cancel writes neither config nor layout. - [ ] Working, open, cover, presence, short event, transition, alarm, static, unavailable, media-neutral, composite-Power and `live_states: false` explanations match the actual face. - [ ] The local short-activity demo lasts 3.3 seconds and the continuous demo runs until stopped. Neither sends a service call; reduced motion uses a compact dot, and both reset immediately on binding change, real activity or alarm. - [ ] Provider metadata is cached between dialog openings and refreshed after registry/config-entry changes; source integrations remain separate from the binding provider. - [ ] Long provider/source/state text wraps without horizontal scroll; maximum marker/ripple size fits the stage and reports its preview scale. - [ ] Derived temperature/humidity values keep the compact plan form (`22.4°`, `48%`), while a direct entity value continues to use HA localization and units. ## Device icon package parity (#211) The independent reference subset under `demo/srv/reference/device-icons/` comes directly from designer package 1.1.1; it is not generated from production CSS. The package archive hash and the owner's #219 red/green Lock/Unlock paint override are recorded in that directory's README. - [ ] `node demo/smoke_device_icon_design.mjs` reads the SVG colors and stroke widths, then compares them with fresh computed styles. It also measures circular core/shell geometry, the real `mdi:lightbulb-spot` painted path, value-pill radius and 44×44 hit area at 32/56/96 px. - [ ] `node demo/capture_device_icon_reference.mjs` writes a two-column **Reference SVG / Runtime** matrix for both themes to `artifacts/device-icon-reference/`. Code review must inspect this artifact visually; a green historical golden is not proof of package parity. - [ ] Preview/static parity and unavailable keyboard/tap behavior remain covered by `smoke_device_preview_parity`, `smoke_static_icon` and `smoke_disabled_device` after a fresh production build. ## Device marker geometry and input polish (#213) - [ ] `node demo/smoke_device_icon_pixel_alignment.mjs` covers core bases 24…112 CSS px in quarter-pixel steps at DPR 1/1.25/1.5/2. DOM centres, isolated painted centroids/support and a deliberate 1 CSS px mutant must distinguish browser raster parity from a persistent offset. - [ ] `node demo/smoke_device_icon_design.mjs` keeps the effective 32/56/96 geometry, uses the direct 0.55 MDI/core ratio and proves hover plus the configured action from the far value-capsule end at right/bottom/left/top. - [ ] Opening binding/registry-less/lock-action smokes preserve the secure no-toggle-on-plan invariant while checking compact Light/Dark locked/unlocked/unknown shell/core presentation. - [ ] `node demo/smoke_opening_entity_search.mjs` checks the real opening dialog: contact and lock search by friendly name/entity ID, preserved contact priority, visible IDs, persistent **none** option and unchanged `opening.contact`/`opening.lock` storage. - [ ] Unit presentation coverage compares marker LQI colour with the shared continuous `lqiColor()` across former 40/41 and 179/180 boundaries; bands remain semantic metadata only. ## Text marker shell shape (#217) - [ ] `node demo/smoke_device_icon_design.mjs` checks the external Text frame, not only its core: a long value keeps a saturating capsule radius at 24/32/56/96/112 px, while Icon-only remains circular and Double remains a capsule. The runtime mutation to `border-radius: 50%` must be rejected. - [ ] `device-text-shell-long-light` and `device-text-shell-long-dark` isolate a large `498 ppm` Text marker. Golden review must visibly confirm straight upper/lower middle sections rather than an ellipse. - [ ] `node demo/capture_device_icon_reference.mjs` includes an additional 96 px Text row beside the normative Light/Dark `Text Default.svg`. ## Device lock and orange foreground palette (#219) - [ ] Closed/`locked` is green `#66D17A`; open/`unlocked` is red `#F0410C`. The same core/stroke palette is used by ordinary lock markers and compact door/gate lock badges; glyph shape remains closed/open/question. - [ ] Every device glyph on an orange core (`on`/working and physical `open`) is white in Light and `#252525` in Dark. `device-icon-state-table-light` and `device-icon-state-table-dark` show `on` and `open` together, plus both lock states [unit: device-marker-polish-contract; auto: smoke_device_icon_design; golden: device-icon-state-table-*]. - [ ] Alarm, hover, focus, selected, unavailable, virtual, press feedback, pulse, hit-area and lock actions retain their existing priority and behaviour [unit: device presentation/polish/pointer; visual source review]. ## Touch support and release gates The product contract is defined in `docs/TOUCH-SUPPORT.md`: | Surface | Touch release status | |---|---| | View and View dialogs/actions | Required and release-blocking | | Kiosk/wall tablet | Required and release-blocking | | Editor entry/exit and no accidental mutation during multi-touch | Safety floor; release-blocking | | Feature parity of Plan/Device/Background editors | Best effort; not a general release gate | All editors remain fully tested on desktop with mouse/keyboard. A touch-editor failure may be accepted only as a deliberate scope change with updated user documentation and test classification in the same change. “Best effort” cannot be used to waive data corruption, unsafe service calls, permission failures, missing destructive confirmation or an editor exception that breaks View. - [ ] Smoke harness itself (v1.43.2, audit T1/T2): every smoke asserts named facts via `check`/`checkAll` and exits non-zero on any mismatch or uncaught in-card exception; the suite runs in CI against a FRESHLY built bundle. Sanity ritual: break one invariant on purpose (e.g. remove the kiosk editor guard) and confirm the matching smoke goes red [auto: CI job "smoke"] - [ ] Room gear discoverability (v1.43.3, user feedback): in the Plan editor every room card carries a pill button "⚙ Room" of a FIXED readable size (independent of the card font) — including rooms without a name; it opens Room settings [auto: smoke_feedback_v2] - [ ] Metrics readability (v1.43.3): the metrics line is 0.75 of the room name (was 0.62 — unreadable on tablets); per-room sliders still apply on top [auto: smoke_feedback_v2] - [ ] Touch tooltips: touch/pen immediately clears hover even if the browser claims hover support; compatibility mouse is ignored, while a later real paired-mouse event restores desktop hover without reload [auto: smoke_feedback_v2] - [ ] Light-source flag (v1.44.0, user feedback): a smart SWITCH driving dumb fixtures creates a Glow pool only once "This device is a light source" is ticked. External targets under "Controls" still feed group state/statistics but never create a pool at the switch coordinates; unticked devices without a light entity never glow [auto: smoke_glow] - [ ] Device card controls (v1.44.0): the device card opens with its controllable entities FIRST — toggles right there (≥30 px tap targets), cover/lock/climate open HA more-info; model, links and manuals moved below; config/diagnostic entities are not listed; locks never toggle from the card [auto: smoke_card_controls] - [ ] Lock invariant, all paths (v1.44.2, review CR-1): icon tap, controls[], device card and _cardToggle refuse locks/alarm panels entirely; the door card's Unlock asks for confirmation, Lock does not [auto: smoke_lock_invariant] - [ ] Attachment migration is transactional (v1.44.2, review CR-2/CR-3): rebinding COPIES files, saves the config, and only then deletes the old folder; a rejected save leaves the old files and urls intact; a name collision in the destination gets a unique name (the pre-existing file is never silently linked); urls are rewritten only for confirmed copies [auto: unit logic.test + tests_backend] - [ ] Plans and PDFs load in a real browser (v1.44.3, B1 regression): open a dashboard with an uploaded plan — the background renders and a manual link opens; DevTools shows /api/houseplan/content/... returning 200 via a signed url, while the same url without authSig returns 401 [auto: tests_backend + manual] - [ ] Auth policy is single-sourced (v1.44.4, B2): the HTTP upload and every WS write use the same `may_write`, which denies non-admins when the config entry is unavailable [auto: tests_backend] - [ ] Coordinates and caps (v1.44.4, B5): NaN/Infinity are refused on room rects, polygon vertices, view_box and openings — not only in layout; the openings list honours MAX_OPENINGS [auto: tests_backend] - [ ] Drag hardening (v1.44.4, L4 sub-item): every drag pipeline captures the pointer through the tolerant helper; decor follows the infinite canvas and stops only at the shared normalized ±5000 garbage bound [auto: smoke_decor, smoke_drag_bounds] - [ ] Room climate counts hidden sensors (v1.44.5): a thermometer that is NOT placed on the plan (hidden by filtering or by the user) still feeds the room card, the tooltip and the temperature fill; fridges/TRVs still do not; an explicit per-room source still wins [auto: unit devices.test] - [ ] Room climate follows explicit House Plan placement (#317): a real temperature/humidity sensor moved away from registry Area A votes exactly once in its marker target, including an area-less `space + room_id` room; hidden markers still vote, while removed/HA-disabled ones do not. Exact entity placement wins over its parent device only for that entity, and explicit room sources remain authoritative [auto: smoke_room_climate_placement; units: test/devices.test.mjs; mutation: room-climate-ignores-marker-placement] - [ ] Room hover + tooltip: in View, hovering any room visibly highlights it (filled, transparent and area-less alike) and shows its name plus clean- floor area; temperature/signal follow when available. Thick walls reduce the area to the inner contour. The wash/halo use plain SVG without CSS filters, and hovering never replaces or flashes the Glow pool/gradient DOM. Editors do neither [auto: smoke_ux_fixes + smoke_glow; manual visual] ## Новый код не добавляет any (#342) ```bash node scripts/no-new-any.mjs # origin/dev...HEAD node scripts/no-new-any.mjs --base origin/dev --head HEAD node scripts/no-new-any.mjs --diff patch.diff # или `-` для stdin ``` В `src/**` сейчас **1034 вхождения** явного `any` в 49 файлах — больше, чем называл аудит (330), потому что монолит с тех пор разделился и его обвязка уехала в `houseplan-editor-runtime.ts`. Разовая замена такого объёма — месяц риска ради нуля пользовательской ценности, поэтому долг снимается при плановом извлечении подсистем (#425, прежний #34). Гейт держит приращение на нуле. Что он судит: **только добавленные строки** диапазона. Существующий `any` на нетронутой строке законен. Правка строки со старым `any` считается новой ответственностью — изменённая строка в диффе выглядит добавленной, и это намеренно: тронул, значит либо типизируй, либо обоснуй. Исключение объявляется на той же строке: ```ts const raw = (event as any).detail; // any-ok: форма события HA не типизирована в @types ``` Голый `// any-ok`, пустая причина и шаблоны вроде `todo`, `hack`, `потом` не проходят: причина обязана быть не короче 12 символов и не совпадать со списком заглушек в скрипте. Ложных срабатываний нет по построению, а не по старанию: текст разбирается парсером TypeScript, и нарушением считается узел `AnyKeyword`. Слово «any» в комментарии, в строковом литерале, в многострочном шаблоне `html` и в идентификаторах `company`, `anyOf`, `manyRooms` таким узлом не является. В CI гейт вызывается в job `frontend`; её checkout получил полную историю без блобов, потому что diff-aware проверке нужен диапазон, а содержимое старых ревизий — нет. ## Локальный набор перед пушем (#343) Красный CI — дорогой способ узнать о проблеме: пять минут ожидания, а при код-ревью ещё и лишний раунд. Прецедент назван в задаче: находка r2-H1 в #329 стоила целого раунда и ловилась локальным `npm test`. ```bash node scripts/pre-push-gate.mjs # origin/dev..HEAD node scripts/pre-push-gate.mjs --base origin/dev --head HEAD node scripts/pre-push-gate.mjs --no-smokes --no-mutants node scripts/pre-push-gate.mjs --max-smokes=3 --max-mutants=1 ``` Что прогоняется: проверка, что ветка приведена к `origin/dev`, `npx tsc --noEmit`, `npm test`, смоки, выбранные `scripts/smoke-select.mjs` по диффу, и мутанты, выбранные `scripts/mutation-gate.mjs --changed` по тем же файлам. Отставание от `dev` — предупреждение, а не провал набора: гейтом остаётся конвейер, который приводит ветку сам (#257) и забыть не может. Смысл локальной проверки в другом: после любого ребейза разбор на ревью становится полным, а не по дельте (§7.2), а конфликт всё равно чинится на машине автора — дешевле узнать об этом до пуша, чем из комментария через сорок минут (#364). Отключается флагом `--no-rebase-check`. Замер на реальном диапазоне (`953f675~1..953f675`, правка `src/houseplan-card.ts`): типы 5 с, юниты 17–19 с, два смока 22 с — **46 секунд** на всё. Три свойства, без которых такой набор бесполезен: - **не останавливается на первом упавшем** — иначе автор узнаёт о втором нарушении следующим кругом, то есть ровно то, от чего набор защищает; - **громко перечисляет, чего не проверял** — молчаливый пропуск дважды стоил проекту дня (#171, #207), а «Verified» без названной команды и её результата доказательством не является; - **не претендует на полноту.** Golden, полная матрица смоков, HA-харнесс — heavy-набор Validate на кандидате; весь мутационный реестр — ночное расписание (#513), а не этот набор. Бандл не собирается: `bundle-sync.mjs` раскладывает закоммиченный `dist`, а свежесть проверяет сам продукт — `assertFreshDemoBundle` внутри каждого смока сверяет вшитый отпечаток с исходниками дерева и скажет, если нужна пересборка. Лимиты по умолчанию — шесть смоков и два мутанта. Мутант дорог: каждый пересобирает бандл, а правка `src/houseplan-card.ts` задевает их 62. Превышение лимита не проглатывается — набор печатает точную команду для полного прогона. Три вида ответа `smoke-select` различаются и здесь: дифф без исполняемого кода — «смоки не требуются»; прямое совпадение или зарегистрированная связь — прогоняется; **связь не доказана** — отдельная громкая строка, потому что это не «проверять нечего»: молчание стоило #234 бета-блокирующего регресса. ### Как включить в хук Набор намеренно не включён в `.githooks/pre-push` по умолчанию: 20–45 секунд на каждый пуш, включая пуши одной строки документации, — цена, которую стоит платить осознанно. Включается переменной окружения: ```bash export HP_PREPUSH_GATE=1 # в профиль оболочки git push # хук прогонит набор перед процессным гейтом ``` Обойти, как и процессный гейт, можно через `git push --no-verify` — и тогда то же самое найдёт Validate, уже после того как код окажется в `dev`. ## Environments matrix Run View/kiosk core flows in every applicable touch environment. Run editor core flows in desktop environments; touch editors only need the safety floor and separately promised workflows: - [ ] Chrome / Edge (desktop, Windows or Linux) — View + all editors - [ ] Firefox (desktop) — View + all editors; SVG viewBox math and container queries differ historically - [ ] Safari (macOS) — View + all editors; pointer events / pinch behavior - [ ] HA Companion app, Android — View only as the parity contract; cold start is mandatory - [ ] HA Companion app, iOS — View only as the parity contract - [ ] Tablet in kiosk/panel mode — View/kiosk, landscape, touch gestures - [ ] Phone portrait, narrow ≤400 px — View and View dialogs/actions - [ ] Dark theme and light theme (badges, dialogs, plan contrast) - [ ] RU, EN and DE profile locales (+ `language:` card option forcing each); `de-DE`, `de-AT` and `de-CH` resolve to German, while an unknown locale falls back to English [unit: i18n, i18n-runtime] - [ ] German cold start requests exactly one locale chunk, shows only a neutral busy frame before commit and never flashes English; a second card reuses the page cache. EN/RU request no locale chunk [auto: German locale smoke] - [ ] German locale download failure retries the content-hashed asset once and then unblocks the card in English with one warning [unit: i18n-runtime; auto: German locale smoke fault injection] - [ ] German View and a representative settings/device dialog fit at desktop and 390 px without horizontal overflow or clipped actions [golden: German desktop/mobile scenarios; auto: dialog footer width at desktop and 320 px] ## Installation / upgrade / removal - [ ] A successful entry setup registers `/houseplan` as `houseplan-panel` only after store migrations/repairs complete. The panel is visible to admin and read-only users; actual editor/write access still follows `can_write` and `admin_only` [auto backend: panel registration/permission tests]. - [ ] Missing `houseplan-panel.js`, static registration failure, foreign path collision or panel API exception leaves the integration, WS API and dashboard card usable. Unload removes only the exact panel object owned by this setup generation; reload/reconnect cannot duplicate or delete a replacement [auto backend: panel lifecycle + mutation tests]. - [ ] Wide View/editor and 320 px read-only/empty `/houseplan` have no horizontal overflow or duplicate product title. Menu activation emits bubbling and composed `hass-toggle-menu`; `hass`, `narrow`, `route` and `panel` updates preserve one child card; leaving the route keeps only the space and returns in View [auto: `smoke_houseplan_panel`; golden: panel matrix]. - [ ] The dashboard full card advertises `{columns:"full"}` to Sections while retaining `getCardSize`; the compact space card has no grid default. Both stable JS entries and their exact graphs/hashes are verified, the card graph excludes the panel root, and panel-only gzip stays within 8 KiB [unit: `houseplan-panel`, bundle manifest/budget/tree/freshness]. - [ ] README and User Guide in EN/RU each separate Storage mode, HA 2026.2+ `resource_mode: yaml` and legacy HA 2024.6–2026.1 full-YAML dashboard setup; both hard-reload shortcuts are present and a flat top-level `resources:` block is rejected [auto: `check-docs`]. - [ ] Fresh Storage-mode install from the HACS default catalog (plain search) → integration appears; the exact `?v=` URL is created or adopted in the Lovelace resource registry without an `extra_module_url` fallback or a duplicate. An upgrade updates the one canonical entry [auto backend: `tests_backend/test_ha_frontend_registration.py`]. - [ ] `single_config_entry`: adding a second entry is impossible [manual] - [ ] A pending or transient registry installs the fallback immediately, then retries exactly once after HA started plus the fixed one-second delay. Success removes only this setup's exact fallback when supported; unload before the listener, during the delay or during the task prevents all late side effects, and reloads do not accumulate listeners [auto backend: `tests_backend/test_ha_frontend_registration.py`; mutation gate]. - [ ] HA 2026.2+ YAML resources and a legacy 2024.6–2026.1 full-YAML dashboard use the truthful `extra_module_url` fallback without a registry write or polling loop. A Storage dashboard is never instructed to switch to legacy `mode: yaml` just for House Plan [auto backend: `tests_backend/test_ha_frontend_registration.py`; auto: `check-docs`]. - [ ] A missing frontend bundle or failed static-path registration does not fail integration setup, does not report a successful loader and does not consume the one-time notification; System Health reports file/static/loader/outcome honestly [auto backend: `tests_backend/test_ha_frontend_registration.py`, `tests_backend/test_ha_setup.py::test_missing_frontend_bundle_does_not_skip_backend_setup`]. - [ ] The first available frontend registration creates one localized persistent hard-reload notification. Repeated setup, retry completion and a new House Plan version create no duplicate; uninstall dismisses it best effort [auto backend: `tests_backend/test_ha_frontend_registration.py`; mutation gate]. - [ ] System Health preserves existing plan statistics and reports card file, static path, typed resource outcome, final loader, exact versioned URL, retry state, safe error and first-notice state without traceback, tokens or external paths [auto backend: `tests_backend/test_ha_frontend_registration.py`]. - [ ] In a full card, every successful `config/get` authoritatively replaces the backend version. Equal versions and unknown/malformed values show nothing; a known symmetric mismatch shows one direction-neutral manual-reload banner in ordinary View/editor/dialog states and never reloads without a trusted click [unit: `version-recovery`; auto: `smoke_version_recovery`]. - [ ] In kiosk, an unsafe mismatch preserves the current frame. The first fully safe idle state stores the backend target before exactly one reload; the same target after reload/remount or in a second full card gets no second attempt, a new target gets one, and unavailable `sessionStorage` is manual-only [unit: `version-recovery`; auto: `smoke_version_recovery`; mutation gate]. - [ ] `custom:houseplan-space-card` loads the shared bundle/config but never owns the version banner, safety timer or automatic reload [unit: `version-recovery`; auto: `smoke_version_recovery`]. - [ ] Removal deletes every House Plan Lovelace resource entry with the canonical base URL and dismisses the notification; `.storage/houseplan.*` survives and reinstall picks the old config up [auto backend: `tests_backend/test_ha_frontend_registration.py`]. - [ ] Diagnostics download works; personal fields (name/link/description/pdfs) are `**REDACTED**` [manual] ## Modes (v1.25.0) ★ - [ ] The card always loads in **View**; edit modes are never restored [manual] - [ ] View: pan/zoom/space-switch/tap/long-press/tooltips only — dragging an icon, label or opening does nothing; panning may start on top of an icon [manual] - [ ] View header: space tabs + count + zoom + editor tabs, the general-settings cog and the per-space gears (visible in EVERY mode since v1.30.1/v1.30.3 for users who may edit); no editor toolbars [auto: smoke_modes] - [ ] Space-tab reorder (#243): in an editor with at least three spaces, use a real mouse drag while browser pointer capture remains on the held tab; moving left resolves the tab under the cursor and paints its left divider, moving right paints the right divider, and each valid drop saves exactly once [auto: smoke_space_tab_reorder; golden: space-tab-drop-before-light, space-tab-drop-after-dark] - [ ] Move a held space from a valid target out over the plan: the divider clears immediately and release does not save. `pointercancel` and removing the card mid-drag also end the gesture without changing order [auto: smoke_space_tab_reorder] - [ ] A sub-threshold mouse gesture remains a tab click; the next click after an outside release still works. Touch, View and a card fixed to one `floor` never expose reordering [auto: smoke_space_tab_reorder] - [ ] Plan: markup toolbar, space gears, +space, ⚙ palette; device icons hidden, labels/openings draggable; orange stage frame [manual] - [ ] Devices: icon drag works, click opens the marker editor directly; +/👁/↺/⬡ buttons; accent stage frame [manual] - [ ] Mode tabs hidden for non-admin users; segmented control highlights the active mode - [ ] Openings in View (v1.28.1+): the door/window/gate itself is a pure drawing — no cursor change, no hover outline, no hit target, no click, regardless of bindings [manual] - [ ] The LOCK BADGE is the one exception: when a lock is bound it is shown and clickable in View (pointer cursor, click → door/lock info card); inert in Plan so it does not fight editing [manual] - [ ] Device icons in View show a pointer cursor (no grab); grab only in the Devices mode [manual] - [ ] In Plan an opening is interactive: grab cursor, hover outline, drag along walls, click (any tool) opens its properties — with a 3 px drag threshold since v1.43.1, so a tap is never swallowed [auto: smoke_inert_openings] - [ ] Opening drag rulers (2026-08-03): while an opening is dragged, a measure badge on EACH shoulder shows the along-the-wall distance from the wall end to the nearest opening edge, live; the wall is ONE room's edge — the edge the opening is snapped to — so a neighbouring room's collinear edge is never merged in; at that edge's center (±half a grid step) a perpendicular dashed tick appears and the center magnet-snaps; there is no modifier that disables the magnet; badges and tick vanish on release [auto: smoke_opening_measure + unit openingShoulders] - [ ] Physical rulers while PLACING a new opening (#238): one room shows two lines/four endpoint ticks from the preview jambs to the physical inner face endpoints. A shared wall shows four independently resolved lines/ eight ticks, two on each room face. A finished independent wall stops per direction at the nearest physical face of a connected wall/partition and falls back to its own endpoint where none exists. Lines, ticks and labels update in the same frame as the preview and are pointer/ARIA inert; saved opening drag retains the legacy two badges and no new lines [unit: opening-dimensions; auto: smoke_opening_inner_distances + smoke_opening_measure] - [ ] While PLACING a new opening: pressing **Opening** only opens the shared secondary tray; choosing Window / Door / Gate arms the 120 / 90 / 300 cm session preset. Moving over a physical wall shows the complete architectural symbol at 50% opacity, above the masonry, together with the physical dimension badges and the existing centre tick/magnet. The preview accepts pointer hits anywhere inside a thick wall body, is absent on virtual spans and existing openings, and never carries an interactive or persistent identity. A direct click without prior hover resolves the same candidate authoritatively and opens its dialog. Save and Cancel keep the selected preset for repeated placement; tool/mode/space exit and Esc clear it [unit: opening-placement; auto: smoke_opening_preview + smoke_opening_measure; golden: opening-placement-door-thick-wall-dark] ## Onboarding ★ - [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [manual] - [ ] Wizard: uncheck all → "Create" disabled; "Start from scratch" → classic dialog - [ ] Wizard: N floors → space dialog per floor with prefilled name and progress "i of N"; Skip skips one; Cancel aborts the whole queue [manual] - [ ] After the last wizard space (or first manual space) → markup mode auto-opens with a toast - [ ] Empty config, no floors → classic "New space" dialog auto-opens once per session - [ ] All floors skipped, nothing created → empty state with "Add space" button remains usable [auto: smoke_optional_space_model] ## Spaces ★ - [ ] Create with an image (SVG, PNG, JPG, WebP) → correct aspect, crisp at zoom (SVG) - [ ] Oversized plan (>8 MB) → readable error toast, dialog stays open - [ ] Create with "No image — I'll outline rooms by hand": orientation landscape/portrait/square respected [manual]; borders+names default ON [manual] - [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [manual] - [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [manual] - [ ] Delete space with rooms/devices → tab disappears, layout of other spaces untouched - [ ] Delete the last space → empty state without console errors; active editor gestures and drafts are aborted, and creating the first space remains available [auto: smoke_optional_space_model] - [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [manual] - [ ] Fill "zigbee": rooms tint red→green by average LQI; rooms without zigbee stay unfilled [manual] - [ ] Fill "lights": yellow when any light on, grey when all off, unfilled when the room has no lights [manual]; toggling a light from the plan recolors the room - [ ] Fill "temperature": blue below the comfort range, green inside, yellow above [manual]; comfort bounds editable inline on the radio row (swapped bounds tolerated [manual], clearing a field cannot zero a bound [manual]); rooms without a temperature reading stay unfilled [manual] - [ ] Fill mode is a radio group (no dropdown); labels carry no color legend - [ ] Room hover adds a subtle accent wash and double contour without changing the underlying room fill or Glow brightness - [ ] Room tooltip shows average room temperature and humidity after the area line and before LQI; missing values are omitted [auto: smoke_ux_fixes] - [ ] General settings can hide only the room tooltip: default/Cancel/save/reopen semantics, skipped area work, persistent room highlight, unaffected device tooltip and restoration on the next mouse move [auto: smoke_room_tooltip_toggle] - [ ] Average room temperature counts ONLY thermometer/air-monitor devices — fridges, TRV heads, smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [manual] - [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px) - [ ] The scale input is compact (72 px), not full-width; it shows cm in metric HA and inches in imperial HA [manual; auto: smoke_space_scale_defaults] - [ ] A new manual space and every floor-import draft start at 1 cm in metric HA or exactly 1 inch/2.54 canonical cm in imperial HA. Opening and saving an existing 5 cm, fractional or missing legacy value without editing the field is lossless; changing language does not rewrite the canonical draft [auto: smoke_space_scale_defaults] - [ ] Physically equivalent rich fixtures at 1 cm and 5 cm have equal View, Plan-with-grid-masked and static-card pixels/critical bounds. The grid has five times the intervals only; openings retain their edge hit target, and physical/screen-fixed layers are not double-scaled [auto: smoke_grid_scale_invariance; unit: grid-scale.test.mjs, opening-symbol.test.mjs, canvas.test.mjs] - [ ] General settings (⚙ in the header): fill colors grouped by mode (lights on/off/none, temp cold/comfy/hot, LQI weak/strong), each with its own opacity slider [manual]; Reset restores defaults; saving defaults stores nothing [manual] - [ ] Custom fill colors apply to the full card AND the static space-card - [ ] LQI gradient interpolates between the configured weak/strong colors [manual] - [ ] Per-space "Show zigbee signal (LQI)" toggle hides/shows the badges next to devices and the signal line in room tooltips for that space only [manual] - [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [manual] - [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [manual] - [ ] Room hover highlight still works when custom borders/fills are on - [ ] Settings persist across reload and other browsers (server-side) ## Room markup editor ★ - [ ] The toolbar has one **Walls** tool and no separate Room outline or Partition drawing button; Split remains available [auto: smoke_unified_wall_tool + unified-wall-tool-source.test] - [ ] Grid appears; dots snap; the wall chain draws pair-by-pair; shared walls reused - [ ] Ruler: while drawing, the length of the current segment follows the cursor (metres, or feet+inches on an imperial HA); scale = canonical per-space `cell_cm` (new-space default 1 cm or 1 inch; missing legacy fallback 5 cm) - [ ] Every completed segment is saved immediately as one ordinary partition. Changing tool, editor or floor clears only the session-local chain state; accepted walls stay unchanged and are not resumed after reload/remount [auto: smoke_unified_wall_tool + smoke_free_walls + smoke_plan_snap_overlay] - [ ] A legacy warm-viewport token `partition` still opens the unified Walls tool, but `partition` is not a runtime tool-state, dispatch branch or golden-matrix option [auto: wall-face-graph.test + unified-wall-tool-source.test + golden-matrix.test] - [ ] Re-selecting Walls, Reset, pan, pinch, a second pointer, `pointercancel` and a suppressed synthetic click never finish a chain or save an extra segment [auto: smoke_unified_wall_tool] - [ ] The active segment keeps a visible thin axis and endpoint above a thick preview. Distinct nodes inside the ambiguity radius fail closed with a zoom prompt. Shift constrains the actual endpoint to the nearest exact 45° ray, including exact ray/wall intersections; the angle colour follows the stored vector, not pointer intent [unit: plan-snap-overlay.test.mjs; auto: smoke_plan_snap_overlay + smoke_plan_drawing_repairs]. - [ ] With no active chain, a click strictly inside the smallest unoccupied exact wall face offers a room; boundary/snap hits and Shift bypass it. Keep/Cancel is a true no-op. If one endpoint→endpoint or endpoint→solid-line repair closes the face within 2 physical cm, the red diagnostic is offered and moves geometry only together with Create. A larger gap, hosted-opening mover or multiple possible repairs fails closed [unit: wall-face-graph.test.mjs + wall-face-repair.test.mjs; auto: smoke_plan_drawing_repairs]. - [ ] Deleting a room uses an accessible Keep walls / Delete walls / Cancel dialog. Keep materialises only exclusive positive solid intervals as partitions and rehosts their openings; Delete cascades only openings on those exclusive walls. Shared walls/openings, explicit partitions and partition-hosted openings survive. Either accepted choice is one Undo/Redo/save transaction [unit: room-deletion.test.mjs; auto: smoke_plan_drawing_repairs]. - [ ] There is no "Erase" tool in the markup toolbar (removed in v1.19.0) - [ ] Rooms never overlap (v1.20.0): a click strictly inside an existing room is refused with a toast; a click ON a shared wall (including mid-span of a longer neighbour wall) still works - [ ] Closing an outline drawn AROUND an existing room is refused; the outline stays open - [ ] Merge (v1.21.0): two rooms sharing a wall merge into one; the dialog picks the surviving name/area; rooms touching only at a corner or apart are refused with a toast - [ ] Split (v1.21.0): click a room, then two points on its walls — the bigger part keeps the name/area/devices, the smaller opens the new-room dialog; Cancel leaves the room whole - [ ] Split: a cut with an end off the wall, or along a wall, is refused with a toast - [ ] Split: the click snaps to the nearest wall, so it works on non-grid-aligned rooms (imported/legacy polygons), not only on rooms drawn on the current grid [manual] - [ ] Split: a click far from any wall (middle of the room) is a miss with a toast — the wall-snap pull is capped, accidental clicks do not pick a wall [manual] - [ ] Esc / Ctrl+Z removes the last dot (and its line); Reset clears the active path - [ ] A latest segment that creates bounded endpoint, T or X faces opens the room queue in area/key order. Existing exact/partial rooms and physical gaps, including opening cuts, are excluded; nested rooms remain eligible [unit: wall-face-graph; auto: smoke_unified_wall_tool + smoke_room_autoclose] - [ ] Create and Keep as walls answers are buffered. The last answer applies all rooms and unconsumed wall atoms as one Undo/Redo transaction; Cancel/Esc restores the terminal draft without partial rooms [auto: smoke_unified_wall_tool] - [ ] A clean divider across one existing room offers only the smaller child; the larger child keeps the original room id, name, area binding, settings and device placement [auto: smoke_unified_wall_tool] - [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name - [ ] Room dialog uses the medium width and its body has no horizontal overflow; long options stay inside it at desktop and narrow widths [auto: smoke_editor_tabs; manual: narrow viewport] - [ ] "No area" room (decorative) requires a name; saves with `area: null` - [ ] Cancel in a Walls face queue restores the persisted terminal draft - [ ] Saving a room with an area: area devices appear with icons; positions are fixed into the layout [manual] - [ ] Delete-room consequences are chosen explicitly as described above; there is no Erase tool - [ ] Device icons hidden during markup; visible again on exit ## Devices on the plan ★ - [ ] Auto devices appear only in rooms bound to their area [manual] - [ ] **Entity/parent ownership (#226):** placing `entity:X` removes X from its automatic parent. A visible unclaimed sibling keeps one residual parent; an empty or HA-hidden-only residual removes it. State, primary/action, `allEntities`, light/Glow and LQI cannot see X twice [auto: unit `devices.test.mjs`; browser `smoke_entity_parent_dedup.mjs`]. - [ ] Two explicit markers `entity:X` + `device:D` coexist and the device stays complete. A user-hidden live entity marker still owns X, while an entity tombstone returns X to the parent; disabled entity ownership follows the known full-registry relation [auto: unit `devices.test.mjs`]. - [ ] The #94 curtain boundary is deliberate: untouched hidden `cover.*` stays cover-first, but after placing the only visible auxiliary switch the hidden-only automatic remainder disappears. An explicit `device:D` restores the complete curtain beside that entity marker [auto: unit `devices.test.mjs`]. - [ ] Renderer and seeder cannot drift back to exact-binding-only ownership [mutation: `entity-marker-kept-in-parent-device`, `entity-marker-parent-seeded`]. - [ ] Filtering hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [manual] - [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [manual] - [ ] Light groups fold their single lamps; `group_lights=false` unfolds [manual] - [ ] Drag anywhere (no edit mode), snaps to grid, persists after reload, per space - [ ] ↺ reset restores auto layout after confirm - [ ] Temperature badge on thermometers; LQI value under zigbee icons with red→green color - [ ] Unified live states (dev, owner 2026-08-05; lock palette #219): actual work is yellow; open door/window and open valve are orange; unlocked lock is red and locked lock is green; covers stay neutral and morph their icon; unavailable is faded. The plate and the activity effect come from the same semantic resolver - [ ] State icons (v1.26.0): auto icons morph with state — door/window/garage open↔closed, lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [manual] - [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit) as its body, small badges hidden; non-numeric fallback keeps the icon [manual] - [ ] RGB lights (v1.27.0, contract changed in v1.52.0): a lamp's colour lives in its glow spot and the activity-effect fallback ONLY — the icon/badge/border get no RGB tint; explicit activity color still wins; off lights unchanged [auto: smoke_light_badges + smoke_rgb_alarm] - [ ] Alarm pulse (v1.27.0, unified dev): leak/smoke/gas/CO/siren in `on` and an alarm control panel in `triggered` get a red plate and red pulse over every dynamic display mode and even with ordinary live-state dressing off; `static_icon` is the deliberate neutral exception after an editor warning; clears on 'off'; unavailable never alarms [manual]; reduced-motion is static - [ ] Render cost (v1.43.1, audit L1): geometry (space model, open pairs) is computed once per config change, not per HA state push — smoke asserts zero recomputations across 10 state pushes and recomputation after an edit; the plan still renders dashes/islands correctly [auto: smoke_render_perf] - [ ] Opening tap vs drag (v1.43.1, audit L4): a tap on a door in the Plan editor opens its properties (3 px threshold like the other pipelines) and writes nothing; a real drag that ends where it started also writes nothing [auto: smoke_render_perf] - [ ] Concave containment (v1.43.1, audit G2): an island room inside a U- or L-shaped parent is accepted and punches the evenodd hole; a traced duplicate outline is still NOT containment [auto: smoke_inert_openings] - [ ] Backend hardening (v1.43.1, audit B2-B5): the admin check fails closed when the entry is unavailable; layout/set and config/set without expected_rev may bootstrap revision zero, but over a non-empty store each returns `conflict` without changing its document/rev/backups or firing an event (including a no-op body); explicit stale revisions are rejected; a production config-writer inventory requires expected_rev; NaN/Infinity coordinates and oversized collections are rejected [auto: tests_backend/test_ha_websocket.py + coordinate-write-barrier-guard.test] - [ ] Save race (v1.43.0, audit L2): make a markup edit, then press Save in any dialog within 500 ms (or let another client save) — the markup edit must survive and reach the server; a failed reload now shows a toast [auto: unit: tests_backend] - [ ] Niche split (v1.43.0, audit G1): a cut that starts AND ends on the same wall carves a niche; the two parts' areas must sum to the original (the invariant is enforced in code and asserted for every split test) [auto: smoke_save_race] - [ ] Authenticated content (v1.43.0, audit B1): plan images and marker files are only reachable through /api/houseplan/content/… with a session; the old /houseplan_files/plans|files paths return 404 after a restart; old stored URLs keep working (rewritten on read) [auto+manual] - [ ] Every editor option is storable (v1.45.3, issue #3): set a sensor to "value instead of an icon" and save — no validation error, the value shows on the plan after a reload. Same for each tap action and each fill mode [auto: backend test_every_display_mode_the_editor_offers_is_accepted and neighbours, test_a_marker_showing_its_value_can_be_saved] - [ ] Room settings button (dev): detached from the (movable) name label — always at the room's geometric centre, one button-height below it; sized at 70% of a device icon and zooming WITH the plan; the small metric rows under the room name now show in the plan editor too [auto: smoke_room_cards gearDetached/plainInPlan] - [ ] Working plate remains universal: in a source-glow space a lit lamp or other actually working device stays yellow in View and in every editor; the glow pool is an additional spatial indicator, not a replacement [auto: smoke_light_badges] - [ ] Size/angle parity (v1.52.1, HP-1513-01): a marker with size 3 / angle 37 scales x3 and rotates on BOTH cards [auto: smoke_size_angle_parity] - [ ] Tap runs an automation (dev, owner's spec 2026-07-29): the tap-action list has "Run automation/script/scene" with a searchable picker; saving without a target is refused; the confirm checkbox guards toggle AND run (our dialog, Esc/cancel = no call); automation.trigger / script.turn_on / scene.turn_on per domain; a deleted target toasts and calls nothing [auto: smoke_tap_run + unit resolveToggleIntent/runServiceFor + backend test_run_target_is_bounded_to_runnable_domains] - [ ] Universal Toggle state (#94): the option is visible for device, entity and virtual markers; the separate Open/close option is absent. The hint names the exact target(s), current state, next effect and skipped refs. Exact entity never retargets to a sibling; explicit controls never fall back to the controller; partial groups call exactly the shown available subset; a no-target tap is a quiet no-op. Locks, alarm panels and garage/door/gate covers are explained secure no-ops. Cover/valve use closed→open, open→close, moving→stop when supported, otherwise their domain toggle. Confirmation re-resolves current state but cancels if the target set changed. Opening and saving an untouched legacy `cover` or an absent light default preserves the original token/absence; an intentional selector edit writes `toggle`. Feature-gated climate/water-heater/siren/ camera/media-player/legacy-vacuum entities require their exact HA bits; an empty service catalog is unsupported. If #73 retains an older visual device while live controls change, click calls only the current controls [auto: test/device-toggle.test.mjs + smoke_cover_tap + smoke_cover_not_primary + smoke_controls + backend action-schema parity] The synthetic HA fixture publishes its service catalog explicitly, so browser checks exercise the same fail-closed resolver as production - [ ] A cover is NEVER painted (dev, owner 2026-08-04): «у штор не должно быть жёлтой подложки никогда, индикация открыто/закрыто за счёт морфинга иконки». Walk one curtain through closed / open / ajar / opening / closing: the plate is the plain neutral badge every time — never the yellow «включено» one, never the orange «открыто» frame it used to wear while open — the icon is the only open/closed signal, and the breathing `.activity-transition` ring appears in the two travelling states and nowhere else when «Icon + activity» is selected. The morph is exhaustive: every device class gives two DIFFERENT glyphs (awning included), a cover with no device_class morphs within its own auto-icon family (mdi:roller-shade, mdi:garage-variant), a hand-picked icon morphs only inside the pair it was picked from, and an unknown/unavailable state morphs nothing. NOT touched: an open door / window binary sensor and an open valve still wear the orange «открыто» frame (a valve has no icon pair, so the frame is all it has); lock keeps its separate red-unlocked/green-locked palette [auto: smoke_cover_no_plate + unit stateIcon «every class, both ways»] - [ ] Cover target and indication parity: on a device where a hidden functional `cover.*` competes with an auxiliary option switch, the shared resolver selects the cover, calls only it and supplies the same entity to icon morph/activity. A mixed lamp+cover remains a lamp unless the universal toggle result actually selects the cover. Cover presentation remains neutral in every state and cannot be painted yellow by its controls; secure cover classes call nothing and never fall back to info [auto: smoke_cover_not_primary + smoke_cover_plate_precedence + test/device-toggle.test.mjs] - [ ] Light-source badges (current contract): in glow fill a lit lamp's badge stays yellow just like a lit socket; the pool keeps the source's RGB while the marker keeps semantic yellow. Other fills behave identically; morphing and the activity-colour fallback survive [auto: smoke_light_badges + smoke_rgb_alarm] - [ ] Icon size multiplier scales the glyph (dev): set a marker's size to 3 — the icon inside grows with the badge instead of staying default [auto: smoke_icon_scale] - [ ] Auto-grid parity (v1.51.2, HP-1511-01): with an empty layout, a visible device among hidden ones sits at the same spot on both cards [auto: smoke_hidden_flag autoGridParity] - [ ] Activity ghost (v1.51.2, unified dev): a hidden icon+activity marker shows its base icon and no effect [auto: smoke_hidden_flag rippleGhost*] - [ ] Hidden LQI parity (v1.51.1, HP-1510-01): a room whose only Zigbee devices are hidden paints the same lqi fill on the full and the static card [auto: smoke_hidden_flag lqiParity] - [ ] Ghost shows no numbers (v1.51.1, HP-1510-02): a hidden value-display device renders as a plain ghost — no value/temp/hum/LQI, no icon morph [auto: smoke_hidden_flag ghostHidesValue] - [ ] Hide-from-plan flag (dev, docs/FILTERING.md): every existing device dialog has a bottom-left "Hide" / "Show" action, incl. virtual; the change is applied by Save; hidden devices vanish from every mode and the count, still count toward room LQI, cast no glow/light fill; the device editor's "Show hidden" (local, per tab) shows them as BLUE dashed ghosts — distinct from a grey unavailable icon — with NO live state paint (no yellow, no alarm, no activity); showing and saving keeps a hidden:false marker (re-seed protection); an old config materialises on first load by an editing client and legacy clients keep the old behaviour until then [auto: smoke_hidden_flag + unit seedHiddenBindings/seeded/legacy] - [ ] Yellow means working (dev): a TRV whose hvac_action is heating glows yellow; one that is merely enabled (idle) or has a service switch on (anti-scaling, child lock) stays dark; a lit light turns its state on by the same condition that lights the glow pool and shows the yellow badge even where that pool is drawn [auto: smoke_yellow_principle + smoke_light_badges] - [ ] Activity baseline (beta.10 audit): rebuilding the device registry seeds the current snapshot immediately, so the very first later motion/event transition is detected. Rebinding a marker's effective source clears the old source's finite flash in the same update [auto: smoke_motion_sense] - [ ] Editor gestures on touch (dev): in the plan editor on a phone, pinch zooms and a moving finger pans; releasing after a gesture does not draw a point, a clean tap still does [auto: smoke_editor_gestures] - [ ] Legacy geometry parity (v1.50.4, HP-1503-01): a store with a zero viewport and a negative rect renders identically sane in BOTH cards — full canvas fallback, normalised rectangle [auto: smoke_legacy_geometry] - [ ] Sizes are positive (v1.50.3, HP-1502-01): view_box or room w/h of zero or below is refused; a store that already holds one opens on the full canvas, not a blank screen [auto: test_sizes_are_not_coordinates + unit safeViewBox fallback] - [ ] Room card layout (v1.50.3): the settings button is the bottom row of the card and the room name sits in the same spot in view and plan modes [manual; verified by vb-coordinate measurement] - [ ] Geometry bounds (v1.50.2, HP-1501-01): a config with a 1e100 room vertex is refused by the server; one already stored still renders with a sane frame [auto: test_geometry_magnitudes_are_bounded + unit contentBounds legacy case] - [ ] No-op repair (v1.50.2, HP-1501-02): geometry/repair with a typo'd space id errors, moves no revision and keeps the previous backup undoable [auto: test_a_noop_repair_does_not_eat_the_backup] - [ ] Card below other dashboard content (v1.50.1, HP-1500-02): place the card after a tall card in a normal dashboard — the plan still gets most of the viewport instead of a zero-height stage [auto: smoke_zoom_out] - [ ] Frame never degenerate (v1.50.1, HP-1500-03): a space with one lone marker opens with canvas around it, not an empty scene; an absurd stored coordinate neither hides the plan nor is accepted by the server [auto: unit contentBounds + backend test_layout_coordinates_are_bounded] - [ ] Stranded migration repair (v1.50.1, HP-1500-01): geometry/repair with dry_run previews, applies with a backup, undo restores; wrong space is recoverable [auto: test_geometry_repair_is_explicit_previewable_and_undoable] - [ ] Editors see the whole canvas (v1.50.0, HP-1490-03): a hand-drawn space with one small room opens content-fit in View; switching to the plan editor shows the full square with room to draw a second room far away; back to View restores the content fit [auto: smoke_audit_1490] - [ ] Save waits for a picked plan's proportions (v1.50.0, HP-1490-04): pick a saved plan and hit Save before the thumbnail loads — the stored aspect is the real one, never the previous file's [auto: smoke_audit_1490] - [ ] Zoom goes below the fit (v1.50.0): minus past 100% floats the plan centred, floor at 0.4x; entering an editor keeps the stage inside the viewport [auto: smoke_zoom_out] - [ ] Migration crash recovery (v1.50.0, HP-1490-01): kill HA between the two store writes of the square migration — the next start finishes the layout half from the saved intent [auto: test_square_migration_finishes_after_a_crash_between_the_writes] - [ ] Parallel upload quota (v1.50.0, HP-1490-02): two simultaneous uploads with one slot left — exactly one succeeds [auto: test_parallel_uploads_cannot_slip_past_the_quota_together] - [ ] Zoom opens on the content (v1.49.0): a space with no background and one small room opens with that room filling the screen, with a small margin. With a background it still fits the whole image [auto: unit: contentBounds] - [ ] Deleting a picked plan is refused (v1.49.0, HP-1470-02): pick a saved plan, reopen the list — its delete button is disabled. Ask the server to store a plan url whose file is gone: `missing_plan`, and the revision does not move [auto: smoke_saved_plans + backend test_config_set_refuses_a_plan_that_no_longer_exists] - [ ] Portable import rechecks local content under its paired-write lock: a plan or marker PDF deleted after preview fails with `missing_plan` or `missing_content`, and neither store advances [auto: backend test_apply_rechecks_plan_file_under_the_write_lock + test_apply_rechecks_attachment_under_the_write_lock] - [ ] Uploads are bounded (v1.49.0, HP-1470-01): past the store quota an upload is refused with a clear error and the disk does not grow; the plan list returns the newest 60 with a total [auto: unit: test_check_quota_counts_the_whole_store_not_one_request, backend test_uploads_are_bounded_by_a_store_quota] - [ ] An attachment already written to staging is not reserved twice: with the real 512 MiB disk reserve intact its same-filesystem promotion succeeds, while one byte below the reserve still fails. Uploads checked before any bytes are written continue to reserve their full incoming size [auto: backend test_issue_554_low_disk_reserve_distinguishes_staged_and_unwritten_bytes + test_issue_554_upload_uses_actual_free_space_after_staging; mutation: quota-reserves-staged-bytes-twice-on-disk] - [ ] Square canvas migration (v1.48.0): after the upgrade every existing plan looks exactly as before, just with margins where the canvas was extended. Measure a wall in the plan editor — the length in cm is unchanged. Marker positions, doors, decor and the saved zoom are all where they were [auto: unit: test_a_wide_plan_gains_margins_above_and_below and neighbours, test_migration_preserves_real_lengths_and_shapes] - [ ] A plan image is centred (v1.48.0): a wide image sits in the middle with empty bands above and below, a tall one with bands at the sides, and it is never stretched [auto: unit: fitInSquare + smoke_space_settings] - [ ] Re-attaching a detached plan (v1.47.0): detach a plan, save, RELOAD THE PAGE, open space settings → "Already uploaded" → the image is listed with its size and no "in use" note → attach it → it renders. The one a space uses shows that space and cannot be deleted; a free one can, with a confirm, and disappears from the list [auto: smoke_saved_plans + backend test_stored_plans_can_be_listed_and_deleted_on_request] - [ ] Detaching a plan keeps the file (v1.46.6): switch a space to "draw" and SAVE — the image is still in `config/houseplan/plans/` right afterwards, and after a restart, and can be re-attached. Deleting the space keeps it too. Replacing a plan still removes the one it replaced, immediately. Check straight after the save: the earlier bug deleted the file at that moment, while every scheduled-pass test passed [auto: unit: test_plan_collection_matrix, test_attachment_collection_matrix, backend test_detaching_a_plan_keeps_the_file] - [ ] Rebinding a device does not eat its manuals (v1.46.5): attach two files to a device, rebind it to another HA device — both are readable afterwards. If a copy failed, the file it failed on is still there rather than deleted with the folder [auto: backend test_files_cleanup_keeps_referenced_files] - [ ] Nothing accumulates on an idle instance (v1.46.2/v1.46.3, HP-1461-01, HP-1462-01): attach a file, cancel the dialog, and do not save anything else — the file is gone after a restart AND after the daily pass, while every file the configuration still references is untouched. Seed the strays AFTER the last save, or `config/set` collects them and the check proves nothing [auto: backend test_startup_sweep_collects_what_no_commit_will, test_daily_sweep_callback_collects_too, test_sweep_and_a_config_write_do_not_race] - [ ] A drag wins over a concurrent remote move (v1.46.2, HP-1461-02): drag an icon and, while the save is still in flight, have another window move a different icon — your icon stays where you put it and the other one updates [auto: smoke_layout_sync] - [ ] Concurrent uploads of one name (v1.46.1, HP-1460-01): attach the same file from two browser tabs at once — two attachments, two sets of bytes, neither lost. A file whose name is at the length limit still downloads [auto: unit: test_reserve_filename_is_safe_under_concurrency and neighbours] - [ ] No temporary files survive (v1.46.1, HP-1460-02): abort a large upload mid-transfer, send two files in one request, make promotion fail — in each case the files folder holds no `.upload-*`. An old one is swept at startup [auto: backend test_upload_leaves_no_temporary_behind + unit: sweep_upload_temps] - [ ] Two full cards agree on positions (v1.46.1, HP-1460-03): open the plan in two windows, drag an icon in one — it moves in the other without a reload; a drag in progress in the second window is not thrown away [auto: smoke_layout_sync] - [ ] Uploaded SVG is inert as a document (v1.46.0, HP-1454-01): open a plan's signed url directly in a tab — a `