Compare commits

...
169 Commits
Author SHA1 Message Date
Matysh ce07db748e Witness synchronous warm confirmation refusal
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 1m40s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 1m54s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 2m2s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 2m6s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 1m44s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 1m34s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 1m55s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 39s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 2m0s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 56s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 8m55s
Issue: #470
User-Visible: no
2026-09-05 22:59:28 +03:00
Matysh 5ff97858f8 Repair escaped mutation witnesses
Issue: #466
Issue: #467
Issue: #468
Issue: #469
User-Visible: no
2026-09-05 22:13:25 +03:00
Matysh 855b62729d Fix isolated pure-module test loading
Issue: #465
User-Visible: no
2026-09-05 21:01:29 +03:00
Matysh 0a955f5df5 Release v1.72.0
Promote the published v1.72.0 beta line without new product behaviour: stable version fields, synchronized generated bundles, bilingual changelogs, release notes and status metadata only.

Issue: #54
Release: v1.72.0
User-Visible: no
2026-09-05 20:22:19 +03:00
Matysh 198484f762 Merge main into dev before v1.72.0 promotion 2026-09-05 20:10:32 +03:00
Matysh 2558d68f06 build: prepare v1.72.0-beta.5 candidate
Issue: #461
Issue: #462
Issue: #463
User-Visible: yes
Release: v1.72.0-beta.5
2026-09-05 18:52:59 +03:00
claude[bot] 7ab12294fc docs: review document for #463
Проверка (CI) / Классификация изменённых файлов (push) Successful in 23s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 59s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 26s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 25s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 41s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 4m17s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 6m24s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #463
User-Visible: no
2026-09-05 15:22:56 +00:00
Matysh 23c65e449e test: accept corrected dialog baselines
Issue: #463
User-Visible: no
Release: v1.72.0-beta.5
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33973294353
2026-09-05 18:05:19 +03:00
Matysh 1856302cd3 fix: recover native dialog modal placement
Issue: #463
User-Visible: yes
2026-09-05 17:53:16 +03:00
claude[bot] 523bd4569d docs: review document for #463
Issue: #463
User-Visible: no
2026-09-05 14:38:15 +00:00
claude[bot] f6956fa472 docs: review document for #463
Issue: #463
User-Visible: no
2026-09-05 14:31:44 +00:00
claude[bot] fc0c616698 docs: review document for #462
Issue: #462
User-Visible: no
2026-09-05 14:06:29 +00:00
Matysh daccaf7ea5 test: accept version recovery baselines
Issue: #462
User-Visible: no
Release: v1.72.0-beta.5
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33969189246
2026-09-05 16:40:38 +03:00
Matysh 12e5728a17 docs: accept final screenshot capture
Issue: #462
User-Visible: no
2026-09-05 16:35:53 +03:00
Matysh 65d38b2403 fix: keep continuity cache read-only until load
Issue: #462
User-Visible: no
2026-09-05 16:31:54 +03:00
Matysh 21a1993015 docs: accept unchanged screenshot capture
Issue: #462
User-Visible: no
2026-09-05 16:15:27 +03:00
Matysh dc032dcb3e fix: sort manifest keys for hassfest
Issue: #462
User-Visible: no
2026-09-05 16:15:26 +03:00
Matysh 152a6ca5cc test: isolate version recovery reload guards
Issue: #462
User-Visible: no
2026-09-05 16:10:19 +03:00
Matysh fcf0ce80eb fix: make card resource recovery observable
Issue: #462
User-Visible: yes
2026-09-05 16:04:54 +03:00
claude[bot]andMatysh a4fe939f63 docs: review document for #462
Issue: #462
User-Visible: no
2026-09-05 16:00:54 +03:00
Matysh 248c926a17 docs: cover native more-info reload guard
Issue: #462
User-Visible: no
2026-09-05 16:00:54 +03:00
Matysh 0f5c3d8967 docs: cite Home Assistant resource mode contract
Issue: #462
User-Visible: no
2026-09-05 16:00:54 +03:00
claude[bot]andMatysh ea2ec19b71 docs: review document for #462
Issue: #462
User-Visible: no
2026-09-05 16:00:54 +03:00
Matysh 282e52cc73 docs: close resource recovery ambiguities
Issue: #462
User-Visible: no
2026-09-05 16:00:54 +03:00
Matysh 46d43a29be docs: resolve resource recovery review
Issue: #462
User-Visible: no
2026-09-05 16:00:54 +03:00
claude[bot]andMatysh 0466a02dfd docs: review document for #462
Issue: #462
User-Visible: no
2026-09-05 16:00:54 +03:00
Matysh 69207f5757 docs: tighten resource recovery contract
Issue: #462
User-Visible: no
2026-09-05 16:00:54 +03:00
Matysh 6b77d598fd docs: specify frontend resource recovery
Issue: #462
User-Visible: no
2026-09-05 16:00:54 +03:00
claude[bot] fcc4125c6e docs: review document for #461
Issue: #461
User-Visible: no
2026-09-05 11:46:31 +00:00
Sergey Matyunin 41e6eef941 test: prove wall-draw junction parity
Issue: #461
User-Visible: no
2026-09-05 14:39:00 +03:00
claude[bot] 13ab4b12b7 docs: review document for #461
Issue: #461
User-Visible: no
2026-09-05 11:36:18 +00:00
Sergey Matyunin 34d53bf2d1 docs: refresh screenshot source fingerprint
Issue: #461
User-Visible: no
2026-09-05 14:14:35 +03:00
Sergey Matyunin 04a1de091c fix: speed up intermediate wall-chain commits
Issue: #461
User-Visible: yes
2026-09-05 14:04:26 +03:00
claude[bot] 9bdf531c1a docs: review document for #461
Issue: #461
User-Visible: no
2026-09-05 10:33:53 +00:00
Sergey Matyunin 1ef21037a8 docs: specify wall draw click performance
Issue: #461
User-Visible: no
2026-09-05 13:26:53 +03:00
Sergey Matyunin 16b4c2d6d3 build: prepare v1.72.0-beta.4 candidate
Issue: #456
Issue: #457
Issue: #459
Issue: #460
User-Visible: yes
2026-09-05 12:32:06 +03:00
claude[bot] 9ee60993ff docs: review document for #460
Issue: #460
User-Visible: no
2026-09-05 09:26:21 +00:00
Sergey Matyunin dfb4efaa1c docs: refresh screenshot source fingerprint
Issue: #460
User-Visible: no
2026-09-05 12:15:43 +03:00
Sergey Matyunin 5d09e722e4 test: link live editor contract to browser smokes
Issue: #460
User-Visible: no
2026-09-05 12:10:26 +03:00
Sergey Matyunin 5763c6aec0 fix: settle live editor frames deterministically
Issue: #460
User-Visible: yes
2026-09-05 12:10:26 +03:00
claude[bot]andSergey Matyunin 570da3dd1a docs: review document for #460
Issue: #460
User-Visible: no
2026-09-05 12:08:34 +03:00
Sergey Matyunin ca2a00231b docs: specify live editor settlement
Issue: #460
User-Visible: no
2026-09-05 12:08:34 +03:00
claude[bot] 833c9a2a33 docs: review document for #459
Issue: #459
User-Visible: no
2026-09-05 08:46:15 +00:00
claude[bot]andCodex 50bc1d6955 docs: review document for #459
Issue: #459
User-Visible: no
2026-09-05 11:18:37 +03:00
Codex ba47395337 docs: обновить отпечаток источника скриншотов
User-Visible: no
Issue: #459
2026-09-05 11:15:26 +03:00
Codex 33f16dd3cc chore: пересобрать бандл после ребейза на dev
User-Visible: no
Issue: #459
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33953825960
2026-09-05 11:13:43 +03:00
Codex ef5bfa4bcd test: принять эталон с кружком справки у связей Zigbee
Issue: #459
User-Visible: no
Release: v1.72.0-beta.4
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33953342176
2026-09-05 11:10:44 +03:00
Codex b4898583d7 feat: подсказка к настройке связей Zigbee
User-Visible: yes
Issue: #459
2026-09-05 11:10:44 +03:00
claude[bot] 7320937f62 docs: review document for #457
Проверка (CI) / Классификация изменённых файлов (push) Successful in 31s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 43s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 47s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Issue: #457
User-Visible: no
2026-09-05 08:07:26 +00:00
Sergey Matyunin d80805acec docs: refresh Zigbee route review fingerprint
Issue: #457
User-Visible: no
2026-09-05 10:52:55 +03:00
Sergey Matyunin 896ceecf3a fix: address Zigbee route review
Issue: #457
User-Visible: no
2026-09-05 10:50:19 +03:00
claude[bot]andSergey Matyunin 7cb1eee386 docs: review document for #457
Issue: #457
User-Visible: no
2026-09-05 10:50:19 +03:00
Sergey Matyunin da22b87fe1 docs: refresh optimized source fingerprint
Issue: #457
User-Visible: no
2026-09-05 10:50:19 +03:00
Sergey Matyunin 41b8e71248 perf: keep Zigbee route overlay within bundle guard
Issue: #457
User-Visible: no
2026-09-05 10:50:19 +03:00
Sergey Matyunin 58b1651570 docs: refresh screenshot source fingerprint
Issue: #457
User-Visible: no
2026-09-05 10:50:19 +03:00
Sergey Matyunin 793d06c75e feat: show Zigbee routes to coordinator
Issue: #457
User-Visible: yes
2026-09-05 10:50:19 +03:00
claude[bot]andSergey Matyunin d13ecf1237 docs: review document for #457
Issue: #457
User-Visible: no
2026-09-05 10:50:19 +03:00
Sergey Matyunin a63aef0239 docs: specify Zigbee route arrows
Issue: #457
User-Visible: no
2026-09-05 10:50:19 +03:00
claude[bot] b04e4aacce docs: review document for #459
Issue: #459
User-Visible: no
2026-09-05 07:36:52 +00:00
claude[bot] c81d1d9775 docs: review document for #459
Issue: #459
User-Visible: no
2026-09-05 07:25:50 +00:00
claude[bot] 6af6d7b388 docs: review document for #456
Проверка (CI) / Классификация изменённых файлов (push) Successful in 23s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m5s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 48s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 19s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 24s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 7m4s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 10m10s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #456
User-Visible: no
2026-09-05 06:37:26 +00:00
Sergey Matyunin 7dd5b4798a test: cover the space copy workflow
Issue: #456
User-Visible: no
Release: v1.72.0-beta.4
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33948778925
2026-09-05 09:25:08 +03:00
claude[bot]andSergey Matyunin 5108202419 docs: review document for #456
Issue: #456
User-Visible: no
2026-09-05 09:25:08 +03:00
Sergey Matyunin 2c7879131f test: account for space copy action
Issue: #456
User-Visible: no
2026-09-05 09:25:08 +03:00
Sergey Matyunin 6dd9fb03c3 docs: refresh screenshot source fingerprint
Issue: #456
User-Visible: no
2026-09-05 09:25:08 +03:00
Sergey Matyunin b286d7ba43 feat: copy spaces without room bindings
Issue: #456
User-Visible: yes
2026-09-05 09:25:08 +03:00
claude[bot]andSergey Matyunin fe0c2e0c83 docs: review document for #456
Issue: #456
User-Visible: no
2026-09-05 09:25:08 +03:00
Sergey Matyunin aac9232bbc docs: specify space copy workflow
Issue: #456
User-Visible: no
2026-09-05 09:25:08 +03:00
Claude de30066be7 test(gates): мутанты на восемь модулей горячего пути из #451
#451 принёс 1 651 строку в восьми новых модулях, а единственный мутант
того релиза патчил houseplan-card.ts и houseplan-editor-runtime.ts —
места, ОТКУДА код ушёл. Вынос в модули был правильным решением, но
защита осталась смотреть на старые файлы.

Восемь мутантов, семь модулей, все прогнаны штатным раннером (чистый
прогон зелёный, мутант красный):

- live-editor-view-mode-routes-live — режим View обязан оставаться
  реактивным: живой путь редактора там означает план, который перестал
  отвечать на Home Assistant у двух персон из трёх;
- live-editor-first-gesture-frame-goes-live — первый кадр жеста меняет
  выделение и хром и обязан остаться реактивным;
- pointer-move-queue-keeps-first-move — очередь last-wins: сохранение
  ПЕРВОГО коллбэка кадра рисует план там, где палец уже не находится;
- live-hass-tick-never-deferred — тик состояния посреди жеста
  откладывается намеренно, и флаг отложенности гарантирует его
  воспроизведение после;
- live-viewport-identity-projection-not-recognized — см. ниже;
- render-invalidation-unknown-key-ignored — классификатор обязан
  ошибаться в сторону перерисовки на незнакомых ключах hass;
- resize-live-preflight-keeps-every-room — живой resize проверяет только
  задетые комнаты, иначе каждый кадр становится полной проверкой плана;
- render-lifecycle-diagnostics-cache-never-invalidated — кэш диагностики
  обязан сбрасываться, иначе красная точка нового устройства не загорится.

Мутант на live-viewport пришлось заменить, и это стоит записать. Issue
предлагал снять `setLayerProjection(layer, null)` из
`commitHouseplanViewport` — прогон показал, что тест остаётся ЗЕЛЁНЫМ:
следом идёт `paintLiveViewport(root, painted, painted)`, который на
равных аргументах даёт identity и обнуляет проекцию сам. Тот цикл —
подстраховка, а не контракт. Настоящий контракт — распознавание identity
(`isIdentityLiveLayerProjection`), потому что даже единичный transform
переключает путь композитинга и сдвигает установившийся растр на
несколько уровней цвета. Мутируется теперь он.

`src/live-hover.ts` остался без мутанта сознательно: его контракты либо
чисто производительные (мемо по наведённой комнате), либо доменные
(подсветка комнаты, застрявшая после ухода курсора). Первое мутантом не
ловится в принципе, второе — только браузерным смоком, которого в
песочнице нет. Записано в тесте и в issue.

Чтобы требование не жило в памяти, добавлен гейт: у каждого модуля
горячего пути обязан быть свой мутант, и он не имеет права патчить
houseplan-card.ts или houseplan-editor-runtime.ts — то есть исходный
дефект #458 больше не воспроизводим молча. Проверено отрицательным
прогоном: перевод патча любого из модулей на старое ядро краснит гейт.

Гейты: npm test 1960 tests, 1959 pass, 0 fail; typecheck зелёный;
mutation-gate --check зелёный на всех якорях; каждый из восьми мутантов
прогнан отдельно.

Issue: #458
User-Visible: no
2026-09-05 09:08:09 +03:00
Sergey Matyunin 6ef0804bc4 build: prepare v1.72.0-beta.3 candidate
Issue: #451
User-Visible: yes
2026-09-05 07:43:20 +03:00
Sergey Matyunin 356b260035 merge: integrate #451 render performance
Owner arbitration accepts the reviewed tip without a fifth review cycle.

Issue: #451
User-Visible: yes
2026-09-05 07:36:49 +03:00
Sergey Matyunin 3d0fe7d756 docs: record beta screenshot acceptance environment
Issue: #455
User-Visible: no
2026-09-05 02:40:25 +03:00
Sergey Matyunin 5adada070f build: prepare v1.72.0-beta.2 candidate
Issue: #449
Issue: #450
Issue: #452
Issue: #454
User-Visible: yes
2026-09-05 02:36:19 +03:00
Sergey Matyunin 5bd0c4a033 docs: refresh screenshot fingerprint for #451
Проверка (CI) / Классификация изменённых файлов (push) Successful in 34s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 45s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 41s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Issue: #451
User-Visible: no
2026-09-05 02:18:50 +03:00
Sergey Matyunin 588f1188c3 fix: validate resize across adjacent rooms
Issue: #451
User-Visible: no
2026-09-05 02:16:06 +03:00
claude[bot] 17bfdaaed0 docs: review document for #451
Issue: #451
User-Visible: no
2026-09-04 23:11:33 +00:00
Sergey Matyunin 07ba2ffbd1 docs: refresh screenshot fingerprint for #451
Issue: #451
User-Visible: no
2026-09-05 01:49:27 +03:00
Sergey Matyunin 5692a2880f fix: preserve live resize validation on large plans
Issue: #451
User-Visible: no
2026-09-05 01:46:26 +03:00
claude[bot] d7e97687ce docs: review document for #451
Issue: #451
User-Visible: no
2026-09-04 22:11:57 +00:00
Sergey Matyunin 444562e47c docs: refresh screenshot fingerprint for #451
Issue: #451
User-Visible: no
2026-09-05 00:44:37 +03:00
Sergey Matyunin 046efe9613 fix: restore resize DOM after live reconciliation
Issue: #451
User-Visible: no
2026-09-05 00:38:19 +03:00
claude[bot] d513f13afa docs: review document for #451
Issue: #451
User-Visible: no
2026-09-04 21:19:52 +00:00
Sergey Matyunin a7bee0f5fb docs: refresh screenshot fingerprint for #451
Issue: #451
User-Visible: no
2026-09-05 00:19:16 +03:00
Sergey Matyunin 4b6b4abc70 fix: preserve bounded resize preflight for #451
Issue: #451
User-Visible: no
2026-09-05 00:16:10 +03:00
Sergey Matyunin 8d787530b1 docs: refresh screenshot fingerprint for #451
Issue: #451
User-Visible: no
2026-09-04 23:56:57 +03:00
Sergey Matyunin fe03eae213 fix: satisfy render review gates
Issue: #451
User-Visible: no
2026-09-04 23:54:09 +03:00
Sergey Matyunin cb68492c99 fix: address render performance review
Issue: #451
User-Visible: no
2026-09-04 23:45:57 +03:00
claude[bot] f8b3445dc3 docs: review document for #451
Issue: #451
User-Visible: no
2026-09-04 19:47:08 +00:00
Sergey Matyunin 1850eb18b5 chore: rebuild bundle after dev rebase
Issue: #451
User-Visible: no
2026-09-04 22:08:20 +03:00
Sergey Matyunin 5254fb9ef5 fix: reconcile lightweight interaction frames
Issue: #451
User-Visible: no
2026-09-04 22:04:17 +03:00
Sergey Matyunin 67c000f17b docs: refresh render performance screenshot fingerprint
Issue: #451
User-Visible: no
2026-09-04 22:04:09 +03:00
Sergey Matyunin 8ef0b6a1fa fix: clear settled viewport compositing
Issue: #451
User-Visible: no
2026-09-04 22:04:09 +03:00
Sergey Matyunin 5d594e70c8 fix: satisfy render performance validation gates
Issue: #451
User-Visible: no
2026-09-04 22:04:03 +03:00
Sergey Matyunin c0d61ca3d2 fix: keep large-plan interactions off the full render path
Issue: #451
User-Visible: yes
2026-09-04 22:03:45 +03:00
claude[bot]andSergey Matyunin 137e160f1a docs: review document for #451
Issue: #451
User-Visible: no
2026-09-04 22:03:23 +03:00
Sergey Matyunin 66c2eec1f9 docs: address render performance spec review
Issue: #451
User-Visible: no
2026-09-04 22:03:23 +03:00
claude[bot]andSergey Matyunin 338700bdcb docs: review document for #451
Issue: #451
User-Visible: no
2026-09-04 22:03:22 +03:00
Sergey Matyunin 029aba6b88 docs: specify render performance fix
Issue: #451
User-Visible: no
2026-09-04 22:03:22 +03:00
Codex 0cd5145df0 ci: считать раунды ревью по опубликованным документам
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 1m56s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 1m45s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 2m0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 1m57s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 51s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 1m53s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 1m48s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 30s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 44s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 1m54s
Зеркало process.yml из dev: конвейер исполняет версию из ветки по умолчанию.

User-Visible: no
Issue: #454
2026-09-04 20:45:58 +03:00
claude[bot] 5784a0e271 docs: review document for #454
Проверка (CI) / Классификация изменённых файлов (push) Successful in 25s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 51s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 53s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 19s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 18s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 7m16s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 9m37s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #454
User-Visible: no
2026-09-04 17:45:17 +00:00
Codex 2a883217c1 chore: пересобрать бандл под новый отпечаток исходников
Отпечаток исходников включает package.json, а 28a4cb5e (#455) его изменил
без bundle:sync. dev красный с 16:27 UTC: CI пересобирает dist и сравнивает с
коммиченной копией — расходятся ровно строкой __HOUSEPLAN_BUILD_FINGERPRINT__
и производными от неё именами чанков. Код чанков побайтово тот же, проверено
diff-ом. Ветка #454 унаследовала красноту ребейзом.

User-Visible: no
Issue: #454
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33895243347
2026-09-04 20:26:24 +03:00
Codex d5e114524c fix: комментарий засчитывается вердиктом только по документу своей задачи
User-Visible: no
Issue: #454
2026-09-04 20:18:59 +03:00
claude[bot] 9e7ae520a8 docs: review document for #454
Issue: #454
User-Visible: no
2026-09-04 17:15:02 +00:00
Codexandclaude[bot] 4b443d8cf5 fix: не считать описание чужого раунда объявлением вердикта
User-Visible: no
Issue: #454
2026-09-04 16:58:31 +00:00
Codexandclaude[bot] dc76c5b2b8 test: контракт на вызов счётчика раундов из guard
User-Visible: no
Issue: #454
2026-09-04 16:58:31 +00:00
Codexandclaude[bot] 8d8263202f ci: считать раунды ревью по опубликованным документам
User-Visible: no
Issue: #454
2026-09-04 16:58:31 +00:00
claude[bot] cc9387163e docs: review document for #454
Issue: #454
User-Visible: no
2026-09-04 16:58:31 +00:00
Codexandclaude[bot] f70a973834 docs: spec #454 — уточнить AC2 по замечанию ревью
User-Visible: no
Issue: #454
2026-09-04 16:58:31 +00:00
claude[bot] 36b4e1cf31 docs: review document for #454
Issue: #454
User-Visible: no
2026-09-04 16:58:31 +00:00
Codexandclaude[bot] 5016aa01cc docs: specify review round counting from published artifacts
User-Visible: no
Issue: #454
2026-09-04 16:58:31 +00:00
Claude 28a4cb5eec fix(gates): съёмка кадров в чужой среде отказывается заранее
Вопрос владельца: зачем агенты снимают PNG на Windows, если кадры мы всё
равно не принимаем, тем более что WSL есть на обеих машинах. Ответ по
коду: этому ничто не мешало. Ни один из шести скриптов съёмки и приёмки
не знал, на какой он ОС — ни `process.platform`, ни win32, ни WSL не
упоминались нигде. Съёмка отрабатывала штатно, а стена появлялась на
приёмке, и текст стены говорил «сцен-свидетелей 0 из 10», то есть
подсказывал неверный вывод «надо объявить больше сцен» — от которого до
`--expect-change` на всю матрицу одна команда.

Причина запрета не политика, а физика: Windows растеризует текст через
DirectWrite, с другим субпиксельным сглаживанием и DPI, поэтому
байтового совпадения с принятым эталоном не даёт никогда, свидетелей
среды быть не может, и приёмка откажет всё равно. Флаги детерминизма из
#410 убирают разброс внутри среды, а не между ОС.

Что сделано:

- `golden:capture` отказывается до запуска браузера, в тексте отказа
  готовая команда для WSL;
- `golden:verify` остаётся законным в любой среде: он ничего не
  принимает, а как грубая проверка полезен;
- обе приёмки (golden и документации) отказываются в чужой среде;
- к отказу свидетелей приписывается фраза про расхождение среды — та
  самая, которой не хватало, чтобы отказ не читался как «объяви больше
  сцен»;
- платформа уезжает в манифесты рядом с версией Chromium: у кадров
  появился провенанс среды;
- осознанный обход есть и требует причину: HP_ALLOW_FOREIGN_CAPTURE.

Главный урок задачи — про цену правки файла, у которого записан хеш.
Первая редакция встроила проверку в `demo/docs/capture.mjs`, и гейт
документации сразу покраснел: его sha записан в индексе скриншотов, и
`scripts/check-docs.mjs` их сверяет. То есть проверка, которая ничего не
рисует, стоила бы пересъёмки всех картинок документации и визуальной
приёмки владельца. Поэтому для документации отказ живёт шагом раньше —
`npm run docs:capture` вызывает `scripts/assert-capture-env.mjs` — и
шагом позже, на приёмке. По той же причине гейт golden стоит в
`demo/golden/policy.mjs`, а не в `run.mjs`: последний входит в корпус
sourceFingerprint. Тест закрепляет обе границы: гейт обязан быть в
policy.mjs и в npm-скрипте и обязан отсутствовать в двух
фингерпринтуемых файлах.

Платформа в юнитах — параметр, а не `process.platform`: иначе тест был
бы зелёным на Linux и красным на машине владельца, то есть тестом про
хост, а не про правило.

AGENTS.md приведён к состоянию после #401 (принимается любая среда,
доказавшая себя байтовым совпадением непринятых кадров) и разводит
проверку и съёмку — прежний текст сливал их в «advisory» и утверждал
«accepted only on a complete Linux CI artefact».

Свидетели, все проверены отрицательным прогоном: снятый гейт съёмки,
гейт, отказывающий и на verify, обход без причины, отказ без команды,
убранная приписка про среду, отцепленные гейты обеих приёмок,
переставшая бросать обёртка, npm-скрипт без проверки и возврат гейта в
каждый из двух фингерпринтуемых файлов. Проверка подключения сначала
смотрела только на импорт модуля и молча проходила, когда отказ
заменяли на `void` — теперь она проверяет вызов бросающей обёртки.

Гейты: npm test 1918 tests, 1917 pass, 0 fail; typecheck зелёный;
check-docs зелёный (индекс скриншотов не задет); pytest без HA 378
passed, 3 skipped.

Issue: #455
User-Visible: no
2026-09-04 19:26:35 +03:00
claude[bot] 9e56842bc1 docs: review document for #449
Проверка (CI) / Классификация изменённых файлов (push) Successful in 37s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 36s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 57s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Issue: #449
User-Visible: no
2026-09-04 16:23:07 +00:00
Sergey Matyunin 1d8edf4a01 docs: refresh screenshot fingerprint for double fit
Issue: #449
User-Visible: no
2026-09-04 19:09:47 +03:00
Sergey Matyunin aa51ed2bf5 fix: complete double background fit delivery
Issue: #449
User-Visible: no
2026-09-04 19:06:29 +03:00
claude[bot] a158d75712 docs: review document for #449
Issue: #449
User-Visible: no
2026-09-04 16:01:44 +00:00
Sergey Matyunin 05ef318133 feat: fit all on double background tap
Issue: #449
User-Visible: yes
2026-09-04 18:33:03 +03:00
claude[bot]andSergey Matyunin b39f99b3a4 docs: review document for #449
Issue: #449
User-Visible: no
2026-09-04 18:18:14 +03:00
CodexandSergey Matyunin 85a064430b docs: name the three editors as the guide does
User-Visible: no
Issue: #449
2026-09-04 18:18:14 +03:00
claude[bot]andSergey Matyunin 070c276e26 docs: review document for #449
Issue: #449
User-Visible: no
2026-09-04 18:18:14 +03:00
CodexandSergey Matyunin 1a7488d695 docs: state the touch editor status in the double fit-all spec
User-Visible: no
Issue: #449
2026-09-04 18:18:14 +03:00
claude[bot]andSergey Matyunin 1ce62613ed docs: review document for #449
Issue: #449
User-Visible: no
2026-09-04 18:18:14 +03:00
Sergey Matyunin b8a31e90ca docs: specify double gesture fit all
Issue: #449
User-Visible: no
2026-09-04 18:18:13 +03:00
claude[bot] 5da19f1793 docs: review document for #450
Issue: #450
User-Visible: no
2026-09-04 15:06:10 +00:00
Sergey Matyuninandclaude[bot] a8ccb2b90f docs: refresh screenshot source fingerprint
Issue: #450
User-Visible: no
2026-09-04 14:58:04 +00:00
Sergey Matyuninandclaude[bot] 8935a736cb fix: accept Zigbee2MQTT raw network maps
Issue: #450
User-Visible: yes
2026-09-04 14:58:04 +00:00
Codex 411a0a20a9 docs: call the fit-all button by its real name in the Russian guide
Проверка (CI) / Классификация изменённых файлов (push) Successful in 23s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 42s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 49s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 19s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 17s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 7m1s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 9m36s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
User-Visible: no
Issue: #452
2026-09-04 17:54:42 +03:00
claude[bot] 89647c10bd docs: review document for #448
Issue: #448
User-Visible: no
2026-09-04 14:00:07 +00:00
Sergey Matyunin ed4ac972b3 docs: close alpha review findings
Issue: #448
User-Visible: no
2026-09-04 16:56:50 +03:00
claude[bot] ee9b28ed98 docs: review document for #448
Issue: #448
User-Visible: no
2026-09-04 13:55:34 +00:00
Sergey Matyunin 658f284b44 docs: refresh alpha screenshot source fingerprint
Issue: #448
User-Visible: no
2026-09-04 16:33:56 +03:00
Sergey Matyunin 169fed01f0 feat: replace Labs expiry with persistent alpha switch
Issue: #448
User-Visible: yes
2026-09-04 16:31:06 +03:00
claude[bot]andSergey Matyunin 7230e4248a docs: review document for #448
Issue: #448
User-Visible: no
2026-09-04 16:20:35 +03:00
Sergey Matyunin 0ec0785dfd docs: clarify alpha touch contract
Issue: #448
User-Visible: no
2026-09-04 16:20:34 +03:00
claude[bot]andSergey Matyunin e846833ed6 docs: review document for #448
Issue: #448
User-Visible: no
2026-09-04 16:20:34 +03:00
Sergey Matyunin ed0fdd4859 docs: specify persistent alpha switch
Issue: #448
User-Visible: no
2026-09-04 16:20:34 +03:00
Sergey Matyunin 112ec0eb66 test: accept Zigbee settings golden frames
Issue: #54
User-Visible: no
Release: v1.72.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33875831578
2026-09-04 16:11:16 +03:00
Sergey Matyunin 775e55f0ed build: prepare v1.72.0-beta.1 candidate
Issue: #54
Issue: #152
Issue: #447
User-Visible: yes
2026-09-04 16:01:33 +03:00
claude[bot] 3f9ef807d8 docs: review document for #54
Issue: #54
User-Visible: no
2026-09-04 12:39:19 +00:00
Sergey Matyunin f0ed526a59 docs: refresh screenshot source fingerprint
Issue: #54
User-Visible: no
2026-09-04 15:30:04 +03:00
Sergey Matyunin 4203a01b04 fix: reject malformed Zigbee topology replies
Issue: #54
User-Visible: no
2026-09-04 15:26:06 +03:00
claude[bot] aab5c3ca95 docs: review document for #54
Issue: #54
User-Visible: no
2026-09-04 12:20:34 +00:00
Sergey Matyunin a2867df9a2 docs: refresh screenshot source fingerprint
Issue: #54
User-Visible: no
2026-09-04 14:48:27 +03:00
Sergey Matyunin 2202b20730 refactor: type Zigbee topology boundaries
Issue: #54
User-Visible: no
2026-09-04 14:45:39 +03:00
Sergey Matyunin 85702afc0c feat: add contextual Zigbee topology hover
Issue: #54
User-Visible: yes
2026-09-04 14:40:09 +03:00
claude[bot] e5587aed7c docs: review document for #54
Issue: #54
User-Visible: no
2026-09-04 11:11:49 +00:00
Sergey Matyunin aed0b5b65a docs: address zigbee spec review
Issue: #54
User-Visible: no
2026-09-04 14:06:18 +03:00
claude[bot] c53702cdfa docs: review document for #54
Issue: #54
User-Visible: no
2026-09-04 11:05:11 +00:00
Sergey Matyunin a6a3df9310 docs: specify contextual zigbee links
Issue: #54
User-Visible: no
2026-09-04 13:53:07 +03:00
claude[bot] ec9824f227 docs: review document for #152
Проверка (CI) / Классификация изменённых файлов (push) Successful in 29s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 49s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 50s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Issue: #152
User-Visible: no
2026-09-04 08:41:12 +00:00
Sergey Matyunin 260767e2f8 docs: accept reviewed room fit fixes provenance
Issue: #152
User-Visible: no
2026-09-04 11:26:36 +03:00
Sergey Matyunin 3a1f2b2d86 fix: address room fit code review
Issue: #152
User-Visible: no
2026-09-04 11:20:41 +03:00
claude[bot] 6eea4d1c7a docs: review document for #152
Issue: #152
User-Visible: no
2026-09-04 08:16:21 +00:00
Sergey Matyunin a54addb694 docs: accept final room fit screenshot provenance
Issue: #152
User-Visible: no
2026-09-04 10:43:27 +03:00
Sergey Matyunin 6a7ef48ae8 fix: preserve editor pointer cancellation
Issue: #152
User-Visible: no
2026-09-04 10:37:42 +03:00
Sergey Matyunin 6190234c51 docs: accept room fit screenshot provenance
Issue: #152
User-Visible: no
2026-09-04 10:32:58 +03:00
Sergey Matyunin 39ba4e2b88 test: exercise shared room action guard
Issue: #152
User-Visible: no
2026-09-04 10:26:50 +03:00
Sergey Matyunin 15cfa96553 test: fix room fit ownership witness
Issue: #152
User-Visible: no
2026-09-04 10:25:32 +03:00
Sergey Matyunin 4aad9facdf feat: fit rooms from view
Issue: #152
User-Visible: yes
2026-09-04 10:24:25 +03:00
claude[bot] 81026539d3 docs: review document for #152
Issue: #152
User-Visible: no
2026-09-04 06:53:57 +00:00
Sergey Matyunin f672de674f docs: revise room click fit specification
Проверка (CI) / Классификация изменённых файлов (push) Successful in 49s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 49s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 5m3s
Issue: #152
User-Visible: no
2026-09-04 09:37:44 +03:00
claude[bot]andSergey Matyunin 68992d5582 docs: spec review document for #152
Issue: #152
User-Visible: no
2026-09-04 09:29:35 +03:00
Sergey Matyunin f29c4c5b79 docs: specify room click fit
Issue: #152
User-Visible: no
2026-09-04 09:29:35 +03:00
claude[bot] d5bfa0e3e1 docs: review document for #447
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m5s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 22s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 25s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 42s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 16s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 7m7s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 8m25s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #447
User-Visible: no
2026-09-04 06:21:59 +00:00
Sergey Matyunin f62dd6b632 docs: accept screenshots for #447
Issue: #447
User-Visible: no
2026-09-04 09:09:20 +03:00
Sergey Matyunin 6b07b70d7c fix: preserve exterior furniture side and nudge decor
Issue: #447
User-Visible: yes
2026-09-04 08:58:48 +03:00
claude[bot] 62ecb8566e docs: review document for #447
Issue: #447
User-Visible: no
2026-09-04 05:48:58 +00:00
Sergey Matyunin 818ad2d2bb docs: distinguish render and stored decor nudge steps
Issue: #447
User-Visible: no
2026-09-04 08:40:44 +03:00
claude[bot] 6c668d4bfe docs: review document for #447
Issue: #447
User-Visible: no
2026-09-04 05:39:54 +00:00
Sergey Matyunin a1fbea07d2 docs: specify exterior furniture snap and keyboard nudge
Issue: #447
User-Visible: no
2026-09-04 08:28:20 +03:00
330 changed files with 51559 additions and 18919 deletions
+8
View File
@@ -37,6 +37,7 @@ jobs:
- large-house
- isometric
- plan-snap
- interaction
- blend
- overlay
- space-default
@@ -190,6 +191,10 @@ jobs:
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
;;
interaction)
npm run benchmark:large-house-interaction -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/interaction-baseline.json
npm run benchmark:large-house-interaction -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/interaction-candidate.json
;;
blend)
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
@@ -235,6 +240,9 @@ jobs:
plan-snap)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
;;
interaction)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-interaction.json --baseline=../artifacts/performance/interaction-baseline.json --candidate=../artifacts/performance/interaction-candidate.json --output=../artifacts/performance/interaction-comparison.json
;;
blend)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
;;
+80 -11
View File
@@ -43,9 +43,19 @@ jobs:
spent: ${{ steps.decide.outputs.spent }}
limit: ${{ steps.decide.outputs.limit }}
steps:
# Мелкий checkout: guard остаётся лёгким, но ему нужен
# scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что
# inline-shell не покрывается тестами (#454). Node на раннере
# предустановлен, setup-node не нужен.
- uses: actions/checkout@v7
with:
fetch-depth: 1
ref: dev
persist-credentials: false
- id: decide
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
REPO: ${{ github.repository }}
LABEL: ${{ github.event.label.name }}
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
@@ -87,19 +97,78 @@ jobs:
#
# Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал
# цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по
# имени документа в теле комментария; документа нет — вердикт не
# посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы
# работу досрочно: из двух ошибок выбрана обратимая.
# имени документа — раньше по подстроке маркера в теле комментария,
# теперь по имени документа ЭТОЙ задачи, `<MARKER>-<NUM>`: голая
# подстрока протекала на прозе. #454 поймала это на себе — разбор
# чужих задач в комментарии содержал `CODE-REVIEW`, и первый же
# код-ревью получил заход r3.
#
# Счёт по комментариям остаётся ровно тем же, но он БОЛЬШЕ НЕ
# ЕДИНСТВЕННЫЙ (#454). Маркер этапа попадает в тело комментария,
# только если ревьюер сам назвал имя файла, — то есть прежний счёт
# зависел от формулировки. На #449 первый спек-вердикт файла не
# назвал, заход r2 получил номер r1, и документ второго раунда лёг
# ПОВЕРХ документа первого. Оценка «недосчёт обратим» была неверна
# ровно здесь: номер захода входит в имя файла, и повтор номера — не
# лишний заход, а потеря артефакта.
#
# Поэтому рядом встаёт второй источник — опубликованные документы:
# их имена несёт сам конвейер, и подделать их прозой нельзя. Берётся
# МАКСИМУМ двух источников: недосчёт возможен только при отказе
# обоих, перерасчёт невозможен по построению.
attempt=1; spent=0; spent_list=""
if [ -n "$stage" ]; then
comments=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json comments)
of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))]"
# Блокирующим считается вердикт, у которого в строке вердикта стоит
# «жёлтый» или «красный». Регистр и окружение слова не важны.
blocking="$of_stage | map(select(.body | test(\"Вердикт:[^\\n]*(жёлт|красн)\"; \"i\")))"
attempt=$(( $(printf '%s' "$comments" | jq -r "$of_stage | length") + 1 ))
spent=$(printf '%s' "$comments" | jq -r "$blocking | length")
spent_list=$(printf '%s' "$comments" | jq -r "$blocking | map(\"- \" + .url) | join(\"\\n\")")
comments=$(mktemp)
gh issue view "$NUM" --repo "$REPO" --json comments > "$comments"
# Ветка задачи — та же, что выберет шаг ревью: свежая по коммиту.
# Её нет у задач, размеченных до появления конвейера; тогда счёт по
# файлам даёт ноль и работает страховка по комментариям.
branch=""; newest=""
for ref in $(gh api "repos/$REPO/git/matching-refs/heads/issue/$NUM-" \
--jq '.[].ref' 2>/dev/null | sed 's|^refs/heads/||'); do
date=$(gh api "repos/$REPO/commits/$ref" --jq '.commit.committer.date' 2>/dev/null || true)
if [ -n "$date" ] && { [ -z "$newest" ] || [ "$date" \> "$newest" ]; }; then
newest="$date"; branch="$ref"
fi
done
target="${branch:-dev}"
names=$(mktemp); docs=$(mktemp -d)
if ! gh api "repos/$REPO/contents/docs/reviews?ref=$target" --jq '.[].name' \
> "$names" 2>/dev/null; then
: > "$names"
echo "::warning::список docs/reviews на $target не получен — счёт по файлам отключён"
fi
# Каталог перечисляется одним ответом до 1000 записей; за этой
# границей ответ молча обрежется, и счёт по файлам занизится.
if [ "$(grep -c . "$names")" -ge 1000 ]; then
echo "::warning::в docs/reviews не меньше 1000 файлов — листинг contents обрезается, счёт по файлам ненадёжен"
fi
# Тела нужны только своим документам этапа: их единицы.
for name in $(grep -E "^${marker}-${NUM}-r[0-9]+\\.md$" "$names" || true); do
gh api "repos/$REPO/contents/docs/reviews/$name?ref=$target" \
-H 'Accept: application/vnd.github.raw' > "$docs/$name" 2>/dev/null || rm -f "$docs/$name"
done
list=$(mktemp)
counters=$(node scripts/review-doc-guard.mjs --counters \
--marker="$marker" --num="$NUM" --names="$names" --docs="$docs" \
--comments="$comments" --spent-list="$list")
spent_list=$(cat "$list")
# Пустой ответ означает, что скрипт не отработал. Тогда остаются
# значения по умолчанию (заход 1, циклов 0): guard обязан
# продолжить работу, а не встать.
new_attempt=$(printf '%s\n' "$counters" | sed -n 's/^attempt=//p')
new_spent=$(printf '%s\n' "$counters" | sed -n 's/^spent=//p')
new_blocking=$(printf '%s\n' "$counters" | sed -n 's/^blocking=//p')
case "$new_attempt" in ''|*[!0-9]*) echo "::warning::счётчик раундов не дал числа — работают значения по умолчанию" ;; *) attempt="$new_attempt" ;; esac
case "$new_spent" in ''|*[!0-9]*) : ;; *) spent="$new_spent" ;; esac
# Перечень учтённого обязан сходиться с числом: если цикл виден
# только документом, ссылка на комментарий его не объяснит.
if [ -n "$new_blocking" ]; then
spent_list="$spent_list
- документы: $new_blocking"
fi
echo "ветка материала: ${branch:-нет, читался dev}"
fi
# Отказ обязан быть виден в issue, а не только в логе прогона.
+2
View File
@@ -761,6 +761,8 @@ jobs:
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
- name: Бюджеты ограничений стыков (#330)
run: npm run benchmark:junction-limits
- name: Быстрый commit точки стены (#461)
run: npm run benchmark:wall-draw-click -- --output=artifacts/performance-smoke/wall-draw-click.json
- name: Upload performance smoke report
if: always()
uses: actions/upload-artifact@v7
+32 -15
View File
@@ -344,9 +344,27 @@ whole review round that a local run would have caught immediately.
The full smoke set, `golden` and `performance_smoke` still belong to the
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
the beta gate is CI at the exact SHA.
**Verifying and capturing are different things (#455).** `golden:verify` is
advisory and legal anywhere, Windows included: it reports differences and
accepts nothing. **Capturing** frames is refused outside Linux
before the browser even starts — `golden:capture` through
`demo/golden/policy.mjs`, documentation screenshots through
`npm run docs:capture` (use that script, not a bare `node
demo/docs/capture.mjs`: the gate sits one step earlier because editing the
capture script invalidates the committed screenshot index). The refusal prints
the WSL command. The reason is not policy but physics: Windows
rasterizes text through DirectWrite with different subpixel and DPI behaviour,
so no frame ever matches an accepted baseline byte for byte, no environment
witness can exist, and acceptance would refuse anyway (#401 accepts any
environment that proves itself with byte-identical undeclared frames — Linux is
simply the only one we have). The deliberate override is
`HP_ALLOW_FOREIGN_CAPTURE="reason"`; the reason travels into the output and the
manifest. Baselines are still accepted only via
`npm run golden:accept -- --reviewed` on a complete artefact, and the accepted
index records the platform next to the Chromium build.
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
@@ -399,21 +417,20 @@ Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It
reproduces against the pristine committed bundle, so treat it as
pre-existing/pixel-precision, not a regression you introduced.
## Labs flags
## Alpha experiments
`src/labs.ts` is the single registry and resolver for hidden presentation
experiments. Activate a live flag through `?hp-labs=<id>` or the shared hash
grammar, remove it with `-<id>`, and use `off` to clear the set. Do not add a
YAML/config switch for a Labs-only experiment. A new entry needs a unique
lowercase id, issue, numeric-core `since`, numeric-core `expires`, summary and
unit/browser coverage. Invalid or duplicate registry entries fail closed.
experiments. One browser-local switch controls the complete set known to the
installed build: `hp_alpha=1` in query or the shared hash grammar enables and
persists it, while `hp_alpha=0` disables and persists it. Do not add per-feature
URL/storage keys or a YAML/config switch. The legacy `hp-labs` grammar and
`houseplan_card_labs_v1` storage are ignored and never migrated.
Expiry is exclusive and ignores prerelease suffixes: an entry expiring at
`1.65.0` is unavailable in `1.65.0-beta.1`. Before that cycle, either remove the
experiment or graduate it through its own reviewed issue; never extend expiry as
an incidental change. Labs may alter presentation only and must not gate data,
migrations, stores, HA actions or network calls. Current renderer details are in
`docs/ISOMETRIC.md`.
A new registry entry needs a unique lowercase id, issue, summary and
unit/browser coverage; it does not get `since` or `expires`. Invalid or duplicate
entries fail closed. Alpha capabilities may alter presentation only and must not
gate data, migrations, stores, HA actions or network calls. Current renderer
details are in `docs/ISOMETRIC.md`.
Demo harness render quirk: the fake `hass` in `demo.html` is set once, so opening the
page directly in a browser renders the floor plan but **device icons only appear
+36 -7
View File
@@ -94,17 +94,46 @@ House Plan is in the HACS default catalog — no custom repository needed.
2. Restart Home Assistant.
3. Open **Settings → Devices & services → Add integration → House Plan**.
The card is registered automatically. If you manage Lovelace resources
manually, use the URL served by the integration:
The card is registered automatically. After installing or updating House Plan,
restart Home Assistant and fully reload the page: `Ctrl+F5` on Windows/Linux or
`Cmd+Shift+R` on macOS.
#### Storage mode (Home Assistant default)
No YAML is normally needed. If automatic registration did not make the card
available, open **Settings → Dashboards → menu ⋮ → Resources → Add
resource**, enter `/houseplan_files/houseplan-card.js`, and select **JavaScript
module**.
#### YAML resources mode (Home Assistant 2026.2+)
To manage resources in `configuration.yaml` independently of the dashboard
mode, use:
```yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
lovelace:
resource_mode: yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
```
Do not use the on-disk path inside `custom_components`; Home Assistant does not
serve that path as a JavaScript module.
#### Legacy Home Assistant 2024.6–2026.1
Only for a full-YAML dashboard that is already managed in YAML, use:
```yaml
lovelace:
mode: yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
```
`mode: yaml` changes the dashboard itself to YAML mode. Do not switch a storage
dashboard to legacy YAML just for House Plan; use the Storage mode instructions
above instead. Do not use the on-disk path inside `custom_components`; Home
Assistant does not serve that path as a JavaScript module.
### Manual installation
+36 -7
View File
@@ -98,17 +98,46 @@ House Plan входит в основной каталог HACS — пользо
2. Перезапустите Home Assistant.
3. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
добавьте именно URL, который публикует интеграция:
Карточка регистрируется автоматически. После установки или обновления
House Plan перезапустите Home Assistant и полностью перезагрузите
страницу: `Ctrl+F5` в Windows/Linux или `Cmd+Shift+R` в macOS.
#### Режим Storage (по умолчанию в Home Assistant)
Обычно YAML не нужен. Если авторегистрация не сделала карточку доступной,
откройте **Настройки → Панели управления → меню ⋮ → Ресурсы → Добавить
ресурс**, укажите `/houseplan_files/houseplan-card.js` и выберите тип
**JavaScript-модуль**.
#### YAML-ресурсы в Home Assistant 2026.2+
Чтобы управлять ресурсами в `configuration.yaml` независимо от режима
самой панели, используйте:
```yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
lovelace:
resource_mode: yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
```
Не используйте путь к файлу внутри `custom_components`: Home Assistant не
публикует его как JavaScript-модуль.
#### Home Assistant 2024.6–2026.1: устаревший режим
Только для панели, которая уже полностью управляется через YAML:
```yaml
lovelace:
mode: yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
```
`mode: yaml` переводит в YAML-режим саму панель. Не переключайте
storage-панель в устаревший YAML только ради House Plan; используйте
инструкцию для Storage выше. Не используйте путь к файлу внутри
`custom_components`: Home Assistant не публикует его как JavaScript-модуль.
### Вручную
+15 -104
View File
@@ -6,7 +6,6 @@ import logging
from datetime import timedelta
from pathlib import Path
from homeassistant.components.frontend import add_extra_js_url
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_interval
@@ -16,9 +15,11 @@ from .const import (
ASSETS_DIR,
DOMAIN,
FILES_DIR,
FRONTEND_URL,
PLANS_DIR,
VERSION,
)
from .frontend_registration import (
async_remove_frontend_registration,
async_setup_frontend_registration,
)
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
from .plans import collect_attachments, collect_plans, sweep_upload_temps
@@ -99,49 +100,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
)
)
# Static paths cannot be unregistered — register once per HA run.
if not hass.data[DOMAIN].get("static_registered"):
hass.data[DOMAIN]["static_registered"] = True
static_paths = []
try:
from homeassistant.components.http import StaticPathConfig
if card_path.exists():
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
# NOTE (audit B1): plans and marker files are NO LONGER static.
# They are served by HouseplanContentView, which requires auth.
# Only the entry and manifest-gated JS chunks stay public —
# Lovelace modules must be loadable without an auth header.
if static_paths:
await hass.http.async_register_static_paths(static_paths)
except ImportError: # very old HA versions
if card_path.exists():
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
if not card_path.exists():
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
return True
# Register the card. Preferably as a Lovelace resource (the frontend AWAITS
# resources before rendering dashboards, so the card is available even on a cold
# start of the mobile app). If the resource registry is unavailable (YAML-mode
# Lovelace, old versions) — fall back to extra_module_url.
module_url = f"{FRONTEND_URL}?v={VERSION}"
registered = await _register_lovelace_resource(hass, module_url)
if not registered:
add_extra_js_url(hass, module_url)
# Tell the user exactly where the card lives — the #1 support issue is people adding a
# Lovelace resource pointing at the on-disk path (/custom_components/...), which HA does
# not serve (wrong MIME → "Custom element doesn't exist"). The correct served URL is below.
if registered:
_LOGGER.info("House Plan card auto-registered as a Lovelace resource: %s", module_url)
else:
_LOGGER.info(
"House Plan card is served at %s . Lovelace resources look YAML-managed — add it "
"manually under `resources:` as { url: %s, type: module }. Do NOT use the on-disk "
"path /custom_components/houseplan/frontend/houseplan-card.js (HA does not serve it).",
module_url, module_url,
)
# NOTE (audit B1): plans and marker files are no longer static. They are
# served by HouseplanContentView, which requires auth. Only the manifest-
# gated frontend bundle stays public so Lovelace can load it. Registration
# failures are observable and recoverable, but never skip storage migration,
# repairs or housekeeping below.
await async_setup_frontend_registration(hass, entry, card_path)
# One-time move to the square canvas (v1.48.0). Coordinates used to be
# normalised against a per-space aspect ratio; the canvas is now always
@@ -319,61 +283,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -
return True
async def async_remove_entry(hass: HomeAssistant, entry) -> None:
"""Clean up on integration removal: drop our Lovelace resource entry."""
try:
resources = _lovelace_resources(hass)
if resources is None or not hasattr(resources, "async_delete_item"):
return
for item in list(resources.async_items()):
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL:
await resources.async_delete_item(item["id"])
_LOGGER.debug("House Plan Lovelace resource removed: %s", item.get("url"))
except Exception as err: # noqa: BLE001 — best-effort cleanup
_LOGGER.debug("Could not remove the Lovelace resource on uninstall: %s", err)
def _lovelace_resources(hass: HomeAssistant):
lovelace = hass.data.get("lovelace")
resources = getattr(lovelace, "resources", None)
if resources is None and isinstance(lovelace, dict):
resources = lovelace.get("resources")
return resources
async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool:
"""Register (or update) the card in the Lovelace resource registry.
Returns True on success. Idempotent: if a resource with our path exists —
update the URL on version change; otherwise create it. Any exception → False
(fall back to extra_module_url).
"""
try:
resources = _lovelace_resources(hass)
if resources is None:
return False
# the resource registry must be loaded
if hasattr(resources, "loaded") and not resources.loaded:
await resources.async_load()
resources.loaded = True
elif hasattr(resources, "async_get_info"):
await resources.async_get_info()
# only storage mode allows creating items
if not hasattr(resources, "async_create_item"):
return False
base = FRONTEND_URL
existing = [
item for item in resources.async_items()
if str(item.get("url", "")).split("?", 1)[0] == base
]
if existing:
item = existing[0]
if item.get("url") != module_url and hasattr(resources, "async_update_item"):
await resources.async_update_item(item["id"], {"url": module_url})
return True
await resources.async_create_item({"res_type": "module", "url": module_url})
_LOGGER.debug("House Plan card registered as a Lovelace resource: %s", module_url)
return True
except Exception as err: # noqa: BLE001 — any failure → fallback
_LOGGER.debug("Could not register the Lovelace resource (%s), falling back to extra_module_url", err)
return False
async def async_remove_entry(
hass: HomeAssistant, entry: HouseplanConfigEntry
) -> None:
"""Clean up frontend registration and notice on integration removal."""
await async_remove_frontend_registration(hass, entry)
+1 -1
View File
@@ -51,7 +51,7 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
ASSETS_DIR = "houseplan/assets"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.71.0"
VERSION = "1.72.0"
DECOR_ASSETS_API_VERSION = 1
# #43: the support transport is deliberately not configurable. A user supplied
@@ -1,125 +1,182 @@
{
"schema": 1,
"fingerprint": "b58e7cdef9cffc5c835789c5b3ebc59d270d8170d1928c8a2b224b40569c939e",
"fingerprint": "0d4311a7ae77b6ea13ed95f2b0c886ad4253d09f6770dfff438ee45667c22a39",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-BzvSZLQf.js",
"houseplan-assets/houseplan-card-C0qjaV_3.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 292741,
"initialViewGzipBytes": 300154,
"lazyFiles": [
"houseplan-assets/backdrop-pick-BUWmH3zd.js",
"houseplan-assets/de-B00cun6t.js",
"houseplan-assets/editor-Cv6TOit6.js",
"houseplan-assets/fr-BMO7ooc0.js",
"houseplan-assets/houseplan-editor-runtime-xreJqGLn.js",
"houseplan-assets/houseplan-onboarding-runtime-DND3nptI.js"
"houseplan-assets/backdrop-pick-CzRGitFR.js",
"houseplan-assets/de-BRjMWzox.js",
"houseplan-assets/editor-DIlG_iy6.js",
"houseplan-assets/fr-CB9Rh5BY.js",
"houseplan-assets/houseplan-editor-runtime-CVgleQRG.js",
"houseplan-assets/houseplan-onboarding-runtime-B8qICyvO.js",
"houseplan-assets/hp-zigbee-topology-overlay-CpC8UBVY.js",
"houseplan-assets/live-interaction-runtime-BuROtNwW.js",
"houseplan-assets/zigbee-topology-DjEvSVKB.js",
"houseplan-assets/zigbee-topology-runtime-toe7Mdc6.js"
],
"lazyGzipBytes": 223395,
"lazyGzipBytes": 250869,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-BUWmH3zd.js",
"houseplan-assets/editor-Cv6TOit6.js",
"houseplan-assets/houseplan-editor-runtime-xreJqGLn.js"
"houseplan-assets/backdrop-pick-CzRGitFR.js",
"houseplan-assets/editor-DIlG_iy6.js",
"houseplan-assets/houseplan-editor-runtime-CVgleQRG.js",
"houseplan-assets/zigbee-topology-DjEvSVKB.js"
],
"lazyEditorGzipBytes": 167358,
"lazyEditorGzipBytes": 186626,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-BUWmH3zd.js",
"houseplan-assets/houseplan-onboarding-runtime-DND3nptI.js"
"houseplan-assets/backdrop-pick-CzRGitFR.js",
"houseplan-assets/houseplan-onboarding-runtime-B8qICyvO.js"
],
"lazyOnboardingGzipBytes": 14239,
"lazyOnboardingGzipBytes": 14325,
"lazyLocaleFiles": [
"houseplan-assets/de-B00cun6t.js",
"houseplan-assets/fr-BMO7ooc0.js"
"houseplan-assets/de-BRjMWzox.js",
"houseplan-assets/fr-CB9Rh5BY.js"
],
"lazyLocaleGzipBytes": 49079,
"lazyLocaleGzipBytes": 50148,
"files": [
{
"path": "houseplan-assets/backdrop-pick-BUWmH3zd.js",
"sha256": "e7e88a03ea2fbbdb148c38de69b4796464e72708025193793c86fc143508aab2",
"rawBytes": 21418,
"gzipBytes": 7281,
"path": "houseplan-assets/backdrop-pick-CzRGitFR.js",
"sha256": "acd02f075bc18ca361cb543749461208b9f2dea2433aa3950a1296c31759de6b",
"rawBytes": 21592,
"gzipBytes": 7357,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BzvSZLQf.js"
"houseplan-assets/houseplan-card-C0qjaV_3.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-B00cun6t.js",
"sha256": "63d4a5f686bdf72fb90a1265fbf13ca4044fe8f9c044f027e0f6829dc70f8c1a",
"rawBytes": 82140,
"gzipBytes": 24809,
"path": "houseplan-assets/de-BRjMWzox.js",
"sha256": "fdba581311125cadbec7b827e8a4b87f3aba4eab7ff12d4dbf8af58517e9e9a8",
"rawBytes": 84232,
"gzipBytes": 25351,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-Cv6TOit6.js",
"sha256": "04a98bcb607339183d4c441f81c2d9f67bb174da26cd23ca8279df5ce48dd133",
"rawBytes": 3826,
"path": "houseplan-assets/editor-DIlG_iy6.js",
"sha256": "8c28088bcc9df5581948eeda9518f43eb73ca8ed9918ab0961f3eea114e2f3c9",
"rawBytes": 3831,
"gzipBytes": 1583,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BzvSZLQf.js"
"houseplan-assets/houseplan-card-C0qjaV_3.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-BMO7ooc0.js",
"sha256": "90d0488056c3c2be3c7f231a6e97686f0e54e5713528e062984bc6d36087822d",
"rawBytes": 84267,
"gzipBytes": 24270,
"path": "houseplan-assets/fr-CB9Rh5BY.js",
"sha256": "95cf4e535042c78eb00bedc08c8c2a9e779236847f3e12dca4757fca435629b7",
"rawBytes": 86273,
"gzipBytes": 24797,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-BzvSZLQf.js",
"sha256": "73ff73117f8baa0c1125bfdf84dd0042fdf6b3d1f2b149e3963430cedbcc71ea",
"rawBytes": 1039623,
"gzipBytes": 291943,
"path": "houseplan-assets/houseplan-card-C0qjaV_3.js",
"sha256": "03a04aa491c26e42f8adea0126c8bb970b54ec7a12bbd9475f9279a91a2b7fc0",
"rawBytes": 1069135,
"gzipBytes": 299358,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-B00cun6t.js",
"houseplan-assets/editor-Cv6TOit6.js",
"houseplan-assets/fr-BMO7ooc0.js",
"houseplan-assets/houseplan-editor-runtime-xreJqGLn.js",
"houseplan-assets/houseplan-onboarding-runtime-DND3nptI.js"
"houseplan-assets/de-BRjMWzox.js",
"houseplan-assets/editor-DIlG_iy6.js",
"houseplan-assets/fr-CB9Rh5BY.js",
"houseplan-assets/houseplan-editor-runtime-CVgleQRG.js",
"houseplan-assets/houseplan-onboarding-runtime-B8qICyvO.js",
"houseplan-assets/hp-zigbee-topology-overlay-CpC8UBVY.js",
"houseplan-assets/live-interaction-runtime-BuROtNwW.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-xreJqGLn.js",
"sha256": "b2ae41efa27b191388b09eaf02e9e4b302c2031238c6e90037d71c9422179792",
"rawBytes": 601522,
"gzipBytes": 158494,
"path": "houseplan-assets/houseplan-editor-runtime-CVgleQRG.js",
"sha256": "fc089a1e4a1df49c3e1c8e78620a3bd23f3ba7270213db663cd018854c22fe55",
"rawBytes": 639580,
"gzipBytes": 170261,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-BUWmH3zd.js",
"houseplan-assets/houseplan-card-BzvSZLQf.js"
"houseplan-assets/backdrop-pick-CzRGitFR.js",
"houseplan-assets/houseplan-card-C0qjaV_3.js",
"houseplan-assets/zigbee-topology-DjEvSVKB.js"
],
"dynamicImports": [
"houseplan-assets/zigbee-topology-runtime-toe7Mdc6.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-B8qICyvO.js",
"sha256": "87d24dae20c92a060e34d1e4ce80d300c947b7ac6b3a86f6025b39750d286d46",
"rawBytes": 28071,
"gzipBytes": 6968,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CzRGitFR.js",
"houseplan-assets/houseplan-card-C0qjaV_3.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-DND3nptI.js",
"sha256": "f78f4fdbc5e390829ef57e8e09d2ad856639138d83f3c62c997c2cbb71115f6c",
"rawBytes": 28088,
"gzipBytes": 6958,
"path": "houseplan-assets/hp-zigbee-topology-overlay-CpC8UBVY.js",
"sha256": "b5cbcfffe86b10e56c078dbf139123a6c16625f8c2396570215f06def12e08ba",
"rawBytes": 8626,
"gzipBytes": 3236,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-BUWmH3zd.js",
"houseplan-assets/houseplan-card-BzvSZLQf.js"
"houseplan-assets/houseplan-card-C0qjaV_3.js",
"houseplan-assets/zigbee-topology-DjEvSVKB.js",
"houseplan-assets/zigbee-topology-runtime-toe7Mdc6.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/live-interaction-runtime-BuROtNwW.js",
"sha256": "b0d405033a72cab412d7475170818f00729dfedd049f2dcfc3387beefa43ea42",
"rawBytes": 5196,
"gzipBytes": 2126,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-C0qjaV_3.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-DjEvSVKB.js",
"sha256": "531006824ca5197709f62fc3bae854d4a7349d3c7f3c3ab85c223d7e31261b9a",
"rawBytes": 18961,
"gzipBytes": 7425,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-C0qjaV_3.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-runtime-toe7Mdc6.js",
"sha256": "09566891268f0fd002283422cd1b83151520240025fb9e2899cee758e6147e8c",
"rawBytes": 3941,
"gzipBytes": 1765,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-C0qjaV_3.js",
"houseplan-assets/zigbee-topology-DjEvSVKB.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "a4f11751d4f28af28debedad8298a810cd999c7bc6ed66f22d6fec1b8ebaae3f",
"sha256": "12e80545e7ee775e0859c8113f922c25f15bef46c5c1866db3cff6d7d3dccdce",
"rawBytes": 1183,
"gzipBytes": 798,
"gzipBytes": 796,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-BzvSZLQf.js"
"houseplan-assets/houseplan-card-C0qjaV_3.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b58e7cdef9cffc5c835789c5b3ebc59d270d8170d1928c8a2b224b40569c939e";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-BzvSZLQf.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
@@ -0,0 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0d4311a7ae77b6ea13ed95f2b0c886ad4253d09f6770dfff438ee45667c22a39";import{v as e,l as o,z as t,dt as s,A as a,du as i,dv as l,dw as n,E as r,c}from"./houseplan-card-C0qjaV_3.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0d4311a7ae77b6ea13ed95f2b0c886ad4253d09f6770dfff438ee45667c22a39";import{n as t,b as n}from"./zigbee-topology-DjEvSVKB.js";import{I as e}from"./houseplan-card-C0qjaV_3.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(f(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,p=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{p=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const f=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&p?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof f&&b.push(f),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{p as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b58e7cdef9cffc5c835789c5b3ebc59d270d8170d1928c8a2b224b40569c939e";try{await import("./houseplan-assets/houseplan-card-BzvSZLQf.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0d4311a7ae77b6ea13ed95f2b0c886ad4253d09f6770dfff438ee45667c22a39";try{await import("./houseplan-assets/houseplan-card-C0qjaV_3.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
@@ -0,0 +1,659 @@
"""Register the House Plan card with the Home Assistant frontend."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from homeassistant.components import frontend, persistent_notification
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.translation import async_get_translations
from .const import DOMAIN, FRONTEND_URL, VERSION
from .store import HouseplanConfigEntry
_LOGGER = logging.getLogger(__name__)
FRONTEND_REGISTRATION_KEY = "frontend_registration"
FRONTEND_STATIC_REGISTERED_KEY = "static_registered"
FRONTEND_FALLBACK_URLS_KEY = "frontend_fallback_urls"
FRONTEND_RELOAD_NOTICE_DATA_KEY = "frontend_reload_notice_created"
FRONTEND_RELOAD_NOTICE_ID = "houseplan_frontend_reload_notice"
FRONTEND_RETRY_DELAY_SECONDS = 1.0
_NOTICE_TITLE_KEY = "component.houseplan.issues.frontend_reload_notice.title"
_NOTICE_DESCRIPTION_KEY = (
"component.houseplan.issues.frontend_reload_notice.description"
)
_NOTICE_TITLE_FALLBACK = "House Plan card connected"
_NOTICE_DESCRIPTION_FALLBACK = (
"The House Plan card is connected. Fully reload this page to use the latest "
"frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you "
"manage dashboard resources manually in storage mode, open Settings → "
"Dashboards → Resources."
)
RegistrationStatus = Literal[
"not_attempted",
"created",
"updated",
"existing",
"registry_pending",
"yaml_fallback",
"transient_error",
"error_fallback",
]
ResourceLoader = Literal[
"none",
"lovelace_resource",
"extra_module_url",
"lovelace_resource_with_session_fallback",
]
ReloadNoticeStatus = Literal[
"created",
"already_created",
"pending_frontend",
]
@dataclass(frozen=True, slots=True)
class RegistrationOutcome:
"""Result of one Lovelace resource registry attempt."""
status: RegistrationStatus
last_error: str | None = None
@dataclass(slots=True)
class FrontendRegistrationState:
"""Observable state and lifecycle handles for the current config-entry run."""
card_file_present: bool
static_path_registered: bool = False
resource_status: RegistrationStatus = "not_attempted"
loader: ResourceLoader = "none"
module_url: str | None = None
retry_pending: bool = False
retry_attempted: bool = False
last_error: str | None = None
first_reload_notice: ReloadNoticeStatus = "pending_frontend"
active: bool = True
fallback_added: bool = False
_cancel_started: Callable[[], None] | None = field(default=None, repr=False)
_cancel_timer: Callable[[], None] | None = field(default=None, repr=False)
_retry_task: asyncio.Task[None] | None = field(default=None, repr=False)
_notice_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
@callback
def cancel(self) -> None:
"""Prevent all delayed work for this setup generation."""
self.active = False
self.retry_pending = False
if self._cancel_started is not None:
self._cancel_started()
self._cancel_started = None
if self._cancel_timer is not None:
self._cancel_timer()
self._cancel_timer = None
if self._retry_task is not None and not self._retry_task.done():
self._retry_task.cancel()
self._retry_task = None
def _safe_error(phase: str, err: BaseException) -> str:
"""Return a support-safe failure fingerprint without message or path data."""
return f"{phase}:{type(err).__name__}"
def _lovelace_resources(hass: HomeAssistant):
"""Return the Lovelace resource collection across supported HA versions."""
lovelace = hass.data.get("lovelace")
resources = getattr(lovelace, "resources", None)
if resources is None and isinstance(lovelace, dict):
resources = lovelace.get("resources")
return resources
def _is_current(hass: HomeAssistant, state: FrontendRegistrationState) -> bool:
"""Return whether a delayed operation still belongs to the loaded entry."""
return (
state.active
and hass.data.get(DOMAIN, {}).get(FRONTEND_REGISTRATION_KEY) is state
)
def _owned_fallback_urls(hass: HomeAssistant) -> set[str]:
"""Return exact extra-module URLs owned for this Home Assistant run."""
domain_data = hass.data.setdefault(DOMAIN, {})
urls = domain_data.get(FRONTEND_FALLBACK_URLS_KEY)
if not isinstance(urls, set):
urls = set()
domain_data[FRONTEND_FALLBACK_URLS_KEY] = urls
return urls
def _active_extra_module_urls(hass: HomeAssistant) -> frozenset[str] | None:
"""Return HA's current module URLs, or None when the API is unknowable.
``add_extra_js_url`` is intentionally idempotent and has no ownership
return value. Looking before adding is therefore the only way to avoid
claiming (and later removing) an identical URL configured by the user.
Both the oldest supported HA and current HA expose ``UrlManager.urls``;
unknown future/legacy shapes fail closed and are never claimed.
"""
key = getattr(frontend, "DATA_EXTRA_MODULE_URL", None)
if key is None:
return None
manager = hass.data.get(key)
if manager is None:
# Frontend setup normally creates the manager before this integration.
# If it is genuinely absent, no configured URL can predate our add;
# add_extra_js_url will either create/use its supported path or fail.
return frozenset()
urls = getattr(manager, "urls", None)
if not isinstance(urls, (set, frozenset, list, tuple)):
return None
return frozenset(url for url in urls if isinstance(url, str))
async def _async_remove_duplicate_resources(
resources,
existing: list[dict],
canonical_id: str,
*,
is_active: Callable[[], bool],
) -> None:
"""Best-effort remove legacy duplicates after choosing one authority."""
remover = getattr(resources, "async_delete_item", None)
if remover is None:
return
for item in existing:
if item.get("id") == canonical_id:
continue
if not is_active():
return
try:
await remover(item["id"])
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - canonical registration still succeeded
_LOGGER.debug(
"Could not remove a duplicate House Plan Lovelace resource",
exc_info=True,
)
async def async_register_lovelace_resource(
hass: HomeAssistant,
module_url: str,
*,
is_active: Callable[[], bool] | None = None,
) -> RegistrationOutcome:
"""Create or update the canonical Lovelace resource entry."""
active = is_active or (lambda: True)
resources = _lovelace_resources(hass)
if resources is None:
return RegistrationOutcome("registry_pending")
if not hasattr(resources, "async_create_item"):
return RegistrationOutcome("yaml_fallback")
try:
if not active():
return RegistrationOutcome("registry_pending")
if hasattr(resources, "async_get_info"):
await resources.async_get_info()
elif hasattr(resources, "loaded") and not resources.loaded:
await resources.async_load()
resources.loaded = True
if not active():
return RegistrationOutcome("registry_pending")
existing = [
item
for item in resources.async_items()
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL
]
if existing:
# Prefer an already-current entry when legacy duplicates exist. This
# preserves its stable id while the remaining same-base entries are
# removed best effort below.
item = next(
(candidate for candidate in existing if candidate.get("url") == module_url),
existing[0],
)
if item.get("url") == module_url and item.get("type") == "module":
await _async_remove_duplicate_resources(
resources,
existing,
item["id"],
is_active=active,
)
return RegistrationOutcome("existing")
if not hasattr(resources, "async_update_item"):
return RegistrationOutcome(
"transient_error", "registry_update:UnsupportedOperation"
)
if not active():
return RegistrationOutcome("registry_pending")
await resources.async_update_item(
item["id"], {"url": module_url, "res_type": "module"}
)
await _async_remove_duplicate_resources(
resources,
existing,
item["id"],
is_active=active,
)
return RegistrationOutcome("updated")
if not active():
return RegistrationOutcome("registry_pending")
await resources.async_create_item(
{"url": module_url, "res_type": "module"}
)
_LOGGER.debug("House Plan card registered as a Lovelace resource")
return RegistrationOutcome("created")
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001 - typed fallback is intentional
_LOGGER.debug("Could not register the House Plan Lovelace resource", exc_info=True)
return RegistrationOutcome("transient_error", _safe_error("registry", err))
def _add_fallback(
hass: HomeAssistant, state: FrontendRegistrationState
) -> None:
"""Add this setup's exact versioned extra-module URL, best effort."""
if state.module_url is None or not _is_current(hass, state):
return
owned_urls = _owned_fallback_urls(hass)
if state.module_url in owned_urls:
state.fallback_added = True
state.loader = "extra_module_url"
return
active_before = _active_extra_module_urls(hass)
if active_before is not None and state.module_url in active_before:
# The exact URL predates this setup. It is a working loader, but it is
# not ours to remove after registry recovery or on uninstall.
state.fallback_added = True
state.loader = "extra_module_url"
return
try:
frontend.add_extra_js_url(hass, state.module_url)
except Exception as err: # noqa: BLE001 - frontend setup must remain available
state.loader = "none"
state.last_error = _safe_error("fallback_add", err)
_LOGGER.warning("Could not add the House Plan frontend fallback", exc_info=True)
return
if active_before is not None:
owned_urls.add(state.module_url)
else:
_LOGGER.debug(
"House Plan could not prove ownership of the frontend fallback; "
"it will remain until Home Assistant restarts"
)
state.fallback_added = True
state.loader = "extra_module_url"
def _remove_owned_fallback_url(
hass: HomeAssistant,
state: FrontendRegistrationState,
module_url: str,
) -> bool:
"""Remove one exact integration-owned fallback when HA supports it."""
owned_urls = _owned_fallback_urls(hass)
if module_url not in owned_urls:
return True
remover = getattr(frontend, "remove_extra_js_url", None)
if remover is None:
return False
try:
remover(hass, module_url)
except KeyError:
# Another lifecycle path already removed the exact URL.
owned_urls.discard(module_url)
return True
except Exception as err: # noqa: BLE001 - registry remains authoritative
state.last_error = _safe_error("fallback_remove", err)
_LOGGER.debug("Could not remove the House Plan frontend fallback", exc_info=True)
return False
owned_urls.discard(module_url)
return True
def _remove_fallback(
hass: HomeAssistant, state: FrontendRegistrationState
) -> bool:
"""Remove this setup's exact fallback when the running HA supports it."""
if state.module_url is None:
return True
owned_urls = _owned_fallback_urls(hass)
if state.module_url in owned_urls:
removed = _remove_owned_fallback_url(hass, state, state.module_url)
state.fallback_added = not removed
return removed
# A pre-existing/user-owned or uninspectable exact URL must never be
# removed. Report the combined loader while it is known (or conservatively
# assumed) to remain active in this HA document lifecycle.
active = _active_extra_module_urls(hass)
state.fallback_added = active is None or state.module_url in active
return not state.fallback_added
async def _async_create_reload_notice(
hass: HomeAssistant,
entry: HouseplanConfigEntry,
state: FrontendRegistrationState,
) -> None:
"""Create and persist the one-time localized hard-reload instruction."""
# Initial fallback setup and the one-second retry may overlap when loading a
# translation is slow. Serialize the persisted one-shot contract rather
# than relying only on the stable notification ID (which prevents two UI
# rows but would still perform and persist two creates).
async with state._notice_lock:
if entry.data.get(FRONTEND_RELOAD_NOTICE_DATA_KEY) is True:
if state.first_reload_notice != "created":
state.first_reload_notice = "already_created"
return
if (
not _is_current(hass, state)
or not state.card_file_present
or not state.static_path_registered
or state.loader == "none"
):
state.first_reload_notice = "pending_frontend"
return
title = _NOTICE_TITLE_FALLBACK
description = _NOTICE_DESCRIPTION_FALLBACK
try:
translations = await async_get_translations(
hass,
hass.config.language,
"issues",
integrations={DOMAIN},
)
title = translations.get(_NOTICE_TITLE_KEY, title)
description = translations.get(_NOTICE_DESCRIPTION_KEY, description)
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - built-in English fallback is intentional
_LOGGER.debug(
"Could not load the House Plan reload notice translation",
exc_info=True,
)
if not _is_current(hass, state):
return
try:
persistent_notification.async_create(
hass,
description,
title=title,
notification_id=FRONTEND_RELOAD_NOTICE_ID,
)
except Exception: # noqa: BLE001 - do not consume flag on failure
state.first_reload_notice = "pending_frontend"
_LOGGER.warning(
"Could not create the House Plan reload notice", exc_info=True
)
return
try:
hass.config_entries.async_update_entry(
entry,
data={**entry.data, FRONTEND_RELOAD_NOTICE_DATA_KEY: True},
)
except Exception: # noqa: BLE001 - roll back the unpersisted notice
# A stable ID prevents duplicate rows, but dismissing the notice is
# still important: without a durable flag, displaying it would make
# System Health claim that the one-shot contract is pending while a
# user-visible notice already exists. A later setup/retry can now
# attempt the complete create+persist operation honestly.
try:
persistent_notification.async_dismiss(
hass, FRONTEND_RELOAD_NOTICE_ID
)
except Exception: # noqa: BLE001 - stable ID remains a safe fallback
_LOGGER.debug(
"Could not roll back an unpersisted House Plan reload notice",
exc_info=True,
)
state.first_reload_notice = "pending_frontend"
_LOGGER.warning(
"Could not persist the House Plan reload notice state",
exc_info=True,
)
return
state.first_reload_notice = "created"
async def _async_retry_registration(
hass: HomeAssistant,
entry: HouseplanConfigEntry,
state: FrontendRegistrationState,
) -> None:
"""Perform the single delayed resource-registry retry."""
if not _is_current(hass, state) or state.module_url is None:
return
state.retry_pending = False
state.retry_attempted = True
outcome = await async_register_lovelace_resource(
hass,
state.module_url,
is_active=lambda: _is_current(hass, state),
)
if not _is_current(hass, state):
return
if outcome.status in {"created", "updated", "existing"}:
state.resource_status = outcome.status
state.last_error = outcome.last_error
state.loader = (
"lovelace_resource"
if _remove_fallback(hass, state)
else "lovelace_resource_with_session_fallback"
)
elif outcome.status == "yaml_fallback":
state.resource_status = "yaml_fallback"
state.last_error = outcome.last_error
_add_fallback(hass, state)
else:
state.resource_status = "error_fallback"
state.last_error = outcome.last_error or "registry_retry:Unavailable"
_add_fallback(hass, state)
await _async_create_reload_notice(hass, entry, state)
def _schedule_retry(
hass: HomeAssistant,
entry: HouseplanConfigEntry,
state: FrontendRegistrationState,
) -> None:
"""Schedule exactly one lifecycle-bound retry after HA start plus one second."""
state.retry_pending = True
@callback
def _after_started(_event=None) -> None:
if not _is_current(hass, state):
return
state._cancel_started = None
@callback
def _after_delay(_now=None) -> None:
if not _is_current(hass, state):
return
state._cancel_timer = None
state._retry_task = entry.async_create_background_task(
hass,
_async_retry_registration(hass, entry, state),
"houseplan frontend resource retry",
)
state._cancel_timer = async_call_later(
hass, FRONTEND_RETRY_DELAY_SECONDS, _after_delay
)
state._cancel_started = async_at_started(hass, _after_started)
async def async_setup_frontend_registration(
hass: HomeAssistant,
entry: HouseplanConfigEntry,
card_path: Path,
) -> FrontendRegistrationState:
"""Register static/card resources without blocking the rest of entry setup."""
domain_data = hass.data.setdefault(DOMAIN, {})
previous = domain_data.get(FRONTEND_REGISTRATION_KEY)
if isinstance(previous, FrontendRegistrationState):
previous.cancel()
card_file_present = card_path.is_file()
module_url = f"{FRONTEND_URL}?v={VERSION}" if card_file_present else None
active_fallbacks = _active_extra_module_urls(hass)
fallback_added = module_url is not None and (
module_url in _owned_fallback_urls(hass)
or (active_fallbacks is not None and module_url in active_fallbacks)
)
state = FrontendRegistrationState(
card_file_present=card_file_present,
loader="extra_module_url" if fallback_added else "none",
module_url=module_url,
first_reload_notice=(
"already_created"
if entry.data.get(FRONTEND_RELOAD_NOTICE_DATA_KEY) is True
else "pending_frontend"
),
fallback_added=fallback_added,
)
domain_data[FRONTEND_REGISTRATION_KEY] = state
entry.async_on_unload(state.cancel)
if domain_data.get(FRONTEND_STATIC_REGISTERED_KEY):
state.static_path_registered = True
elif card_file_present:
try:
try:
from homeassistant.components.http import StaticPathConfig
except ImportError: # Home Assistant versions before the async API
hass.http.register_static_path(
FRONTEND_URL, str(card_path), cache_headers=False
)
else:
await hass.http.async_register_static_paths(
[StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False)]
)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001 - setup continues without frontend
state.last_error = _safe_error("static_path", err)
_LOGGER.warning("Could not register the House Plan static path", exc_info=True)
else:
domain_data[FRONTEND_STATIC_REGISTERED_KEY] = True
state.static_path_registered = True
if not card_file_present:
_LOGGER.warning("houseplan-card.js was not found next to the integration")
return state
if not state.static_path_registered or state.module_url is None:
return state
outcome = await async_register_lovelace_resource(
hass,
state.module_url,
is_active=lambda: _is_current(hass, state),
)
if not _is_current(hass, state):
return state
state.resource_status = outcome.status
state.last_error = outcome.last_error
if outcome.status in {"created", "updated", "existing"}:
state.loader = (
"lovelace_resource"
if _remove_fallback(hass, state)
else "lovelace_resource_with_session_fallback"
)
_LOGGER.info(
"House Plan card auto-registered as a Lovelace resource: %s",
state.module_url,
)
else:
_add_fallback(hass, state)
if outcome.status in {"registry_pending", "transient_error"}:
_schedule_retry(hass, entry, state)
else:
_LOGGER.info(
"House Plan card uses the frontend extra-module fallback: %s",
state.module_url,
)
await _async_create_reload_notice(hass, entry, state)
return state
@callback
def get_frontend_registration_state(
hass: HomeAssistant,
) -> FrontendRegistrationState | None:
"""Return current frontend registration state for System Health."""
state = hass.data.get(DOMAIN, {}).get(FRONTEND_REGISTRATION_KEY)
return state if isinstance(state, FrontendRegistrationState) else None
async def async_remove_frontend_registration(
hass: HomeAssistant, entry: HouseplanConfigEntry
) -> None:
"""Remove all House Plan resource entries and its one-time notice."""
state = get_frontend_registration_state(hass)
if state is not None:
state.cancel()
domain_data = hass.data.get(DOMAIN, {})
if domain_data.get(FRONTEND_REGISTRATION_KEY) is state:
domain_data.pop(FRONTEND_REGISTRATION_KEY, None)
# Fallback ownership belongs to this HA run, not to the latest config-entry
# generation. A bundle-missing reload can therefore have no current URL
# while an earlier exact URL is still registered. Uninstall cleans every
# URL that this integration itself added, and never pattern-matches foreign
# extra-module entries.
cleanup_state = state or FrontendRegistrationState(card_file_present=False)
for module_url in tuple(_owned_fallback_urls(hass)):
_remove_owned_fallback_url(hass, cleanup_state, module_url)
try:
persistent_notification.async_dismiss(hass, FRONTEND_RELOAD_NOTICE_ID)
except Exception: # noqa: BLE001 - uninstall cleanup is best effort
_LOGGER.debug("Could not dismiss the House Plan reload notice", exc_info=True)
try:
resources = _lovelace_resources(hass)
if resources is None or not hasattr(resources, "async_delete_item"):
return
if hasattr(resources, "async_get_info"):
await resources.async_get_info()
for item in list(resources.async_items()):
if str(item.get("url", "")).split("?", 1)[0] != FRONTEND_URL:
continue
try:
await resources.async_delete_item(item["id"])
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - continue best-effort cleanup
_LOGGER.debug(
"Could not remove one House Plan Lovelace resource",
exc_info=True,
)
continue
_LOGGER.debug("House Plan Lovelace resource removed")
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - uninstall cleanup is best effort
_LOGGER.debug("Could not remove the House Plan Lovelace resource", exc_info=True)
+4 -1
View File
@@ -1,6 +1,9 @@
{
"domain": "houseplan",
"name": "House Plan",
"after_dependencies": [
"lovelace"
],
"codeowners": [
"@Matysh"
],
@@ -16,5 +19,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.71.0"
"version": "1.72.0"
}
+16
View File
@@ -19,6 +19,22 @@
"broken_plan": {
"title": "Floor plan image is missing",
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
},
"frontend_reload_notice": {
"title": "House Plan card connected",
"description": "The House Plan card is connected. Fully reload this page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you manage dashboard resources manually in storage mode, open Settings → Dashboards → Resources."
}
},
"system_health": {
"info": {
"card_file": "House Plan card file",
"static_path": "Static frontend path",
"resource_status": "Resource registration status",
"resource_loader": "Frontend resource loader",
"resource_url": "Frontend resource URL",
"resource_retry": "Resource registration retry",
"resource_error": "Frontend registration error",
"first_reload_notice": "First reload notice"
}
}
}
+41 -1
View File
@@ -6,6 +6,7 @@ from typing import Any
from homeassistant.components import system_health
from homeassistant.core import HomeAssistant, callback
from .frontend_registration import get_frontend_registration_state
from .store import get_data
@@ -23,7 +24,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
cfg_raw = await data.config_store.async_load() or {}
layout_raw = await data.store.async_load() or {}
config = cfg_raw.get("config", {})
return {
result = {
"config_rev": cfg_raw.get("rev", 0),
"spaces": len(config.get("spaces", [])),
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
@@ -33,3 +34,42 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"markers": len(config.get("markers", [])),
"layout_entries": len(layout_raw.get("layout", {})),
}
frontend = get_frontend_registration_state(hass)
if frontend is None:
result.update(
{
"card_file": "missing",
"static_path": "not_registered",
"resource_status": "not_attempted",
"resource_loader": "none",
"resource_url": "unavailable",
"resource_retry": "not_needed",
"resource_error": "none",
"first_reload_notice": "pending_frontend",
}
)
return result
result.update(
{
"card_file": "present" if frontend.card_file_present else "missing",
"static_path": (
"registered"
if frontend.static_path_registered
else "not_registered"
),
"resource_status": frontend.resource_status,
"resource_loader": frontend.loader,
"resource_url": frontend.module_url or "unavailable",
"resource_retry": (
"pending"
if frontend.retry_pending
else "attempted"
if frontend.retry_attempted
else "not_needed"
),
"resource_error": frontend.last_error or "none",
"first_reload_notice": frontend.first_reload_notice,
}
)
return result
@@ -25,6 +25,22 @@
"broken_plan": {
"title": "Grundrissbild fehlt",
"description": "Die Plandatei `{file}` für den Bereich `{space}` wurde in `config/houseplan/plans/` nicht gefunden. In den Bereichseinstellungen der House-Plan-Karte den Plan erneut hochladen."
},
"frontend_reload_notice": {
"title": "House-Plan-Karte verbunden",
"description": "Die House-Plan-Karte ist verbunden. Laden Sie diese Seite vollständig neu, um die aktuelle Benutzeroberfläche zu verwenden (`Ctrl+F5` unter Windows/Linux oder `Cmd+Shift+R` unter macOS). Wenn Sie Dashboard-Ressourcen im Speichermodus manuell verwalten, öffnen Sie Einstellungen → Dashboards → Ressourcen."
}
},
"system_health": {
"info": {
"card_file": "Datei der House-Plan-Karte",
"static_path": "Statischer Frontend-Pfad",
"resource_status": "Status der Ressourcenregistrierung",
"resource_loader": "Lademethode der Frontend-Ressource",
"resource_url": "URL der Frontend-Ressource",
"resource_retry": "Wiederholung der Ressourcenregistrierung",
"resource_error": "Fehler der Frontend-Registrierung",
"first_reload_notice": "Erster Hinweis zum Neuladen"
}
}
}
@@ -25,6 +25,22 @@
"broken_plan": {
"title": "Floor plan image is missing",
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
},
"frontend_reload_notice": {
"title": "House Plan card connected",
"description": "The House Plan card is connected. Fully reload this page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you manage dashboard resources manually in storage mode, open Settings → Dashboards → Resources."
}
},
"system_health": {
"info": {
"card_file": "House Plan card file",
"static_path": "Static frontend path",
"resource_status": "Resource registration status",
"resource_loader": "Frontend resource loader",
"resource_url": "Frontend resource URL",
"resource_retry": "Resource registration retry",
"resource_error": "Frontend registration error",
"first_reload_notice": "First reload notice"
}
}
}
@@ -25,6 +25,22 @@
"broken_plan": {
"title": "L’image du plan est introuvable",
"description": "Le fichier de plan `{file}` pour l’espace `{space}` est introuvable dans `config/houseplan/plans/`. Ouvrez les paramètres de l’espace dans la carte House Plan et téléversez de nouveau le plan."
},
"frontend_reload_notice": {
"title": "Carte House Plan connectée",
"description": "La carte House Plan est connectée. Rechargez complètement cette page pour utiliser l’interface la plus récente (`Ctrl+F5` sous Windows/Linux ou `Cmd+Shift+R` sous macOS). Si vous gérez manuellement les ressources du tableau de bord en mode stockage, ouvrez Paramètres → Tableaux de bord → Ressources."
}
},
"system_health": {
"info": {
"card_file": "Fichier de la carte House Plan",
"static_path": "Chemin statique de l’interface",
"resource_status": "État de l’enregistrement de la ressource",
"resource_loader": "Mode de chargement de la ressource",
"resource_url": "URL de la ressource de l’interface",
"resource_retry": "Nouvelle tentative d’enregistrement",
"resource_error": "Erreur d’enregistrement de l’interface",
"first_reload_notice": "Premier avis de rechargement"
}
}
}
@@ -25,6 +25,22 @@
"broken_plan": {
"title": "Файл плана этажа не найден",
"description": "Файл плана `{file}` пространства `{space}` не найден в `config/houseplan/plans/`. Откройте настройки пространства в карточке House Plan и загрузите план заново."
},
"frontend_reload_notice": {
"title": "Карточка House Plan подключена",
"description": "Карточка House Plan подключена. Полностью перезагрузите эту страницу, чтобы использовать актуальный интерфейс (`Ctrl+F5` в Windows/Linux или `Cmd+Shift+R` в macOS). Если вы вручную управляете ресурсами панели в режиме хранения, откройте Настройки → Панели → Ресурсы."
}
},
"system_health": {
"info": {
"card_file": "Файл карточки House Plan",
"static_path": "Статический путь интерфейса",
"resource_status": "Статус регистрации ресурса",
"resource_loader": "Способ загрузки ресурса",
"resource_url": "URL ресурса интерфейса",
"resource_retry": "Повтор регистрации ресурса",
"resource_error": "Ошибка регистрации интерфейса",
"first_reload_notice": "Первое уведомление о перезагрузке"
}
}
}
+449 -11
View File
@@ -15,13 +15,15 @@ const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const targetRoot = resolve(valueArg('target-root') ?? '.');
const profile = valueArg('profile') ?? 'large-house-v1';
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1', 'large-house-interaction-v1'].includes(profile))
throw new Error(`unknown large-house profile: ${profile}`);
const isometric = profile === 'large-house-isometric-v1';
const planSnap = profile === 'large-house-plan-snap-v1';
const interaction = profile === 'large-house-interaction-v1';
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
const requiresInteraction = interaction && existsSync(resolve(targetRoot, 'src/live-viewport.ts'));
const fixture = makeLargeHouseFixture();
if (planSnap) {
for (const [floor, space] of fixture.config.spaces.entries()) {
@@ -71,7 +73,7 @@ try {
const measuredSample = iteration - warmups;
const row = await page.evaluate(async ({
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
requiresWallFace,
requiresWallFace, interaction, requiresInteraction,
}) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
@@ -135,11 +137,11 @@ try {
window.__card?.remove?.();
localStorage.clear();
if (isometric) {
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(['iso']));
localStorage.setItem('houseplan_card_alpha_v1', '1');
localStorage.setItem('houseplan_card_view_v1', JSON.stringify(Object.fromEntries(
fixture.config.spaces.map((space) => [space.id, 'iso']),
)));
history.replaceState(null, '', '?hp-labs=iso');
history.replaceState(null, '', '?hp_alpha=1');
} else history.replaceState(null, '', location.pathname);
const host = document.getElementById('host');
const card = document.createElement('houseplan-card');
@@ -151,10 +153,10 @@ try {
subscribeEvents: async () => () => undefined,
subscribeMessage: async () => () => undefined,
};
const hassFor = (states) => ({
const hassBase = {
language: 'en', locale: { language: 'en' },
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas, states,
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas,
floors: {
one: { floor_id: 'one', name: 'One', level: 0 },
two: { floor_id: 'two', name: 'Two', level: 1 },
@@ -178,17 +180,19 @@ try {
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
});
};
const hassFor = (states) => ({ ...hassBase, states });
const loadLongTasks = startLongTaskWindow();
const loadStarted = performance.now();
host.replaceChildren(card);
if (requiresIsometric) {
if (requiresIsometric && window.__hpAlpha !== true) {
if (typeof card._onLabsSnapshot !== 'function')
throw new Error('large-house-isometric-v1 candidate has no Labs fixture hook');
// The product flag expires at 1.65.0. Performance keeps exercising
// the dormant renderer without changing the public registry contract.
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
// Comparison bundles before #448 do not understand hp_alpha. Preserve
// the cross-version benchmark only for that base; current candidates
// must activate through the real URL/storage contract above.
card._onLabsSnapshot({ alpha: true, active: Object.freeze(['iso']), space: '' });
}
card.hass = hassFor(fixture.states);
// launch() warms its own bootstrap card, but each measured sample creates
@@ -206,9 +210,33 @@ try {
await frame();
const modelReadyMs = Number((performance.now() - loadStarted).toFixed(2));
await until(() => card._booting === false);
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
await frame();
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
const loadLongTaskResult = await loadLongTasks.stop();
let fullRenderCount = 0;
let diagnosticsScanCount = 0;
let physicalPreflightCount = 0;
let physicalPreflightMs = 0;
const fullRenderReasons = [];
if (interaction) {
const renderBody = card._renderBody.bind(card);
card._renderBody = (...args) => { fullRenderCount++; return renderBody(...args); };
const bindingStatus = card._bindingStatus.bind(card);
card._bindingStatus = (...args) => { diagnosticsScanCount++; return bindingStatus(...args); };
const checkSpacePhysicalGeometry = card._checkSpacePhysicalGeometry.bind(card);
card._checkSpacePhysicalGeometry = (...args) => {
physicalPreflightCount++;
const started = performance.now();
try { return checkSpacePhysicalGeometry(...args); }
finally { physicalPreflightMs += performance.now() - started; }
};
const willUpdate = card.willUpdate.bind(card);
card.willUpdate = (changed) => {
fullRenderReasons.push([...changed.keys()].map(String));
return willUpdate(changed);
};
}
const viewToggle = isometric ? await duration(async () => {
if (typeof card._setProjection === 'function') {
card._setProjection('flat');
@@ -236,6 +264,406 @@ try {
await card.updateComplete;
});
let interactionDiagnostics = null;
let interactionTimings = null;
let interactionLongTasks = null;
const interactionSeries = interaction ? await duration(async () => {
const deltas = {};
const timings = {};
interactionLongTasks = {};
const diagnosticsBefore = diagnosticsScanCount;
const reasonsBefore = fullRenderReasons.length;
const beforeIrrelevant = fullRenderCount;
const intakeBefore = card._hassSequence;
const irrelevantStarted = performance.now();
let currentStates = nextStates;
for (let index = 0; index < 30; index++) {
currentStates = {
...currentStates,
'sensor.performance_unrelated': {
entity_id: 'sensor.performance_unrelated', state: `${sample}:${index}`,
},
};
card.hass = hassFor(currentStates);
}
await card.updateComplete;
await frame();
timings.irrelevantHaTicksMs = performance.now() - irrelevantStarted;
deltas.irrelevantFullRenders = fullRenderCount - beforeIrrelevant;
deltas.irrelevantHassIntakes = card._hassSequence - intakeBefore;
const relevantBefore = fullRenderCount;
const relevantStarted = performance.now();
currentStates = {
...currentStates,
[firstEntity]: {
...currentStates[firstEntity],
state: currentStates[firstEntity].state === 'on' ? 'off' : 'on',
},
};
card.hass = hassFor(currentStates);
await card.updateComplete;
await frame();
timings.relevantHaTickMs = performance.now() - relevantStarted;
deltas.relevantOutsideGestureFullRenders = fullRenderCount - relevantBefore;
const stage = card.renderRoot.querySelector('.stage');
const device = card.renderRoot.querySelector('[data-hp="device"]');
const room = card.renderRoot.querySelector('[data-hp="room"]');
const heavy = card.renderRoot.querySelector('.wallbodies');
if (!stage || !device || !room || !heavy)
throw new Error('interaction profile has no stage/device/room/heavy scene');
const rect = stage.getBoundingClientRect();
const hoverBefore = fullRenderCount;
const hoverLongTasks = startLongTaskWindow();
const hoverStarted = performance.now();
device.dispatchEvent(new PointerEvent('pointerover', {
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
for (let index = 0; index < 40; index++) {
device.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 100 + index, clientY: rect.top + 100 + index / 2,
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
if ((index + 1) % 20 === 0) {
await card.updateComplete;
await frame();
}
}
const liveTip = card.renderRoot.querySelector('[data-hp-live-tip]');
deltas.tooltipVisible = !!liveTip && !liveTip.hidden && !!liveTip.textContent?.trim();
device.dispatchEvent(new PointerEvent('pointerleave', {
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
room.dispatchEvent(new PointerEvent('pointerenter', {
clientX: rect.left + 300, clientY: rect.top + 300,
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
for (let index = 0; index < 40; index++) {
room.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 300 + index, clientY: rect.top + 300 + index / 2,
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
if ((index + 1) % 20 === 0) { await card.updateComplete; await frame(); }
}
room.dispatchEvent(new PointerEvent('pointerleave', {
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
for (let index = 0; index < 40; index++) {
stage.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 20 + index, clientY: rect.top + 20,
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
if ((index + 1) % 20 === 0) { await card.updateComplete; await frame(); }
}
await card.updateComplete;
timings.hoverSeriesMs = performance.now() - hoverStarted;
deltas.hoverFullRenders = fullRenderCount - hoverBefore;
deltas.hoverTargets = ['device', 'room', 'miss'];
interactionLongTasks.hoverSeries = await hoverLongTasks.stop();
const cameraSvg = card.renderRoot.querySelector('[data-hp-live-viewbox="camera"], [data-hp-live-viewbox="floor"], .zoomwrap > svg');
const initialViewBox = cameraSvg?.getAttribute('viewBox') || '';
const panLongTasks = startLongTaskWindow();
const panStarted = performance.now();
const panMoveRenders = [];
const panTerminalRenders = [];
let relevantDuringGestureFullRenders = 0;
let relevantDuringGestureIntakes = 0;
for (let dragIndex = 0; dragIndex < 4; dragIndex++) {
const pointerId = 992 + dragIndex;
stage.dispatchEvent(new PointerEvent('pointerdown', {
clientX: rect.left + 200, clientY: rect.top + 200, button: 0, buttons: 1,
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
}));
await card.updateComplete;
const panMoveBefore = fullRenderCount;
for (let index = 0; index < 20; index++) {
stage.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 205 + index * 3, clientY: rect.top + 202 + index,
button: 0, buttons: 1, bubbles: true, composed: true,
pointerId, pointerType: 'mouse', isPrimary: true,
}));
if (dragIndex === 0 && index === 10) {
const deferredBefore = fullRenderCount;
const deferredIntakeBefore = card._hassSequence;
for (let tick = 0; tick < 3; tick++) {
currentStates = {
...currentStates,
[firstEntity]: { ...currentStates[firstEntity], state: `gesture-${tick}` },
};
card.hass = hassFor(currentStates);
}
await card.updateComplete;
relevantDuringGestureFullRenders += fullRenderCount - deferredBefore;
relevantDuringGestureIntakes += card._hassSequence - deferredIntakeBefore;
}
if ((index + 1) % 10 === 0) {
await card.updateComplete;
await frame();
}
}
const beforeTerminal = fullRenderCount;
panMoveRenders.push(beforeTerminal - panMoveBefore);
stage.dispatchEvent(new PointerEvent('pointerup', {
clientX: rect.left + 265, clientY: rect.top + 222, button: 0, buttons: 0,
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
}));
await card.updateComplete;
await frame();
panTerminalRenders.push(fullRenderCount - beforeTerminal);
}
timings.panSeriesMs = performance.now() - panStarted;
interactionLongTasks.panSeries = await panLongTasks.stop();
const movedViewBox = cameraSvg?.getAttribute('viewBox') || '';
const currentView = card._viewOr(card._baseVb());
const currentDevice = card._devices.find((item) => item.id === device.dataset.id);
const position = currentDevice ? card._pos(currentDevice) : null;
const scene = position ? card._scenePoint([position.x, position.y]) : null;
const deviceRect = device.getBoundingClientRect();
const expectedX = scene ? rect.left + ((scene[0] - currentView.x) / currentView.w) * rect.width : 0;
const expectedY = scene ? rect.top + ((scene[1] - currentView.y) / currentView.h) * rect.height : 0;
const overlayErrorPx = scene ? Math.hypot(
deviceRect.left + deviceRect.width / 2 - expectedX,
deviceRect.top + deviceRect.height / 2 - expectedY,
) : Infinity;
const cameraLongTasks = startLongTaskWindow();
const cameraStarted = performance.now();
const cameraBefore = fullRenderCount;
const pinchPointerA = 1001, pinchPointerB = 1002;
stage.dispatchEvent(new PointerEvent('pointerdown', {
clientX: rect.left + 360, clientY: rect.top + 300, button: 0, buttons: 1,
bubbles: true, composed: true, pointerId: pinchPointerA,
pointerType: 'touch', isPrimary: true,
}));
stage.dispatchEvent(new PointerEvent('pointerdown', {
clientX: rect.left + 460, clientY: rect.top + 300, button: 0, buttons: 1,
bubbles: true, composed: true, pointerId: pinchPointerB,
pointerType: 'touch', isPrimary: false,
}));
await card.updateComplete;
const pinchMoveBefore = fullRenderCount;
for (let index = 0; index < 20; index++) {
stage.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 360 - index, clientY: rect.top + 300,
button: 0, buttons: 1, bubbles: true, composed: true,
pointerId: pinchPointerA, pointerType: 'touch', isPrimary: true,
}));
stage.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 460 + index, clientY: rect.top + 300,
button: 0, buttons: 1, bubbles: true, composed: true,
pointerId: pinchPointerB, pointerType: 'touch', isPrimary: false,
}));
if ((index + 1) % 5 === 0) await frame();
}
const pinchTerminalBefore = fullRenderCount;
for (const pointerId of [pinchPointerA, pinchPointerB]) {
stage.dispatchEvent(new PointerEvent('pointerup', {
clientX: rect.left + 410, clientY: rect.top + 300,
button: 0, buttons: 0, bubbles: true, composed: true,
pointerId, pointerType: 'touch', isPrimary: pointerId === pinchPointerA,
}));
}
await card.updateComplete;
await frame();
const pinchTerminalRenders = fullRenderCount - pinchTerminalBefore;
for (let index = 0; index < 4; index++) {
stage.dispatchEvent(new WheelEvent('wheel', {
deltaY: index % 2 ? 120 : -120,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
bubbles: true, cancelable: true,
}));
}
await until(() => !card._cameraTransition.active);
await card.updateComplete;
await frame();
timings.cameraSeriesMs = performance.now() - cameraStarted;
interactionLongTasks.cameraSeries = await cameraLongTasks.stop();
deltas.cameraMoveFullRenders = pinchTerminalBefore - pinchMoveBefore;
deltas.cameraTerminalFullRenders = pinchTerminalRenders;
deltas.cameraFullRenders = fullRenderCount - cameraBefore;
const editorLongTaskWindows = [];
const editorConfigBefore = JSON.stringify(card._serverCfg);
const editorCallsBefore = wsCalls;
const editorPaintBefore = card._liveEditorPaintCount || 0;
let editorElapsed = 0;
const editorMoveRenders = [];
const editorTerminalRenders = [];
card._setMode('plan', false);
card._tool = 'draw';
card._path = [[100, 100]];
card.requestUpdate();
await card.updateComplete;
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
for (let index = 0; index < 5; index++) await frame();
let editorStage = card.renderRoot.querySelector('.stage');
let editorRect = editorStage.getBoundingClientRect();
let editorView = card._viewOr(card._baseVb());
const fromScene = (x, y) => ({
clientX: editorRect.left + ((x - editorView.x) / editorView.w) * editorRect.width,
clientY: editorRect.top + ((y - editorView.y) / editorView.h) * editorRect.height,
});
let editorPartLongTasks = startLongTaskWindow();
let partStarted = performance.now();
let editorMoveBefore = fullRenderCount;
for (let index = 0; index < 40; index++) {
editorStage.dispatchEvent(new PointerEvent('pointermove', {
...fromScene(110 + index * 2, 110 + index),
bubbles: true, composed: true, pointerId: 1101,
pointerType: 'mouse', isPrimary: true,
}));
if ((index + 1) % 10 === 0) await frame();
}
let editorTerminalBefore = fullRenderCount;
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
card._cursorPt = null;
card._path = [];
card.requestUpdate();
await card.updateComplete;
await frame();
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
editorElapsed += performance.now() - partStarted;
editorLongTaskWindows.push(await editorPartLongTasks.stop());
card._tool = 'resize';
card.requestUpdate();
await card.updateComplete;
const resizeRoom = card._rszRooms()[0];
const resizeEvent = {
pointerId: 1102, stopPropagation: () => undefined,
preventDefault: () => undefined, target: null,
};
card._rszEdgeDown(resizeEvent, resizeRoom.id, 1);
const resizePlan = card._resize?.plan || card._rszDrag?.plan;
if (!resizePlan) throw new Error('interaction resize plan was not created');
const resizePreflightBefore = physicalPreflightCount;
const resizePreflightMsBefore = physicalPreflightMs;
editorStage = card.renderRoot.querySelector('.stage');
editorRect = editorStage.getBoundingClientRect();
editorView = card._viewOr(card._baseVb());
// A browser delivers pointerdown and pointermove as separate tasks.
// Keep the synthetic harness from attributing both handlers to one
// impossible long task while still timing every move-side preflight.
await new Promise((done) => setTimeout(done, 0));
editorPartLongTasks = startLongTaskWindow();
partStarted = performance.now();
editorMoveBefore = fullRenderCount;
for (let index = 0; index < 40; index++) {
const target = [
resizePlan.a[0] + resizePlan.n[0] * card._gridPitch * (1 + index / 40),
resizePlan.a[1] + resizePlan.n[1] * card._gridPitch * (1 + index / 40),
];
card._rszMove({
...resizeEvent,
clientX: editorRect.left + ((target[0] - editorView.x) / editorView.w) * editorRect.width,
clientY: editorRect.top + ((target[1] - editorView.y) / editorView.h) * editorRect.height,
});
if ((index + 1) % 10 === 0) await frame();
}
editorTerminalBefore = fullRenderCount;
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
deltas.resizeLivePreflightChecks = physicalPreflightCount - resizePreflightBefore;
deltas.resizeLivePreflightMs = physicalPreflightMs - resizePreflightMsBefore;
card._rszCancelDrag();
await card.updateComplete;
await frame();
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
editorElapsed += performance.now() - partStarted;
editorLongTaskWindows.push(await editorPartLongTasks.stop());
card._setMode('decor', false);
card._decorTool = 'select';
const decorShape = card._decorList.find((shape) =>
shape.kind === 'furniture' || shape.kind === 'rect' || shape.kind === 'ellipse');
if (!decorShape) throw new Error('interaction profile has no transformable decor');
card._decorSel = decorShape.id;
card.requestUpdate();
await card.updateComplete;
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
for (let index = 0; index < 5; index++) await frame();
card._dtMeasure();
await card.updateComplete;
editorStage = card.renderRoot.querySelector('.stage');
editorRect = editorStage.getBoundingClientRect();
editorView = card._viewOr(card._baseVb());
const box = card._decorBoxOf(decorShape);
const cornerX = box.x + box.w, cornerY = box.y + box.h;
const decorPointer = (x, y) => ({
pointerId: 1103, pointerType: 'mouse', shiftKey: false,
clientX: editorRect.left + ((x - editorView.x) / editorView.w) * editorRect.width,
clientY: editorRect.top + ((y - editorView.y) / editorView.h) * editorRect.height,
stopPropagation: () => undefined, preventDefault: () => undefined,
currentTarget: null, target: null,
});
card._dtStart(decorPointer(cornerX, cornerY), 'scale', [1, 1]);
await card.updateComplete;
await frame();
editorPartLongTasks = startLongTaskWindow();
partStarted = performance.now();
editorMoveBefore = fullRenderCount;
for (let index = 0; index < 40; index++) {
card._dtMove(decorPointer(cornerX + index, cornerY + index / 2));
if ((index + 1) % 10 === 0) await frame();
}
editorTerminalBefore = fullRenderCount;
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
card._cancelDecorGesture();
await card.updateComplete;
await frame();
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
editorElapsed += performance.now() - partStarted;
editorLongTaskWindows.push(await editorPartLongTasks.stop());
timings.editorSeriesMs = editorElapsed;
interactionLongTasks.editorSeries = {
supported: editorLongTaskWindows.every((window) => window.supported),
count: editorLongTaskWindows.reduce((sum, window) => sum + window.count, 0),
maxMs: Math.max(0, ...editorLongTaskWindows.map((window) => window.maxMs)),
totalMs: editorLongTaskWindows.reduce((sum, window) => sum + window.totalMs, 0),
};
deltas.editorMoveFullRenders = editorMoveRenders;
deltas.editorTerminalFullRenders = editorTerminalRenders;
deltas.editorLivePaints = (card._liveEditorPaintCount || 0) - editorPaintBefore;
deltas.editorConfigStable = JSON.stringify(card._serverCfg) === editorConfigBefore;
deltas.editorWsWrites = wsCalls - editorCallsBefore;
deltas.editorLongTaskParts = editorLongTaskWindows;
card._setMode('view', false);
await card.updateComplete;
await frame();
deltas.panMoveFullRenders = panMoveRenders;
deltas.terminalFullRenders = panTerminalRenders;
deltas.relevantDuringGestureFullRenders = relevantDuringGestureFullRenders;
deltas.relevantDuringGestureIntakes = relevantDuringGestureIntakes;
deltas.viewBoxMoved = !!initialViewBox && initialViewBox !== movedViewBox;
deltas.overlayErrorPx = Number(overlayErrorPx.toFixed(2));
deltas.heavyNodeStable = card.renderRoot.querySelector('.wallbodies') === heavy;
deltas.diagnosticsScans = diagnosticsScanCount - diagnosticsBefore;
deltas.fullRenderReasons = fullRenderReasons.slice(reasonsBefore);
interactionTimings = timings;
interactionDiagnostics = { supported: requiresInteraction, ...deltas };
if (requiresInteraction && (
deltas.irrelevantFullRenders !== 0 || deltas.irrelevantHassIntakes !== 30
|| deltas.relevantOutsideGestureFullRenders !== 1 || deltas.hoverFullRenders !== 0
|| deltas.hoverTargets.join(',') !== 'device,room,miss'
|| deltas.panMoveFullRenders.some((count) => count !== 0)
|| deltas.terminalFullRenders.some((count) => count !== 1)
|| deltas.relevantDuringGestureFullRenders !== 0 || !deltas.tooltipVisible
|| deltas.relevantDuringGestureIntakes !== 3
|| deltas.cameraMoveFullRenders !== 0 || deltas.cameraTerminalFullRenders !== 1
|| deltas.editorMoveFullRenders.some((count) => count !== 0)
|| deltas.editorTerminalFullRenders.some((count) => count > 1)
|| deltas.resizeLivePreflightChecks < 1
|| deltas.editorLivePaints < 3 || !deltas.editorConfigStable || deltas.editorWsWrites !== 0
|| !deltas.viewBoxMoved || deltas.overlayErrorPx > 1 || !deltas.heavyNodeStable
|| deltas.diagnosticsScans !== 0
)) throw new Error(`interaction structural contract failed: ${JSON.stringify(interactionDiagnostics)}`);
}) : null;
let planSnapDiagnostics = null;
const planSnapPointer = planSnap ? await duration(async () => {
card._setMode('plan');
@@ -432,6 +860,11 @@ try {
planSnapPointerMs: planSnapPointer.ms,
planSnapDiagnostics,
} : {}),
...(interactionSeries ? {
interactionSeriesMs: interactionSeries.ms,
...interactionTimings,
interactionDiagnostics,
} : {}),
spaceSwitchMs: spaceSwitch.ms,
stateUpdateMs: stateUpdate.ms,
resizePreviewMs: resizePreview.ms,
@@ -442,6 +875,7 @@ try {
load: loadLongTaskResult,
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
...(interactionSeries ? interactionLongTasks || {} : {}),
spaceSwitch: spaceSwitch.longTasks,
stateUpdate: stateUpdate.longTasks,
resizePreview: resizePreview.longTasks,
@@ -461,6 +895,7 @@ try {
}, {
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
interaction, requiresInteraction,
});
if (measuredSample >= 0) rows.push(row);
}
@@ -474,6 +909,9 @@ const metricNames = [
];
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
if (interaction) metricNames.splice(2, 0,
'interactionSeriesMs', 'hoverSeriesMs', 'panSeriesMs', 'cameraSeriesMs',
'editorSeriesMs', 'irrelevantHaTicksMs', 'relevantHaTickMs');
const report = {
schema: 2,
profile,
+76
View File
@@ -0,0 +1,76 @@
/** #461 production-bundle wall-draw terminal-click performance profile. */
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { launch, finish } from './serve.mjs';
import {
installWallDrawClickHarness, resetWallDrawClickHarness, runWallDrawClickChain,
} from './wall-draw-click-harness.mjs';
const outputArg = process.argv.find((arg) => arg.startsWith('--output='));
const output = outputArg ? resolve(outputArg.slice('--output='.length)) : null;
const { page, browser } = await launch({ width: 1200, height: 900 }, 1);
await installWallDrawClickHarness(page);
const run = async (remote) => {
const fixture = await resetWallDrawClickHarness(page, remote);
// One complete chain warms the browser/JIT; reset removes all of its state.
await runWallDrawClickChain(page);
await resetWallDrawClickHarness(page, remote);
return { fixture, result: await runWallDrawClickChain(page) };
};
const base = await run(false);
const remote = await run(true);
const sorted = [...base.result.times].sort((a, b) => a - b);
const medianMs = sorted[Math.floor(sorted.length / 2)];
const maxMs = Math.max(...base.result.times);
const remoteSorted = [...remote.result.times].sort((a, b) => a - b);
const remoteMedianMs = remoteSorted[Math.floor(remoteSorted.length / 2)];
const clickShape = (click) => click.fullSpacePhysicalChecks === 0
&& click.localPhysicalChecks === 1
&& click.junctionArtifactPasses === 1
&& click.configWrites === 1 && click.history === 1 && click.pathPoints === 1;
const fixtureShape = base.fixture.spaces === 5 && base.fixture.rooms === 12
&& base.fixture.positiveSegments >= 36 && base.fixture.savedDrafts === 4;
const remoteFixtureShape = remote.fixture.positiveSegments === base.fixture.positiveSegments * 2;
const structural = fixtureShape && remoteFixtureShape
&& base.result.first.fullSpacePhysicalChecks === 0
&& base.result.first.localPhysicalChecks === 0
&& base.result.first.configWrites === 0 && base.result.first.history === 0
&& base.result.clicks.length === 7 && base.result.clicks.every(clickShape)
&& remote.result.clicks.every(clickShape)
&& base.result.metrics.localProofMaxObjects < base.fixture.positiveSegments
&& remote.result.metrics.localProofMaxObjects === base.result.metrics.localProofMaxObjects
&& base.result.ids.length === 7 && new Set(base.result.ids).size === 7
&& base.result.terminal.fullSpacePhysicalChecks >= 1
&& base.result.terminalPartitionCount >= 1 && base.result.activeCleared;
const budgets = {
medianMs: 150,
maxMs: 250,
remoteMedianMs: medianMs * 1.5 + 20,
};
const timing = medianMs <= budgets.medianMs && maxMs <= budgets.maxMs
&& remoteMedianMs <= budgets.remoteMedianMs;
const report = {
issue: 461, profile: 'wall-draw-click-v1',
fixture: base.fixture, remoteFixture: remote.fixture,
timing: {
samplesMs: base.result.times.map((value) => Math.round(value * 10) / 10),
medianMs: Math.round(medianMs * 10) / 10,
maxMs: Math.round(maxMs * 10) / 10,
remoteMedianMs: Math.round(remoteMedianMs * 10) / 10,
},
budgets: Object.fromEntries(Object.entries(budgets)
.map(([key, value]) => [key, Math.round(value * 10) / 10])),
counters: base.result.metrics,
structural,
timingPass: timing,
pass: structural && timing,
};
if (output) {
mkdirSync(dirname(output), { recursive: true });
writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
}
console.log(JSON.stringify(report, null, 2));
if (!report.pass) process.exitCode = 1;
await finish(browser);
+47
View File
@@ -0,0 +1,47 @@
/** #54: bounded provider normalization and incident-only hover performance. */
import { performance } from 'node:perf_hooks';
import { mapTopologies, normalizeZhaTopology, resolveMappedTopologyHover } from '../test-build/zigbee-topology.js';
const NODES = 500;
const SAMPLES = 9;
const BUDGETS = { normalizeMs: 80, mapMs: 160, firstHoverMs: 180, repeatedHoverMs: 120 };
const hex = (index) => index.toString(16).padStart(16, '0');
const rows = Array.from({ length: NODES }, (_, index) => ({
ieee: hex(index + 1), device_reg_id: `d${index}`, device_type: index ? 'Router' : 'Coordinator',
neighbors: [1, 2, 3, 4, 5, 6].flatMap((offset) => {
const other = index + offset;
return other < NODES ? [{ ieee: hex(other + 1), lqi: (index * 17 + offset * 13) % 256 }] : [];
}),
}));
const registry = {
revision: 1, authoritative: true, access: 'full', lastSuccess: Date.now(), entities: {},
devices: Object.fromEntries(rows.map((_, index) => [`d${index}`, { id: `d${index}` }])),
};
const active = { kind: 'active', enabledEntityIds: [], allEntityIds: [] };
const devices = rows.map((_, index) => ({
id: `m${index}`, name: '', model: '', area: '', space: index % 5 ? 'main' : 'remote',
icon: '', entities: [], bindingKind: 'device', bindingRef: `d${index}`, bindingStatus: active,
}));
const measured = (fn) => {
const samples = [];
for (let i = 0; i < SAMPLES; i++) {
const started = performance.now(); fn(); samples.push(performance.now() - started);
}
samples.sort((a, b) => a - b);
return samples[Math.floor(samples.length * .9)];
};
let topology; let mapped;
const normalizeMs = measured(() => { topology = normalizeZhaTopology(rows); });
const mapMs = measured(() => { mapped = mapTopologies([topology], devices, registry); });
const firstHoverMs = measured(() => resolveMappedTopologyHover(mapped, 'main', 'm1'));
const repeatedHoverMs = measured(() => {
for (let i = 1; i <= 20; i++) resolveMappedTopologyHover(mapped, 'main', `m${i}`);
});
const result = { nodes: topology.nodes.length, links: topology.links.length,
normalizeMs, mapMs, firstHoverMs, repeatedHoverMs };
console.log(JSON.stringify(result, null, 2));
for (const [name, budget] of Object.entries(BUDGETS)) {
if (result[name] > budget) throw new Error(`${name} ${result[name].toFixed(2)} ms > ${budget} ms`);
}
+56
View File
@@ -0,0 +1,56 @@
const CELL_CM = 5;
const point = (x, y) => [x / 240, y / 240];
const roomAt = (id, x, y) => {
const poly = [point(x, y), point(x + 24, y), point(x + 24, y + 24), point(x, y + 24)];
const wallIds = poly.map((_, index) => `${id}-wall-${index}`);
return {
room: { id, name: id, area: null, poly, wall_ids: wallIds },
segments: poly.map((a, index) => ({
id: wallIds[index], a, b: poly[(index + 1) % poly.length], cm: 15,
})),
walls: poly.map((a, index) => ({
key: `${id}-legacy-${index}`, a, b: poly[(index + 1) % poly.length], cm: 15,
})),
};
};
const roomGrid = (prefix, xOffset = 0) => Array.from({ length: 12 }, (_, index) =>
roomAt(`${prefix}-${index}`, xOffset + 8 + (index % 4) * 32, 8 + Math.floor(index / 4) * 32));
export const WALL_DRAW_CLICK_POINTS = [
point(152, 144), point(176, 144), point(176, 168), point(200, 168),
point(200, 144), point(224, 144), point(224, 168), point(236, 168),
];
export function makeWallDrawClickFixture(remoteVariant = false) {
const primary = roomGrid('base');
const remote = remoteVariant ? roomGrid('remote', 300) : [];
const all = [...primary, ...remote];
const roomDrafts = Array.from({ length: 4 }, (_, index) => ({
id: `saved-draft-${index}`,
points: [point(8 + index * 28, 112), point(20 + index * 28, 112), point(20 + index * 28, 124)],
segments: [
{ id: `saved-draft-${index}-0`, cm: 15 },
{ id: `saved-draft-${index}-1`, cm: 15 },
],
}));
const edited = {
id: 'edited', title: 'Wall draw click', cell_cm: CELL_CM,
view_box: [0, 0, 1, 1],
rooms: all.map((item) => item.room),
wall_segments: all.flatMap((item) => item.segments),
walls: all.flatMap((item) => item.walls),
room_drafts: roomDrafts,
partitions: [], wall_columns: [], openings: [], open_spans: [],
};
return {
model_version: 9,
spaces: [edited, ...Array.from({ length: 4 }, (_, index) => ({
id: `other-${index}`, title: `Other ${index}`, cell_cm: CELL_CM,
view_box: [0, 0, 1, 1], rooms: [], wall_segments: [], walls: [],
room_drafts: [], partitions: [], wall_columns: [], openings: [], open_spans: [],
}))],
markers: [], settings: {},
};
}
+28 -1
View File
@@ -10,6 +10,9 @@ import {
goldenAcceptancePlan, goldenAcceptanceRefusal, goldenSilentDeclarations,
goldenWitnessRefusal,
} from '../../scripts/golden-acceptance.mjs';
import {
assertCaptureEnvironment, captureEnvironment, environmentNote,
} from '../../scripts/capture-environment.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const reviewed = process.argv.includes('--reviewed');
@@ -38,6 +41,16 @@ if (typeof report.chromium !== 'string' || !report.chromium)
throw new Error('candidate report does not identify its Chromium build');
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
// Среда приёмки (#455). Отчёт съёмки платформу не несёт — `run.mjs` входит в
// корпус sourceFingerprint, и его правка объявила бы устаревшими бандл,
// скриншоты документации и сам индекс эталонов. Но приёмка идёт там же, где
// лежат артефакты, поэтому её платформа — честный признак среды кадров.
const environment = captureEnvironment();
const foreignAllowed = assertCaptureEnvironment({ kind: 'golden', stage: 'accept' });
if (foreignAllowed) {
console.log(`Чужая среда приёмки разрешена осознанно: ${foreignAllowed}`);
}
const refusal = goldenAcceptanceRefusal(report.results, declared, declaredNew);
if (refusal) throw new Error(refusal);
@@ -72,7 +85,18 @@ const witnessCheck = goldenWitnessRefusal({
skipWitnesses,
skipReason,
});
if (witnessCheck.refusal) throw new Error(witnessCheck.refusal);
if (witnessCheck.refusal) {
// Приписка про среду — то, чего не хватало отказу: «свидетелей 0 из 10» без
// неё читается как «объяви больше сцен», и обход в одну команду выглядит
// решением (#455).
const note = environmentNote({
capturedOn: environment.platform,
acceptedOn: existsSync(manifestPath)
? JSON.parse(readFileSync(manifestPath, 'utf8')).platform || null
: null,
});
throw new Error(note ? `${witnessCheck.refusal}\n${note}` : witnessCheck.refusal);
}
// Кандидат проверяется целиком, до всякого решения о замене: сломанный отчёт
// не имеет права оставить каталог эталонов половинным.
for (const scenario of GOLDEN_SCENARIOS) {
@@ -102,6 +126,9 @@ writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify
acceptedAt: new Date().toISOString(),
sourceFingerprint: report.buildFingerprint,
chromium: report.chromium,
// Платформа рядом с версией браузера: провенанс среды, по которому следующая
// приёмка отличит «объявили не то» от «сняли не там» (#455).
platform: environment.platform,
// #355: след приёмки в артефакте, не только в истории shell.
witnesses: skipWitnesses
? { skipped: true, reason: skipReason }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

+37 -33
View File
@@ -1,14 +1,18 @@
{
"schema": 1,
"matrixVersion": 54,
"acceptedAt": "2026-09-03T06:25:02.855Z",
"sourceFingerprint": "b69bdfe3df225cf1b96fea0159a03a620e38cb94ec6106f9ee3b0e57cdefd183",
"matrixVersion": 55,
"acceptedAt": "2026-09-05T15:05:00.040Z",
"sourceFingerprint": "450863830ad6931b7698085deecbd39f6686a3e84f1b777008be5347426f4b21",
"chromium": "151.0.7922.34",
"platform": "linux",
"witnesses": {
"count": 132,
"count": 117,
"floor": 10
},
"scenarios": {
"version-mismatch-desktop-dark-en": "8efc4365de5e5f69dc864a36d87144c3a16a2a52f89ec6e5556fc7755f538fb6",
"version-mismatch-touch-light-ru": "6fee87758c8373472b57542b8705595113ef07b92014ef6f5b977d4d1aa19808",
"version-mismatch-kiosk-attempted-dark-de": "d3d0863fe814d20f5b081de6ac14f8bbf7d559be019808e62cd6dcdc7abcaa70",
"hidden-wall-diagnostics-plan-light": "4113474796ccb1b7b8c876fcb463e97eb77bb1ec96129195a0dafdcc7ccca334",
"hidden-wall-diagnostics-plan-dark": "40c48868f0251aec3b1a14c0eef77a78cc7965420fb9b41f46f8190d1fdfd11a",
"coincident-partition-before-dark": "daacbc763c81ad037b305fc34572729c063ec542d031821660b8d3d096a35d19",
@@ -66,9 +70,9 @@
"opening-symbol-flip-pairs-light": "c2d9102921483da1d0216ed9bc55fc9112d67762f159df8af64a277f0db0acf4",
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
"geometry-devices-editor-dark": "7c4072fce11f91b2c894bdb5f4f9113e72089306d629c995d38dfd17f2b65cc8",
"device-inbox-desktop-en-light": "89587944da06872f610da33e8e39441d108cb34849d8b09d4925bff4d0977565",
"device-inbox-desktop-ru-dark": "fe9506408fc198c167f2d4b9cfe91bd9fdfb722b311772457ae44c9d06248680",
"device-inbox-narrow-ru-dark": "3ddb3b66a613c6406d3d9ce2d4d479c8d84eff04acd9b5b2dce02c4c777fc738",
"device-inbox-desktop-en-light": "a73761da0594ba2138f098d971d74566f9915f59a563fd55295add241145ba36",
"device-inbox-desktop-ru-dark": "5a9eb32ffb7f408c6bf8adca9d76b0d01d7b0b514f8b33a01e500f9230541f1a",
"device-inbox-narrow-ru-dark": "65c46b7311f850f305147d75a24ddeded67c9e8b18ea362c88b9964c50a5b727",
"geometry-decor-editor-dark": "4023e36ff4521011153983538e4a43ad80efd92bf07d523c333982bbd2f80ad6",
"tray-wide-selection-en": "5a535b457b8b274ff25085ee992aebd95d756415afe1321d0aadee70610d5801",
"tray-wide-tool-ru": "56c09bb397b76b7e5c6f4d7eaf7ab59e5d96faa409e553c45c6ce7605e9a9418",
@@ -133,33 +137,33 @@
"large-house-zoom-040-dark": "5601d5ce3b1fe79365984301872a851d6c2e81878ec6f6cc3b88aab811d8609a",
"large-house-zoom-250-dark": "7abb72baba00ed035d9f7519b99e1d9f872a6cef81861ed096a511fe9320471f",
"large-house-warm-remount-dark": "441c32f130b8f291582482fa6445f9c85f6f13c61b956020d4fa1d6153094efa",
"device-dialog-desktop-en": "465175fc9960c7ceb5208cf62f919381d56179ef12a93bee20e5c7af63757e7a",
"device-dialog-mobile-ru": "8128783f4e9f9ff301f15db9fa7388b51aa7e8a4a74ac0aaf962b21ecda52c78",
"device-dialog-desktop-de": "8440d4e980fac5df53142b6b118e527d9c0272e7515b65faaba6a58bc9441075",
"device-dialog-desktop-en": "f3a3a45dad2cc22e692366114a4f87272b39ba261c1509e8a501dbfb82b6584e",
"device-dialog-mobile-ru": "7ac796286ab4e65275838b4095ea3f16491311f99fd98cad3bbb475c6061e31c",
"device-dialog-desktop-de": "9d39d3e73a3f36171e8cc7c930bcb43bfbfd788e31db0f584962300a2b00ca93",
"german-view-mobile-light": "55112787d9a26be66dfd8b724115962c4b891d1f8276b7b5b36b0ff25da48257",
"toggle-entity-dialog-desktop-en": "7a8ac3071d8bb3f79f2bfe724d0138e2b309baab3104ef8afb0b2b081c8b82b1",
"toggle-entity-dialog-mobile-ru": "d3062e76da27218cac45ca4c76f34b358d9cb98e85288f24e64b7de1bcca949d",
"device-help-popover-light-ru": "c2146f13d03841c395b0772f1a21de358c62a8703d3d02e7d026fac8ae29955e",
"decor-color-popover-mobile-ru": "c65318327c6e4631c769419e791a749d667b4dfc36f2e3fc46d6fac53180cf1b",
"decor-color-popover-desktop-en": "60de64b1bc072327cb88d2e876732d50d5cb7441c850f52b7af6f4ed5c6de22b",
"general-color-popover-desktop-en": "5b47ab921f889c9ad41cab195e6efeed283f014d37f5377714b54ef30282ebf6",
"settings-help-zoom-200-en-light": "bb1192a52bab5ebc6984a5d799a32d05f9ce374ce6b493f5f618b639eedce8d5",
"settings-help-zoom-200-ru-dark": "13e7fdd19017c38a5659394e622ff54ac8a426f9cb6e85b3248864767aa9f42b",
"support-desktop-empty-light-en": "fecabb63521f258acc66ecdf7a438db10c73d0eac39618c55fb51287080f01f5",
"support-desktop-preview-dark-en": "a866aa74e3385ac45e837b328d64ed65bc4b108eb9dc4149fcccf393975e72b6",
"support-phone-validation-light-ru": "7a461c00631a05872b9aab1f4c0b0b65d1c918273ff06bba25c7bd2e87fba1ca",
"support-phone-success-dark-en": "81f83402933a573002581646d0b322f3770f3df41d5334373315ed5ad4974883",
"support-relay-error-light-en": "e9a9ad906e106eedfa6e84c592325f1604eb64c89c51135c076ac87a6a0766b2",
"support-tablet-preview-dark-ru": "1264e005cd764408edb9b285a5dfbe92ceb90cb925f624b8f65cd47edb96c0c9",
"device-ripple-color-popover-mobile-ru": "d9ebf83ad0d2995b46f6174daf6e21ca5404952afa308ddd3a66ecefcee565d0",
"space-room-color-popover-desktop-ru": "9bbbcf9c5ea6ff5490b500be8cb3086af9d368d14ca50c304f42a697e296ece1",
"backup-full-preview-desktop-en": "0fbd23a92121668001faf5c9ed555a31107704f3074d1791709c8436517d3d1f",
"backup-plan-only-export-desktop-en": "ce66fb57edbc87d56355b0d73a2d4203a67c275d5927d400ef1758ac0b607b34",
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
"optimize-preflight-dialog-dark-en": "101e1a97c6229c693a4edfb4878c6a4564132d6fbcaddd6276cb25aefa429260",
"optimize-preflight-dialog-light-ru": "4f34a59baa95b7ad0ad3dbb47f23996537878f6b666dc9111236713f513de97e",
"optimize-orphan-references-dark-en": "c8122eda61a52ead0f3609b9872b513004ffbc44c1d4928ec11e5d3b5978af78",
"optimize-orphan-references-light-ru": "3cc0b16af015cf695f8bd278dee4d76b160639021a33bc3a28441fb72a5e8e5c",
"toggle-entity-dialog-desktop-en": "3ea2bb828b554b6fb544b77fdef2af947e45af632769f4deab03c6adf195fa68",
"toggle-entity-dialog-mobile-ru": "2d433f3f1fab9e9d4c3d413c302ac0d3b44a2d46ec9ecbf098c3063da6f1df04",
"device-help-popover-light-ru": "b8cb39c77690bb2a2a150a2a77af55411b3f2053741eee86cd5fd19b41b4307f",
"decor-color-popover-mobile-ru": "c63b72493040fefdbcf55ce5325630a321b171f62962a345caa89ec5fa837f55",
"decor-color-popover-desktop-en": "49bc8317d452fbc9e7e95f7c139423838a5ecde763b1fe328c7b1be6ca20ed98",
"general-color-popover-desktop-en": "4b3cd5b7fb405b912cb3506680151b0237b803499bbfbad597c20d3fabc869a7",
"settings-help-zoom-200-en-light": "a6f49b4d0a171dc5f74d409c6e7df0e1c57bc52086b3a66dd794efde1236eef8",
"settings-help-zoom-200-ru-dark": "0b1f995abec3b14cd5b25e0c0fb0a8d04d08567b0b3e5717463059efa244296d",
"support-desktop-empty-light-en": "04d968e28c85096f6eb637f9ae01c2bae86c6d1540a9b3bfb83f0f4992127b4e",
"support-desktop-preview-dark-en": "dd3502ce1584109a837ba80f5683ddd5a1ea2063150217121f6e0b8832a0a659",
"support-phone-validation-light-ru": "37059c99431d6890d1f1a66444c7e853e51387655cd1ee6474063299281f2b1f",
"support-phone-success-dark-en": "024e72048558561b66f13d635fc22e1e3d289d0b39499e6595dc986761daa31f",
"support-relay-error-light-en": "ab769c512f94a1dea22f36f57a36796bc3b5bc3ec788f4ca52bfccbc7fc25ab0",
"support-tablet-preview-dark-ru": "416604355b3785aecda02f1ad21d031ce4c977180ed8606937b6d189b0afb7b1",
"device-ripple-color-popover-mobile-ru": "5b7ff2f212a1731d0d25f57a385fe594b32af8ba3ae0183c40fb285addf9f832",
"space-room-color-popover-desktop-ru": "137deea402c850207bd6e217524359757ca13d9783c7728419e89260b81bcc75",
"backup-full-preview-desktop-en": "fc3ac9ec9eae728f2524131f3f9e3a55fa4afce43f440ee6f7b04bd3b78e66df",
"backup-plan-only-export-desktop-en": "b000db624416d90a8d9e658ec602d278f2bc8df63af2e8eb9562bc2fbff24ed8",
"backup-space-preview-mobile-ru": "4575ba8783647518abbbe9903fd915ec0d3caceaa08a70fa70cf8bf8e7b08356",
"optimize-preflight-dialog-dark-en": "0e140653af81cf8a965fee00d6f5ce7d0b49c22d329f5237e96e6ba1829252b4",
"optimize-preflight-dialog-light-ru": "1fc545b7017c6ae3b0a2b0e1330b037dc0e565113c5892d48be64ca30f34abf6",
"optimize-orphan-references-dark-en": "9611469c4d4e542b77abcc52c7c9484237aa18ff58994926be66514a377564f4",
"optimize-orphan-references-light-ru": "7ea732b04dfd7aca25107935576326975b3cc7cf5757d844d809ceb4cef0a245",
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+43 -19
View File
@@ -35,6 +35,7 @@ const wallUnionIsolationFixture = JSON.parse(readFileSync(
const cardVersion = JSON.parse(readFileSync(
new URL('../../package.json', import.meta.url), 'utf8',
)).version;
const VERSION_RELOAD_ATTEMPT_KEY = 'houseplan-card:version-reload-target:v1';
const fixtureFor = (scenario) => scenario.fixture === 'large'
? makeLargeHouseFixture()
@@ -56,7 +57,7 @@ const themeVars = {
async function stableEnvironment(page, scenario) {
await page.setViewportSize(scenario.viewport);
await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: scenario.theme });
await page.evaluate(({ variables, theme }) => {
await page.evaluate(({ variables, theme, attemptedTarget, attemptKey }) => {
let style = document.getElementById('hp-golden-stability');
if (!style) {
style = document.createElement('style');
@@ -82,7 +83,15 @@ async function stableEnvironment(page, scenario) {
'--hp-golden-page-bg', theme === 'light' ? '#eef1f4' : '#11151b',
);
document.documentElement.style.colorScheme = theme;
}, { variables: themeVars[scenario.theme] || themeVars.dark, theme: scenario.theme });
sessionStorage.removeItem(attemptKey);
if (attemptedTarget) sessionStorage.setItem(attemptKey, attemptedTarget);
}, {
variables: themeVars[scenario.theme] || themeVars.dark,
theme: scenario.theme,
attemptedTarget: scenario.versionRecoveryAttempted
? String(scenario.integrationVersion || '').trim() : null,
attemptKey: VERSION_RELOAD_ATTEMPT_KEY,
});
}
/** Apply every data-only scenario override before the fixture crosses into the browser. */
@@ -736,7 +745,7 @@ export async function prepareGoldenScenario(page, scenario) {
await page.mouse.move(0, 0);
const fixture = prepareGoldenFixture(scenario);
const result = await page.evaluate(async ({ fixture, scenario, cardVersion }) => {
const result = await page.evaluate(async ({ fixture, scenario, cardVersion, attemptKey }) => {
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
@@ -760,10 +769,9 @@ export async function prepareGoldenScenario(page, scenario) {
window.__goldenEditor?.remove?.();
window.__card?.remove?.();
localStorage.clear();
history.replaceState(null, '', scenario.labs?.length
? `?hp-labs=${encodeURIComponent(scenario.labs.join(','))}` : location.pathname);
if (scenario.labs?.length) {
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(scenario.labs));
history.replaceState(null, '', scenario.alpha ? '?hp_alpha=1' : location.pathname);
if (scenario.alpha) {
localStorage.setItem('houseplan_card_alpha_v1', '1');
}
if (scenario.projection && scenario.space) {
localStorage.setItem('houseplan_card_view_v1', JSON.stringify({
@@ -788,8 +796,11 @@ export async function prepareGoldenScenario(page, scenario) {
three: { floor_id: 'three', name: 'Three', level: 2 },
},
callWS: async (message) => {
if (message.type === 'houseplan/config/get')
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
if (message.type === 'houseplan/config/get') return {
config: structuredClone(fixture.config), rev: 1, can_write: true,
...(Object.prototype.hasOwnProperty.call(scenario, 'integrationVersion')
? { integration_version: scenario.integrationVersion } : {}),
};
if (message.type === 'houseplan/layout/get')
return { layout: structuredClone(fixture.layout || {}), rev: 1 };
if (message.type === 'houseplan/trail/get') return scenario.vacuumTrail ? {
@@ -818,21 +829,16 @@ export async function prepareGoldenScenario(page, scenario) {
const card = document.createElement('houseplan-card');
card.setConfig(cardConfig);
host.replaceChildren(card);
if (scenario.testOnlyLabsSnapshot) {
if (!scenario.labs?.length || typeof card._onLabsSnapshot !== 'function') {
throw new Error(`invalid test-only Labs contract: ${scenario.id}`);
}
// connectedCallback has already received the real (expired) registry
// snapshot. Inject only the renderer fixture before hass/model boot so
// fit and warm-remount follow the former live-Labs lifecycle exactly.
card._onLabsSnapshot({ active: Object.freeze([...scenario.labs]), space: '' });
}
card.hass = hassFor();
await until(() => card._loadOk && card._model?.length === fixture.config.spaces.length);
await card.updateComplete;
const expectedDevices = Object.keys(fixture.devices || {}).length;
if (expectedDevices) await until(() => card._devices?.length >= expectedDevices);
await until(() => card._booting === false);
if (scenario.alpha
&& (window.__hpAlpha !== true || JSON.stringify(window.__hpLabs) !== '["iso"]')) {
throw new Error(`alpha contract did not activate isometric capability: ${scenario.id}`);
}
// Golden scenarios intentionally call internal editor commands directly.
// Preload the lazy runtime for that legacy harness contract; cold-View
// loading and retry semantics are covered by smoke_lazy_editor_chunk.
@@ -859,6 +865,21 @@ export async function prepareGoldenScenario(page, scenario) {
await card.updateComplete;
await settleMode(card);
}
if (Object.prototype.hasOwnProperty.call(scenario, 'integrationVersion')) {
const target = String(scenario.integrationVersion || '').trim();
await until(() => card._versionRecovery?.relation?.kind === 'mismatch'
&& card.renderRoot.querySelector('.version-recovery'));
const notice = card._versionRecovery.banner;
const rendered = card.renderRoot.querySelector('.version-recovery');
if (!target || notice?.backend !== target
|| rendered?.getAttribute('data-version-recovery-target') !== target) {
throw new Error(`golden version notice target mismatch: ${scenario.id}`);
}
if (scenario.versionRecoveryAttempted
&& sessionStorage.getItem(attemptKey) !== target) {
throw new Error(`golden kiosk attempted target missing: ${scenario.id}`);
}
}
// #304: the two existing hidden-wall golden scenes also guard the static
// architectural overlay in every Plan tool. Finish in draw so the reviewed
// pixels stay stable; a missing/changed tool projection fails before capture.
@@ -1066,6 +1087,9 @@ export async function prepareGoldenScenario(page, scenario) {
card._setProjection('iso');
await card.updateComplete;
await frame();
if (card._effectiveProjection?.() !== 'iso') {
throw new Error(`isometric golden rendered Flat: ${scenario.id}`);
}
await until(() => card._renderProjection === 'iso');
}
if (Number.isFinite(scenario.zoom)) {
@@ -1809,7 +1833,7 @@ export async function prepareGoldenScenario(page, scenario) {
cachedRays: card._sunRaysCache?.rays?.length || 0,
} } : {}),
};
}, { fixture, scenario, cardVersion });
}, { fixture, scenario, cardVersion, attemptKey: VERSION_RELOAD_ATTEMPT_KEY });
if (scenario.tabDrag) {
const drag = await page.evaluate((placement) => {
const card = window.__goldenCard;
+22 -14
View File
@@ -1,16 +1,11 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 54;
export const GOLDEN_MATRIX_VERSION = 55;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
// The product flag expires at 1.65.0 by contract. Golden keeps the dormant
// renderer covered through an explicit card-only snapshot; this never changes
// URL/storage/registry behaviour and must fail if the rendered view stays flat.
const expiredIsoFixture = { testOnlyLabsSnapshot: true };
const openingRoomContract = {
kind: 'room', wallCm: 30, surface: 'flat',
openings: [
@@ -165,6 +160,19 @@ const decorLayerProbes = {
};
export const GOLDEN_SCENARIOS = Object.freeze([
// #462 AC12: the card-level version recovery notice must stay compact,
// readable and outside layout/fit flow on desktop, narrow touch and the
// manual-only kiosk state after this tab already attempted the target.
{ id: 'version-mismatch-desktop-dark-en', fixture: 'visual', space: 'golden-geometry',
mode: 'view', integrationVersion: '0.0.0-golden-backend',
language: 'en', theme: 'dark', viewport: { width: 1000, height: 900 }, ...page },
{ id: 'version-mismatch-touch-light-ru', fixture: 'visual', space: 'golden-geometry',
mode: 'view', integrationVersion: '0.0.0-golden-backend', touchViewport: true,
language: 'ru', theme: 'light', viewport: { width: 390, height: 760 }, ...page },
{ id: 'version-mismatch-kiosk-attempted-dark-de', fixture: 'visual', space: 'golden-geometry',
mode: 'view', integrationVersion: '0.0.0-golden-backend',
versionRecoveryAttempted: true, kiosk: true,
language: 'de', theme: 'dark', viewport: { width: 820, height: 760 }, ...page },
{ id: 'hidden-wall-diagnostics-plan-light', fixture: 'visual',
space: 'golden-coincident-partition', coincidentPartition: 'before',
hiddenWallDiagnostics: true, mode: 'plan', theme: 'light',
@@ -200,23 +208,23 @@ export const GOLDEN_SCENARIOS = Object.freeze([
cornerSplitWall: 'zero-taper', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
// Stage 2 material/floor-edge plus door, window, gate and nested-room coverage.
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
alpha: true, projection: 'iso',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
alpha: true, projection: 'iso',
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-live-layers-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
alpha: true, projection: 'iso',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-no-borders-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, showBorders: false, fillMode: 'custom',
alpha: true, projection: 'iso', showBorders: false, fillMode: 'custom',
customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-touch-kiosk-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, kiosk: true,
alpha: true, projection: 'iso', kiosk: true,
theme: 'dark', viewport: { width: 390, height: 760 }, ...stage },
{ id: 'isometric-large-warm-remount-dark', fixture: 'large', space: 'perf-floor-2', mode: 'view',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, warmRemount: true,
alpha: true, projection: 'iso', warmRemount: true,
theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
{ id: 'geometry-view-dark-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
@@ -348,7 +356,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-wall-junctions-dark', fixture: 'visual',
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
alpha: true, projection: 'iso',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'opening-placement-door-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
// The shared centre edge is a long 25 cm physical wall. It can contain the
@@ -375,7 +383,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
mode: 'view', openingSymbolContract: openingFlipContract,
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-opening-symbol-parity-dark', fixture: 'visual', space: 'golden-opening-symbols',
mode: 'view', labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
mode: 'view', alpha: true, projection: 'iso',
openingSymbolContract: openingIsoContract,
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
+28 -1
View File
@@ -1,12 +1,39 @@
import {
captureEnvironment, foreignCaptureAllowance, foreignCaptureRefusal,
} from '../../scripts/capture-environment.mjs';
// The name must NOT end with `manifest.json`: the HACS submission check globs
// `*manifest.json` over the whole clone of the default branch and refuses a
// repository with more than one match (test/repo-hygiene.test.mjs).
export const GOLDEN_BASELINE_MANIFEST = 'baselines-index.json';
export const assertGoldenInvocation = (mode, scenarioFilter = '') => {
/**
* Проверка вызова — и заодно проверка среды съёмки (#455).
*
* Почему здесь, а не в `run.mjs`: `run.mjs` входит в корпус
* `sourceFingerprint`, и его правка объявляет устаревшими закоммиченный бандл,
* манифест скриншотов документации и индекс эталонов. Этот файл из корпуса
* исключён именно как «предикаты, вызываемые до и после съёмки», и уже
* вызывается первой строкой `run.mjs` — гейт встаёт без единой правки
* фингерпринтуемого файла.
*
* Платформа — ПАРАМЕТР, а не `process.platform` внутри: иначе юнит стал бы
* зелёным на Linux и красным на машине владельца, то есть тестом про хост, а
* не про правило.
*/
export const assertGoldenInvocation = (mode, scenarioFilter = '', options = {}) => {
if (!['capture', 'verify'].includes(mode)) throw new Error(`unknown golden mode: ${mode}`);
if (mode === 'verify' && scenarioFilter)
throw new Error('golden verify must run the complete matrix; use capture for a diagnostic --scenario run');
// Диагностический прогон verify в чужой среде законен: он ничего не принимает.
if (mode !== 'capture') return;
const platform = options.platform ?? captureEnvironment().platform;
const allowance = options.allowance ?? foreignCaptureAllowance();
const { refusal, allowance: accepted } = foreignCaptureRefusal({
platform, kind: 'golden', stage: 'capture', allowance,
});
if (refusal) throw new Error(refusal);
if (accepted) console.log(`Чужая среда съёмки разрешена осознанно: ${accepted}`);
};
/** A reviewed golden matrix is exact: neither an orphan PNG nor a stale hash
+47 -5
View File
@@ -4,11 +4,13 @@
fixture. The fixture has 60 rooms, 200 devices, 100 openings, 60 partitions,
40 columns and 500 decor objects on three floors.
Issue #89 adds `large-house-isometric-v1` through the same runner. Because the
product flag expires at 1.65.0, the runner injects an explicit test-only Labs
snapshot, selects iso per fixture space, measures the View toggle and records
the capped `isoGeometry` cache. A comparison SHA predating #89 remains flat
while reporting the same profile. The dedicated
Issue #89 adds `large-house-isometric-v1` through the same runner. The runner
enables the current candidate through the real `hp_alpha=1` URL/storage
contract, selects iso per fixture space, measures the View toggle and records
the capped `isoGeometry` cache. A compatibility-only snapshot keeps comparison
bundles from before #448 measurable without restoring a legacy user-facing
activation path; a comparison SHA predating #89 remains flat while reporting
the same profile. The dedicated
`budgets-large-house-isometric.json` applies the reviewed 20% relative allowance
plus absolute noise/ceiling checks. Only the exact-SHA Linux workflow is gate
evidence; a local report is diagnostic.
@@ -23,6 +25,24 @@ websocket traffic change. Its dedicated budget retains every original timing,
heap and cache ceiling and adds the measured pointer series plus a one-entry
snap-geometry cache cap. Exact-SHA Linux output is the only gate evidence.
Issue #451 adds `large-house-interaction-v1`. It dispatches 120 real
device/room/free-background hover moves, four 20-move pans, pinch/wheel camera input, three editor
drag series and 30 unrelated HA snapshots. It also injects relevant snapshots
inside and outside a gesture and counts heavy `_renderBody()` passes plus full
marker-binding diagnostic scans. Current bundles fail inside the runner unless
hover, editor moves and unrelated ticks perform zero full renders, a relevant
tick is deferred during pan, each terminal event reconciles at most one full
frame, the heavy wall DOM remains identical, and the projected HTML device
layer stays within one CSS pixel of its SVG scene position. Older comparison
bundles still produce timing baselines; structural assertions become mandatory
only when the target source contains the live viewport implementation. Its
dedicated budget preserves every existing large-house ceiling and adds the
individual interaction timing gates.
Structural assertions are evaluated independently of timing budgets: a fast
run still fails when an interaction performs an unexpected full render or
changes the heavy scene DOM.
The runner records seven measured samples after one discarded warm-up. With
this intentionally small CI sample, the nearest-rank `p95` is the observed
maximum; reports keep the conventional field name but should be read as a
@@ -119,11 +139,33 @@ it would make the same runner incompatible with its base bundle.
## Local diagnostics
### Wall draw terminal clicks
`wall-draw-click-v1` (#461) loads the production bundle with five spaces. The
edited space contains 12 rooms, 48 positive-thickness wall atoms and four saved
open drafts, then places seven consecutive Walls segments after warm-up. A
second variant doubles remote, non-interacting room geometry. The runner fails
structurally unless every intermediate click performs zero full-space physical
checks, exactly one local physical check, reuses its wall artifact in the
junction proof, adds one history entry and queues one full-config write. The
chain finish must independently return to the full-space barrier.
The canonical Linux ceilings are 150 ms median and 250 ms maximum for the seven
base clicks; the remote median may not exceed `base × 1.5 + 20 ms`. Counters are
the primary verdict, so a fast machine cannot hide a route back through the
generic barrier. Run against a freshly synchronized bundle:
```bash
npm run benchmark:wall-draw-click -- --output=artifacts/performance-smoke/wall-draw-click.json
node demo/smoke_wall_draw_click.mjs
```
Build and copy a fresh demo bundle first, then run:
```bash
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
npm run benchmark:large-house-plan-snap -- --samples=7 --warmups=1 --output=artifacts/performance/plan-snap-local.json
npm run benchmark:large-house-interaction -- --samples=7 --warmups=1 --output=artifacts/performance/interaction-local.json
```
A local report is diagnostic only; it cannot replace the CI comparison.
@@ -0,0 +1,139 @@
{
"schema": 1,
"profile": "large-house-interaction-v1",
"minimumSamples": 7,
"timings": {
"modelReadyMs": {
"stat": "median",
"maxRegressionRatio": 0.3,
"noiseAllowanceMs": 200,
"hardMaxMs": 2500
},
"firstStableRenderMs": {
"stat": "median",
"maxRegressionRatio": 0.3,
"noiseAllowanceMs": 250,
"hardMaxMs": 3000
},
"interactionSeriesMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 150,
"hardMaxMs": 3000
},
"hoverSeriesMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 60,
"hardMaxMs": 500
},
"panSeriesMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 60,
"hardMaxMs": 500
},
"cameraSeriesMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 60,
"hardMaxMs": 500
},
"editorSeriesMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 100,
"hardMaxMs": 750
},
"irrelevantHaTicksMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 40,
"hardMaxMs": 250
},
"relevantHaTickMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 40,
"hardMaxMs": 250
},
"spaceSwitchMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 75,
"hardMaxMs": 1500
},
"stateUpdateMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 75,
"hardMaxMs": 1000
},
"resizePreviewMs": {
"stat": "median",
"maxRegressionRatio": 0.4,
"noiseAllowanceMs": 150,
"hardMaxMs": 2000
},
"panZoomMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 60,
"hardMaxMs": 500
},
"settingsDialogMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 100,
"hardMaxMs": 1000
},
"switchCycleMs": {
"stat": "median",
"maxRegressionRatio": 0.35,
"noiseAllowanceMs": 250,
"hardMaxMs": 7000
}
},
"longTasks": {
"maxSingleMs": 3000,
"maxSingleRegressionRatio": 0.3,
"maxSingleNoiseAllowanceMs": 250,
"maxCountP95": 30,
"maxCountRegressionRatio": 0.35,
"countNoiseAllowance": 3,
"maxTotalP95Ms": 12000,
"maxTotalRegressionRatio": 0.3,
"noiseAllowanceMs": 150
},
"longTaskWindows": {
"hoverSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
"panSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
"cameraSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
"editorSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 }
},
"heap": {
"required": true,
"hardMaxGrowthBytes": 67108864,
"maxRegressionRatio": 0.75,
"noiseAllowanceBytes": 16777216
},
"cacheEntries": {
"cleanFloor": 100,
"glowClip": 200,
"wallUnion": 1,
"openingTunnel": 1,
"openingWallIndex": 1,
"planSnapGeometry": 1,
"wallFaceGraph": 4
},
"cacheGrowth": {
"cleanFloor": 0,
"glowClip": 0,
"wallUnion": 0,
"openingTunnel": 0,
"openingWallIndex": 0,
"planSnapGeometry": 0,
"wallFaceGraph": 0
},
"renderedDevices": 200
}
+27 -1
View File
@@ -17,22 +17,41 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
label: 'large-house-v1',
methods: Object.freeze([
'_baseVb',
'_bindingStatus',
'_cancelDecorGesture',
'_checkSpacePhysicalGeometry',
'_decorBoxOf',
'_dtMeasure',
'_dtMove',
'_dtStart',
'_openSettingsDialog',
'_pickSpace',
'_pos',
'_renderBody',
'_rszCancelDrag',
'_rszEdgeDown',
'_rszMove',
'_rszRooms',
'_setMode',
'_scenePoint',
'_viewOr',
]),
fields: Object.freeze([
'_booting',
'_bootSoft',
'_cameraTransition',
...CACHE_FIELDS,
'_continuity',
'_cursorPt',
'_decorList',
'_decorSel',
'_decorTool',
'_devices',
'_gridPitch',
'_hassSequence',
'_loadOk',
'_model',
'_modeTransitionBusy',
'_path',
'_serverCfg',
'_settingsDialog',
@@ -42,7 +61,7 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
// checks these members only when the target source tree supports Stage 1.
optionalFields: Object.freeze([
'_activeDraftId', '_draftSegmentCms', '_isoGeometryCache', '_offerWallFaces',
'_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
'_liveEditorPaintCount', '_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
'_wallFaceBatch', '_wallFaceGraphCache',
]),
// #380: v1.68.1 owns the same resize session directly on the card; newer
@@ -54,12 +73,19 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
]),
fieldTypes: Object.freeze({
_booting: 'boolean',
_bootSoft: 'boolean',
_cameraTransition: 'object',
_cleanFloorCache: 'map',
_devices: 'array',
_continuity: 'object',
_decorList: 'array',
_decorTool: 'string',
_draftSegmentCms: 'array',
_glowClipCache: 'map',
_gridPitch: 'number',
_hassSequence: 'number',
_loadOk: 'boolean',
_liveEditorPaintCount: 'number',
_model: 'array',
_onLabsSnapshot: 'function',
_offerWallFaces: 'function',
+14
View File
@@ -143,6 +143,20 @@ export const evaluatePerformanceBudget = ({ candidate, baseline, budgets, absolu
},
));
for (const [windowName, budget] of Object.entries(budgets.longTaskWindows ?? {})) {
const windows = candidate.rows.map((row) => row.longTasks?.[windowName]);
const available = windows.every((window) => window?.supported === true);
checks.push({
id: `longTask.${windowName}.available`, actual: available ? 1 : 0, limit: 1, pass: available,
});
const maxSingle = available ? Math.max(...windows.map((window) => window.maxMs)) : NaN;
const countP95 = available ? percentile(windows.map((window) => window.count), 0.95) : NaN;
const totalP95 = available ? percentile(windows.map((window) => window.totalMs), 0.95) : NaN;
checks.push(makeCheck(`longTask.${windowName}.maxSingleMs`, maxSingle, budget.maxSingleMs));
checks.push(makeCheck(`longTask.${windowName}.countP95`, countP95, budget.maxCountP95));
checks.push(makeCheck(`longTask.${windowName}.totalP95Ms`, totalP95, budget.maxTotalP95Ms));
}
const candidateHeap = candidate.rows
.map((row) => row.heapGrowthBytes)
.filter(finite)
+11 -1
View File
@@ -250,15 +250,18 @@ const res = await page.evaluate(async () => {
await wait(100);
const coldCalls = [];
let coldRev = 40;
let releaseColdConfig;
const coldConfigGate = new Promise((resolve) => { releaseColdConfig = resolve; });
const coldHass = window.__mkHass();
const coldBaseCallWS = coldHass.callWS;
coldHass.callWS = async (message) => {
coldCalls.push(structuredClone(message));
if (message.type === 'houseplan/config/get') {
await coldConfigGate;
return { config: coldConfig, rev: coldRev, can_write: true };
}
if (message.type === 'houseplan/layout/get') {
return { layout: coldLayout, rev: coldRev };
return { layout: structuredClone(coldLayout), rev: coldRev };
}
if (message.type === 'houseplan/layout/delete') {
delete coldLayout[message.device_id];
@@ -274,6 +277,12 @@ const res = await page.evaluate(async () => {
coldCard.setConfig({ type: 'custom:houseplan-card', title: 'Cold Area backfill' });
document.body.append(coldCard);
coldCard.hass = coldHass;
await wait(100);
const coldCacheStayedReadOnly = !coldCalls.some((message) =>
message.type === 'houseplan/config/set'
|| ['houseplan/layout/set', 'houseplan/layout/update', 'houseplan/layout/delete']
.includes(message.type));
releaseColdConfig();
for (let attempt = 0; attempt < 20 && !coldCard._loadedOnce; attempt += 1) await wait(100);
await wait(450);
await paint(coldCard);
@@ -525,6 +534,7 @@ const res = await page.evaluate(async () => {
&& compositeDeletesAfter === compositeDeletesBefore
&& c._newIds.has(compositeId) === compositeAttentionBefore
&& !c._areaRelocationIds.has(compositeId),
coldCacheStayedReadOnly,
coldStartBackfilled,
staticReadOnlyProjection: staticCard._areaRelocationIds.has('d_leak')
&& staticLeft > 50 && !!staticEl?.querySelector('.newdot'),
+1
View File
@@ -325,6 +325,7 @@ const drawn = await page.evaluate(async () => {
},
options: { minDim: 1, eps: 0.01 }, rooms: resizeRooms, openings: [],
snapshotIdentity: snapshot, before: JSON.parse(snapshot), wallUnionBefore: null,
epochBefore: c._cfgEpoch,
});
c._resize.move({
pointerId: 912, point: [687.5, 250], step: 0.5, snap: (point) => point,
+57 -24
View File
@@ -65,15 +65,20 @@ const warmGate = await page.evaluate(async () => {
new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)),
]);
await settle();
const warmDecision = card._confirmDanger({
key: 'warm-language-gate',
kind: 'destructive',
title: 'Delete?',
message: 'The plan and all its rooms will be deleted.',
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
});
// Refusal is a synchronous registration barrier, not eventual cleanup by
// the next warm render. A briefly pending request has no decision surface.
const warmRequestWasNeverRegistered = card._dangerConfirm === null
&& card._dangerConfirmController.state === null;
const decision = await Promise.race([
card._confirmDanger({
key: 'warm-language-gate',
kind: 'destructive',
title: 'Delete?',
message: 'The plan and all its rooms will be deleted.',
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
}),
warmDecision,
new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)),
]);
const result = {
@@ -81,6 +86,7 @@ const warmGate = await page.evaluate(async () => {
readyConfirmationOpenedBeforeWarm: openedWhileReady,
readyToWarmCancelsOpenConfirmation: cancelledOnTransition === false,
readyToWarmKeepsCommittedBody: root().querySelector('ha-card')?.outerHTML === committedBody,
warmLanguageGateNeverRegisters: warmRequestWasNeverRegistered,
warmLanguageGateRefusesImmediately: decision === false,
warmLanguageGateKeepsControllerEmpty: card._dangerConfirm === null
&& card._dangerConfirmController.state === null,
@@ -194,6 +200,28 @@ const out = await page.evaluate(async () => {
}));
await settle();
};
const boundedDangerDecision = async (decision, timeoutMs = 250) => {
let timeoutId;
const outcome = await Promise.race([
decision.then((value) => ({ settled: true, value })),
new Promise((resolve) => {
timeoutId = setTimeout(() => resolve({ settled: false, value: 'timeout' }), timeoutMs);
}),
]);
clearTimeout(timeoutId);
if (!outcome.settled) {
// A missing or dead decision surface must fail the smoke quickly instead
// of consuming the whole mutation shard. Cancelling also keeps later
// branches independent from the timed-out controller state.
card._cancelDangerConfirm();
await Promise.race([
decision,
new Promise((resolve) => setTimeout(resolve, 100)),
]);
await settle();
}
return outcome;
};
await settle();
@@ -213,7 +241,9 @@ const out = await page.evaluate(async () => {
await settle();
result.mainBranchRendersExactlyOneConfirm = dialogs() === 1;
card._cancelDangerConfirm();
result.mainBranchCancelResolvesFalse = (await single) === false;
const singleDecision = await boundedDangerDecision(single);
result.mainBranchCancelResolvesFalse = singleDecision.settled
&& singleDecision.value === false;
const savedCfg = card._serverCfg;
const savedModel = card._model;
@@ -240,7 +270,9 @@ const out = await page.evaluate(async () => {
await settle();
result.onboardingBranchShowsConfirm = dialogs() === 1;
await decide(false);
result.onboardingBranchResolves = (await onboarding) === false;
const onboardingDecision = await boundedDangerDecision(onboarding);
result.onboardingBranchResolves = onboardingDecision.settled
&& onboardingDecision.value === false;
await leaveBranch();
// Открытое подтверждение переживает смену ветки: раньше оно исчезало вместе
@@ -251,8 +283,9 @@ const out = await page.evaluate(async () => {
await enterBranch([]);
const stillOpenInOnboarding = dialogs() === 1;
await decide(true);
const survivorDecision = await boundedDangerDecision(survivor);
result.openConfirmSurvivesBranchChange = openedInMainBranch && stillOpenInOnboarding
&& (await survivor) === true;
&& survivorDecision.settled && survivorDecision.value === true;
await leaveBranch();
// The exact missed branch from #417: a non-empty model whose active space
@@ -265,22 +298,17 @@ const out = await page.evaluate(async () => {
card._spaceModel = () => undefined;
card.requestUpdate();
await settle();
const spaceLossDecision = await Promise.race([
pendingAtSpaceLoss,
new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)),
]);
const spaceLossDecision = await boundedDangerDecision(pendingAtSpaceLoss);
result.lostSpaceBranchIsActuallyEntered = root().childElementCount === 0;
result.openConfirmCancelsWhenSpaceIsLost = spaceLossDecision === false
result.openConfirmCancelsWhenSpaceIsLost = spaceLossDecision.settled
&& spaceLossDecision.value === false
&& card._dangerConfirm === null && card._dangerConfirmController.state === null;
const refusedWithoutSpacePromise = card._confirmDanger(request('space-already-lost'));
const refusedWithoutSpaceWasNeverRegistered = card._dangerConfirm === null
&& card._dangerConfirmController.state === null;
const refusedWithoutSpace = await Promise.race([
refusedWithoutSpacePromise,
new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)),
]);
const refusedWithoutSpace = await boundedDangerDecision(refusedWithoutSpacePromise);
result.lostSpaceRequestRefusesImmediately = refusedWithoutSpaceWasNeverRegistered
&& refusedWithoutSpace === false
&& refusedWithoutSpace.settled && refusedWithoutSpace.value === false
&& card._dangerConfirm === null && card._dangerConfirmController.state === null
&& dialogs() === 0;
card._spaceModel = originalSpaceModel;
@@ -302,7 +330,8 @@ const out = await page.evaluate(async () => {
await settle();
const shown = dialogs() === 1;
await decide(false);
return branchEntered && shown && (await pending) === false;
const decision = await boundedDangerDecision(pending);
return branchEntered && shown && decision.settled && decision.value === false;
};
result.fixedFloorPendingStillShowsConfirm = await confirmInFixedBranch(false, 'pending');
result.fixedFloorInvalidStillShowsConfirm = await confirmInFixedBranch(true, 'invalid');
@@ -334,7 +363,9 @@ const out = await page.evaluate(async () => {
}
}
await settle();
result.touchTapOnCancelResolvesFalse = !!cancelButton && (await byTap) === false
const tapDecision = await boundedDangerDecision(byTap);
result.touchTapOnCancelResolvesFalse = !!cancelButton
&& tapDecision.settled && tapDecision.value === false
&& dialogs() === 0;
await leaveBranch();
@@ -350,7 +381,9 @@ const out = await page.evaluate(async () => {
card.hass = savedHass;
card.requestUpdate();
await settle();
result.notReadyCardRefusesInsteadOfHanging = (await refused) === false;
const refusedDecision = await boundedDangerDecision(refused);
result.notReadyCardRefusesInsteadOfHanging = refusedDecision.settled
&& refusedDecision.value === false;
// Match the public surface of HA's pinned ha-dialog closely enough to prove
// branch selection and ARIA forwarding without depending on private shadow
+126 -4
View File
@@ -10,6 +10,7 @@ const res = await page.evaluate(async () => {
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const settleLive = () => c._editorRuntime?._whenLiveEditorSettled() ?? c.updateComplete;
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
// 1) третья вкладка есть
const tabs = [...sr().querySelectorAll('.modetab')];
@@ -272,10 +273,10 @@ const res = await page.evaluate(async () => {
const vw = () => c._viewOr(c._baseVb());
c._decorTool = 'line';
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 4, g * 20], pid: 3 };
await c.updateComplete;
await settleLive();
out.noBadgeBeforeTheDrag = !label(); // нулевая длина — молчим
c._decorDraft = { ...c._decorDraft, b: [g * 16, g * 20] }; // 12 клеток вправо
await c.updateComplete;
await settleLive();
out.lineBadgeShown = !!label();
// 12 клеток × 5 см = 0.60 m, угол 0° — ровно то, что пишет плашка стены
out.lineBadgeText = txt();
@@ -287,12 +288,12 @@ const res = await page.evaluate(async () => {
out.badgeGreenOnAxis = !!label() && label().classList.contains('on45');
// косой отрезок 3-4-5: длина 5 клеток, угол 53.1° — не кратен 45°
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 7, g * 24], pid: 3 };
await c.updateComplete;
await settleLive();
out.obliqueBadge = txt() === fmt(g * 5) + ' · 53.1°';
out.obliqueNotGreen = !!label() && !label().classList.contains('on45');
// прямоугольник показывает габарит и чистую площадь
c._decorDraft = { kind: 'rect', a: [g * 4, g * 20], b: [g * 12, g * 26], pid: 3 };
await c.updateComplete;
await settleLive();
out.rectBadgeHasSizeAndArea = txt().startsWith(fmt(g * 8) + ' × ' + fmt(g * 6) + ' · ')
&& /(?:m²|ft²)$/.test(txt());
// отпустили — плашки нет
@@ -493,6 +494,127 @@ const res = await page.evaluate(async () => {
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
c._decorTool = 'select'; await c.updateComplete;
out.decorRestored = c._decorList.length === decorBefore.length;
// #447: Arrow is a separate rigid transaction, not a synthetic pointer
// move. It preserves off-grid phase, bypasses both magnets and keeps one
// history/save boundary per browser keydown.
const keyboardOriginal = JSON.parse(JSON.stringify(c._decorList));
const keyboardShapes = [
{ id: 'key-rect', kind: 'rect', x: 0.1013, y: 0.2017, w: 0.08, h: 0.05,
angle: 17, color: '#123456', fill: true },
{ id: 'key-furniture', kind: 'furniture', x: 0.3013, y: 0.4017, w: 0.08, h: 0.05,
symbol: 'chair', angle: 90, flip_h: true },
{ id: 'key-image', kind: 'image', x: 0.5013, y: 0.6017, w: 0.08, h: 0.05,
asset_id: 'smoke-missing-asset', opacity: 0.6 },
];
c._curSpaceCfg.decor = keyboardShapes;
c._decorTool = 'select'; c._decorSel = 'key-furniture';
c._decorDraft = null; c._decorMove = null; c._dtDrag = null; c._bdDrag = null;
c.requestUpdate(); await c.updateComplete;
c._geometryHistory.clear();
c._saveConfigDebounced.cancel();
const realDebouncedSave = c._saveConfigDebounced;
let keyboardSaves = 0, decorSnapCalls = 0, furnitureMoveCalls = 0;
const countedSave = () => { keyboardSaves += 1; };
countedSave.pending = () => false; countedSave.cancel = () => {}; countedSave.flush = () => {};
c._saveConfigDebounced = countedSave;
const realDecorSnap = c._decorSnap;
const realFurnitureMove = c._editorRuntime._furnMoveUpdate;
c._decorSnap = (...args) => { decorSnapCalls += 1; return realDecorSnap.apply(c, args); };
c._editorRuntime._furnMoveUpdate = (...args) => {
furnitureMoveCalls += 1;
return realFurnitureMove.apply(c._editorRuntime, args);
};
const arrow = (key, target = window, init = {}) => {
const event = new KeyboardEvent('keydown', {
key, bubbles: true, composed: true, cancelable: true, ...init,
});
return target.dispatchEvent(event) === false;
};
const keyed = (id) => c._decorList.find((shape) => shape.id === id);
const keyStep = g / 1000;
const furnitureBeforeKey = JSON.parse(JSON.stringify(keyed('key-furniture')));
const epochBeforeKey = c._cfgEpoch;
const firstArrowPrevented = arrow('ArrowRight');
const furnitureAfterRight = JSON.parse(JSON.stringify(keyed('key-furniture')));
out.arrowMovesFurnitureOneCellWithoutMagnet = firstArrowPrevented
&& Math.abs(furnitureAfterRight.x - furnitureBeforeKey.x - keyStep) < 1e-12
&& furnitureAfterRight.y === furnitureBeforeKey.y
&& furnitureAfterRight.angle === furnitureBeforeKey.angle
&& furnitureAfterRight.flip_h === furnitureBeforeKey.flip_h
&& decorSnapCalls === 0 && furnitureMoveCalls === 0;
out.arrowIsOneHistoryAndSaveTransaction = c._geometryHistory.size === 1
&& c._geometryHistory.undoName === c._t('history.decor_move')
&& keyboardSaves === 1 && c._cfgEpoch === epochBeforeKey + 1
&& c._decorSel === 'key-furniture';
const shiftArrowPrevented = arrow('ArrowDown', window, { shiftKey: true });
const furnitureAfterDown = JSON.parse(JSON.stringify(keyed('key-furniture')));
out.shiftArrowKeepsTheSameOneCellStep = shiftArrowPrevented
&& Math.abs(furnitureAfterDown.y - furnitureAfterRight.y - keyStep) < 1e-12
&& c._geometryHistory.size === 2 && keyboardSaves === 2;
arrow('z', window, { ctrlKey: true });
const furnitureAfterUndo = JSON.parse(JSON.stringify(keyed('key-furniture')));
arrow('y', window, { ctrlKey: true });
const furnitureAfterRedo = JSON.parse(JSON.stringify(keyed('key-furniture')));
out.arrowUndoRedoIsExactlyOneStep = Math.abs(furnitureAfterUndo.y - furnitureAfterRight.y) < 1e-8
&& Math.abs(furnitureAfterRedo.y - furnitureAfterDown.y) < 1e-8
&& Math.abs(furnitureAfterRedo.x - furnitureAfterRight.x) < 1e-8
&& c._decorSel === 'key-furniture';
c._decorSel = 'key-image';
const imageX = keyed('key-image').x;
arrow('ArrowLeft');
c._decorSel = 'key-rect';
const rectY = keyed('key-rect').y;
arrow('ArrowUp');
out.imageAndOrdinaryDecorUseTheSameArrowPath = Math.abs(keyed('key-image').x - imageX + keyStep) < 1e-12
&& Math.abs(keyed('key-rect').y - rectY + keyStep) < 1e-12
&& decorSnapCalls === 0 && furnitureMoveCalls === 0;
const guardX = () => keyed('key-rect').x;
const guardStart = guardX();
c.requestUpdate(); await c.updateComplete;
const controls = ['input', 'textarea', 'select'].map((tag) => document.createElement(tag));
const editable = document.createElement('div'); editable.contentEditable = 'true';
controls.push(editable);
const fieldResults = [];
for (const control of controls) {
document.body.append(control); control.focus();
fieldResults.push(!arrow('ArrowRight', control) && guardX() === guardStart);
control.remove();
}
out.arrowLeavesFormControlsAlone = fieldResults.every(Boolean);
const toolbarControl = sr().querySelector('.editor-secondary button');
toolbarControl?.focus();
const toolbarGuarded = !!toolbarControl && !arrow('ArrowRight', toolbarControl) && guardX() === guardStart;
toolbarControl?.blur();
c._decorEraseConfirm = { id: 'key-rect', kind: 'rect' };
const dialogGuarded = !arrow('ArrowRight') && guardX() === guardStart;
c._decorEraseConfirm = null;
c._decorMove = { id: 'key-rect', start: [0, 0], orig: JSON.parse(JSON.stringify(keyed('key-rect'))),
pid: 447, moved: false, before: c._geometrySnapshot() };
const gestureGuarded = !arrow('ArrowRight') && guardX() === guardStart;
c._decorMove = null;
out.arrowRespectsToolbarDialogAndGestureGuards = toolbarGuarded && dialogGuarded && gestureGuarded;
const modifierResults = [
{ ctrlKey: true }, { metaKey: true }, { altKey: true },
].map((init) => !arrow('ArrowRight', window, init) && guardX() === guardStart);
out.modifiedArrowsRemainBrowserCommands = modifierResults.every(Boolean);
c._curSpaceCfg.decor = [{ id: 'key-limit', kind: 'rect', x: 4999.9, y: 0, w: 0.1, h: 0.1 }];
c._decorSel = 'key-limit'; c._geometryHistory.clear();
const savesBeforeNoop = keyboardSaves, epochBeforeNoop = c._cfgEpoch;
const noopPrevented = arrow('ArrowRight');
out.boundaryNoopIsConsumedButNotSaved = noopPrevented
&& c._curSpaceCfg.decor[0].x === 4999.9
&& c._geometryHistory.size === 0 && keyboardSaves === savesBeforeNoop
&& c._cfgEpoch === epochBeforeNoop;
c._decorSnap = realDecorSnap;
c._editorRuntime._furnMoveUpdate = realFurnitureMove;
c._saveConfigDebounced = realDebouncedSave;
c._curSpaceCfg.decor = keyboardOriginal;
c._decorSel = null; c._geometryHistory.clear(); c._cfgEpoch++;
c.requestUpdate(); await c.updateComplete;
// Switching modes during a live move cancels the transaction and restores
// its start instead of leaving mutated geometry with a forgotten pointer.
const liveShape = c._decorList[0];
+5 -3
View File
@@ -27,9 +27,11 @@ const res = await page.evaluate(async () => {
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const settleLive = () => c._editorRuntime?._whenLiveEditorSettled() ?? c.updateComplete;
// null-safe: на сборке ДО этих правок у надписи нет ни data-id, ни tspan —
// смок должен показать список провалов, а не упасть с исключением
const el = (id) => sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
const el = (id) => sr().querySelector(`[data-hp-live-editor] .decorlayer text.dtext[data-id="${id}"]`)
|| sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
const attr = (id, name) => { const e = el(id); return e ? e.getAttribute(name) : null; };
const tspans = (id) => { const e = el(id); return e ? [...e.querySelectorAll('tspan')] : []; };
const stageEl = () => sr().querySelector('.stage');
@@ -101,7 +103,7 @@ const res = await page.evaluate(async () => {
out.noFrameWithoutSelection = !sr().querySelector('.dtframe');
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const frame = () => sr().querySelector('.dtframe');
const frame = () => sr().querySelector('[data-hp-live-editor] .dtframe') || sr().querySelector('.dtframe');
out.frameOnSelection = !!frame();
out.fourCornersAndRotate = !!frame()
&& frame().querySelectorAll('.dthandle').length === 5
@@ -147,7 +149,7 @@ const res = await page.evaluate(async () => {
ev('pointerdown', corner, ax + 40, ay2);
out.dragStarted = !!c._dtDrag && c._dtDrag.kind === 'scale';
ev('pointermove', stageEl(), ax + 80, ay2);
await c.updateComplete;
await settleLive();
const scaled = c._decorList.find((x) => x.id === 'dtm');
out.cornerStoresPhysicalSize = scaled.scale === undefined && scaled.size === undefined
&& Math.abs((scaled.size_cm || 0) - mediumSizeCm0 * 2) < 0.01;
+3 -3
View File
@@ -205,7 +205,7 @@ for (const metric of desktop.metrics) {
}
checks.space_dialog_still_uses_medium_shell = desktop.space.widePreset
&& desktop.space.surfaceWidth >= 499 && desktop.space.surfaceWidth <= 501;
checks.space_dialog_footer_not_regressed = desktop.space.buttons === 3
checks.space_dialog_footer_not_regressed = desktop.space.buttons === 4
&& desktop.space.oneRow && desktop.space.noHorizontalOverflow;
for (const kind of ['opening', 'physical', 'opening_de', 'physical_de']) {
@@ -217,10 +217,10 @@ for (const kind of ['opening', 'physical', 'opening_de', 'physical_de']) {
checks.draft_four_actions_remain_contained = narrow.draft.buttons === 4
&& narrow.draft.insideViewport && narrow.draft.noHorizontalOverflow
&& narrow.draft.buttonsContained;
checks.space_narrow_not_regressed = narrow.space.buttons === 3
checks.space_narrow_not_regressed = narrow.space.buttons === 4
&& narrow.space.insideViewport && narrow.space.noHorizontalOverflow
&& narrow.space.buttonsContained;
checks.space_de_narrow_not_regressed = narrow.space_de.buttons === 3
checks.space_de_narrow_not_regressed = narrow.space_de.buttons === 4
&& narrow.space_de.insideViewport && narrow.space_de.noHorizontalOverflow
&& narrow.space_de.buttonsContained;
+318
View File
@@ -0,0 +1,318 @@
// #463: native hp-dialog is a real, centred top-layer modal throughout its
// lifecycle. This is deliberately a production-bundle smoke: Lit updates,
// HTMLDialogElement top-layer state, ::backdrop and late custom-element
// registration cannot be proved by a pure DOM shim.
import { launch, checkAll, finish } from './serve.mjs';
const DESKTOP = { width: 800, height: 600 };
const NARROW = { width: 320, height: 720 };
const { page, browser } = await launch(DESKTOP);
const desktop = await page.evaluate(async () => {
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const settle = async (element) => {
await element.updateComplete;
await element.querySelector('hp-dialog')?.updateComplete;
await frame();
await frame();
};
const deepActive = () => {
let active = document.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active;
};
const backdropAlpha = (dialog) => {
const color = getComputedStyle(dialog, '::backdrop').backgroundColor;
if (!color || color === 'transparent') return 0;
const values = color.match(/[\d.]+/g)?.map(Number) || [];
return values.length >= 4 ? values.at(-1) : 1;
};
const geometry = (host) => {
const native = host.shadowRoot?.querySelector('dialog');
const surface = host.shadowRoot?.querySelector('.surface');
const shellCount = host.shadowRoot?.querySelectorAll('dialog, ha-dialog').length || 0;
const dialogRect = native?.getBoundingClientRect();
const surfaceRect = surface?.getBoundingClientRect();
const centred = !!surfaceRect
&& Math.abs((surfaceRect.left + surfaceRect.right) / 2 - innerWidth / 2) <= 1
&& Math.abs((surfaceRect.top + surfaceRect.bottom) / 2 - innerHeight / 2) <= 1;
return {
native,
oneShell: shellCount === 1,
openModal: native?.open === true && native.matches(':modal'),
centred,
shrinkWrapped: !!dialogRect && !!surfaceRect
&& Math.abs(dialogRect.width - surfaceRect.width) <= 1
&& Math.abs(dialogRect.height - surfaceRect.height) <= 1,
backdropVisible: !!native && backdropAlpha(native) > 0,
noOverflow: !!surface
&& surface.scrollWidth <= surface.clientWidth + 1
&& document.documentElement.scrollWidth <= innerWidth + 1,
};
};
// Exact user surface: hp-confirm forces the native alert branch even when
// Home Assistant provides ha-dialog.
const confirm = document.createElement('hp-confirm');
confirm.token = 463001;
confirm.request = {
key: 'dialog-modal-contract',
kind: 'destructive',
title: 'Delete unfinished contour?',
message: 'The unfinished contour will be removed.',
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
};
const decision = new Promise((resolve) => confirm.addEventListener(
'hp-confirm-decision', (event) => resolve(event.detail.accepted), { once: true },
));
document.body.append(confirm);
await settle(confirm);
const confirmShell = confirm.querySelector('hp-dialog');
const confirmGeometry = geometry(confirmShell);
const confirmCancel = confirm.querySelector('button[autofocus]');
const confirmFocused = deepActive() === confirmCancel;
confirmCancel?.click();
const confirmCancelled = await decision === false;
confirm.remove();
await frame();
// An ordinary dialog starts on the native fallback because ha-dialog is
// intentionally registered only in the second half of this smoke.
const opener = document.createElement('button');
opener.id = 'hp-463-opener';
opener.textContent = 'Open dialog';
document.body.append(opener);
opener.focus();
const lifecycle = document.createElement('hp-dialog');
lifecycle.id = 'hp-463-lifecycle';
lifecycle.title = 'Lifecycle recovery';
lifecycle.innerHTML = '<button autofocus>First action</button>';
let lifecycleCloseCount = 0;
lifecycle.addEventListener('hp-close', () => {
lifecycleCloseCount += 1;
lifecycle.remove();
});
document.body.append(lifecycle);
await settle(lifecycle);
const initialGeometry = geometry(lifecycle);
const native = initialGeometry.native;
// Reproduce the independently confirmed open-but-nonmodal state. update()
// must recover the same physical shell without reporting a user close.
let showModalCalls = 0;
const realShowModal = native.showModal.bind(native);
native.showModal = () => {
showModalCalls += 1;
return realShowModal();
};
native.close();
native.show();
const becameOpenNonmodal = native.open && !native.matches(':modal');
lifecycle.requestUpdate();
await settle(lifecycle);
const afterUpdate = geometry(lifecycle);
const updateRecoveredSameShell = afterUpdate.native === native
&& afterUpdate.openModal && afterUpdate.centred && afterUpdate.oneShell;
const updateRecoveryWasBounded = showModalCalls === 1;
lifecycle.requestUpdate();
await settle(lifecycle);
const stableUpdateDoesNotRetry = showModalCalls === 1;
// Removing a modal from the document drops it from the top layer while the
// open attribute can survive. Reconnecting the same host must repair that
// exact native node and must not synthesize hp-close.
lifecycle.remove();
const detachedLostTopLayer = !native.matches(':modal');
document.body.append(lifecycle);
await settle(lifecycle);
const afterReconnect = geometry(lifecycle);
const reconnectRecoveredSameShell = afterReconnect.native === native
&& afterReconnect.openModal && afterReconnect.centred && afterReconnect.oneShell;
const reconnectRestoresInitialFocus = deepActive() === lifecycle.querySelector('[autofocus]');
const recoveryDidNotClose = lifecycleCloseCount === 0;
// A shrink-wrapped native shell still owns the entire viewport scrim hit
// target through the top layer. A content click stays inside; a click on the
// outer dialog follows dismissOnScrim and preserves the close/focus contract.
lifecycle.dismissOnScrim = true;
lifecycle.requestUpdate();
await settle(lifecycle);
lifecycle.shadowRoot.querySelector('.surface')?.click();
const surfaceClickDoesNotDismiss = lifecycleCloseCount === 0;
native.click();
await frame();
await frame();
const closeContractSurvives = lifecycleCloseCount === 1
&& !lifecycle.isConnected && deepActive() === opener;
// Top-layer ordering must remain intact when a native parent opens an alert
// confirmation. Escape closes only the top alert, then the parent.
opener.focus();
const parent = document.createElement('hp-dialog');
parent.title = 'Native parent';
parent.innerHTML = '<button autofocus>Parent action</button>';
let parentCloseCount = 0;
parent.addEventListener('hp-close', () => {
parentCloseCount += 1;
parent.remove();
});
document.body.append(parent);
await settle(parent);
const child = document.createElement('hp-confirm');
child.token = 463002;
child.request = {
key: 'nested-dialog-order', kind: 'warning', title: 'Nested alert',
message: 'Close only this alert first.', confirmLabel: 'Continue', cancelLabel: 'Cancel',
};
const childDecision = new Promise((resolve) => child.addEventListener(
'hp-confirm-decision', (event) => resolve(event.detail.accepted), { once: true },
));
document.body.append(child);
await settle(child);
const childNative = child.querySelector('hp-dialog')?.shadowRoot?.querySelector('dialog');
const nestedStartIsTwoModals = parent.shadowRoot.querySelector('dialog')?.matches(':modal')
&& childNative?.matches(':modal');
deepActive()?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
const childCancelled = await childDecision === false;
child.remove();
await frame();
await frame();
const firstEscapeClosedOnlyAlert = childCancelled && parentCloseCount === 0
&& parent.shadowRoot.querySelector('dialog')?.matches(':modal')
&& deepActive() === parent.querySelector('[autofocus]');
deepActive()?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
await frame();
await frame();
const secondEscapeClosedParent = parentCloseCount === 1 && !parent.isConnected;
// Keep a second pre-registration ordinary dialog open. It must not switch
// branch when ha-dialog is defined later.
opener.focus();
const early = document.createElement('hp-dialog');
early.id = 'hp-463-early-native';
early.title = 'Opened before HA';
early.innerHTML = '<button autofocus>Keep open</button>';
document.body.append(early);
await settle(early);
const earlyNative = early.shadowRoot.querySelector('dialog');
earlyNative.__hp463Identity = true;
return {
confirmHasOneNativeShell: confirmGeometry.oneShell,
confirmIsTopLayerModal: confirmGeometry.openModal,
confirmSurfaceIsCentred: confirmGeometry.centred && confirmGeometry.shrinkWrapped,
confirmBackdropIsVisible: confirmGeometry.backdropVisible,
confirmFocusAndCancelWork: confirmFocused && confirmCancelled,
standardFallbackStartsCentred: initialGeometry.oneShell && initialGeometry.openModal
&& initialGeometry.centred && initialGeometry.shrinkWrapped && initialGeometry.noOverflow,
artificialOpenNonmodalWasCreated: becameOpenNonmodal,
updateRecoversSameNativeShell: updateRecoveredSameShell,
updateRecoveryRunsOnce: updateRecoveryWasBounded && stableUpdateDoesNotRetry,
detachActuallyDropsTopLayer: detachedLostTopLayer,
reconnectRecoversSameNativeShell: reconnectRecoveredSameShell,
reconnectRestoresInitialFocus,
recoveryNeverEmitsClose: recoveryDidNotClose,
shrinkWrappedScrimKeepsDismissContract: surfaceClickDoesNotDismiss
&& closeContractSurvives,
nestedNativeEscapeKeepsTopLayerOrder: nestedStartIsTwoModals
&& firstEscapeClosedOnlyAlert && secondEscapeClosedParent,
earlyFallbackReadyForLateRegistration: !!earlyNative
&& earlyNative.matches(':modal') && geometry(early).centred,
};
});
await page.setViewportSize(NARROW);
const narrowAndLateRegistration = await page.evaluate(async () => {
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const settle = async (element) => {
await element.updateComplete;
await element.querySelector('hp-dialog')?.updateComplete;
await frame();
await frame();
};
const centred = (host) => {
const surface = host.shadowRoot?.querySelector('.surface');
const rect = surface?.getBoundingClientRect();
return !!rect
&& Math.abs((rect.left + rect.right) / 2 - innerWidth / 2) <= 1
&& Math.abs((rect.top + rect.bottom) / 2 - innerHeight / 2) <= 1;
};
const early = document.querySelector('#hp-463-early-native');
const originalNative = early.shadowRoot.querySelector('dialog');
early.wide = true;
early.requestUpdate();
await settle(early);
const surface = early.shadowRoot.querySelector('.surface');
const surfaceRect = surface.getBoundingClientRect();
const wideFitsNarrowViewport = early.wide && originalNative.matches(':modal')
&& centred(early)
&& surfaceRect.width <= innerWidth * 0.94 + 1
&& surfaceRect.height <= innerHeight * 0.92 + 1
&& surfaceRect.left >= -1 && surfaceRect.right <= innerWidth + 1
&& surface.scrollWidth <= surface.clientWidth + 1
&& originalNative.scrollWidth <= originalNative.clientWidth + 1;
class HaDialogStub extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = '<slot name="headerTitle"></slot>'
+ '<slot></slot><slot name="footer"></slot>';
}
connectedCallback() {
queueMicrotask(() => this.dispatchEvent(new Event('opened')));
}
}
customElements.define('ha-dialog', HaDialogStub);
await frame();
await frame();
const earlyStillNative = early.shadowRoot.querySelector('dialog') === originalNative
&& originalNative.__hp463Identity === true
&& originalNative.matches(':modal')
&& !early.shadowRoot.querySelector('ha-dialog')
&& centred(early);
const lateOrdinary = document.createElement('hp-dialog');
lateOrdinary.title = 'Opened after HA';
lateOrdinary.innerHTML = '<button autofocus>HA action</button>';
document.body.append(lateOrdinary);
await settle(lateOrdinary);
const newOrdinaryUsesOneHaShell = lateOrdinary.shadowRoot.querySelectorAll('ha-dialog').length === 1
&& !lateOrdinary.shadowRoot.querySelector('dialog');
const lateAlert = document.createElement('hp-dialog');
lateAlert.alert = true;
lateAlert.title = 'Alert after HA';
lateAlert.innerHTML = '<button autofocus>Native action</button>';
document.body.append(lateAlert);
await settle(lateAlert);
const alertNative = lateAlert.shadowRoot.querySelector('dialog');
const alertAfterHaStaysOneNativeModal = lateAlert.shadowRoot
.querySelectorAll('dialog, ha-dialog').length === 1
&& !!alertNative?.matches(':modal') && centred(lateAlert);
lateAlert.remove();
lateOrdinary.remove();
early.remove();
await frame();
await frame();
return {
wideNativeFallbackFitsAndCentresOnNarrowViewport: wideFitsNarrowViewport,
openFallbackDoesNotSwitchAfterLateHaRegistration: earlyStillNative,
newOrdinaryUsesExactlyOneHaShell: newOrdinaryUsesOneHaShell,
alertAfterHaStillUsesExactlyOneNativeModal: alertAfterHaStaysOneNativeModal,
};
});
const out = { ...desktop, ...narrowAndLateRegistration };
checkAll(out);
await finish(browser, out);
+1
View File
@@ -131,6 +131,7 @@ const out = await page.evaluate(async () => {
beginDeviceDrag(dev3, r0, 13);
c._pointerMove(mk(2.5 * NORM_W + halfStep - r0.x, 2.5 * NORM_W - r0.y,
400, 400, { shiftKey: true, pointerId: 13 }), dev3);
await Promise.resolve();
o.shiftStillSnapsToTheNodes = onGrid(c._layout[dev3.id].x);
c._cancelDeviceDrag();
+81 -27
View File
@@ -23,8 +23,10 @@ const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const settleLive = () => c._editorRuntime?._whenLiveEditorSettled() ?? c.updateComplete;
const stageEl = () => sr().querySelector('.stage');
const el = (id) => (id ? sr().querySelector(`.decorlayer [data-id="${id}"]`) : null);
const el = (id) => (id ? sr().querySelector(`[data-hp-live-editor] .decorlayer [data-id="${id}"]`)
|| sr().querySelector(`.decorlayer [data-id="${id}"]`) : null);
const attr = (id, n) => { const e = el(id); return e ? e.getAttribute(n) : null; };
const toScreen = (x, y) => {
const r = stageEl().getBoundingClientRect();
@@ -46,8 +48,25 @@ const res = await page.evaluate(async () => {
const cmToUnits = (cm) => (cm / CELL) * PITCH;
const near = (a, b, tol) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= tol;
const pe = (id) => { const e = el(id); return e ? getComputedStyle(e).pointerEvents : null; };
const box = () => c._decorBoxOf(sofaNow());
const reducedMotion = c._reducedMotion;
c._reducedMotion = true;
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
await new Promise((resolve) => {
const observer = new ResizeObserver(() => {
observer.disconnect();
resolve();
});
observer.observe(stageEl());
});
while (c._modeTransitionBusy || c._refitRaf || c._pendingRefitSize) {
await new Promise(requestAnimationFrame);
}
c.requestUpdate();
await c.updateComplete;
c._reducedMotion = reducedMotion;
out.initialModeSettled = !c._modeTransitionBusy && !c._refitRaf && !c._pendingRefitSize;
c._curSpaceCfg.decor = [];
c._decorTool = 'select'; c._decorSel = null; c._furnPalette = null; c._furnCategory = null;
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
@@ -140,7 +159,8 @@ const res = await page.evaluate(async () => {
out.selectedItemIsMarked = !!item('sofa')?.classList.contains('on');
// ================= 3. точное превью без мутации ==========================
const ghost = () => sr().querySelector('.furniture-placement-preview');
const ghost = () => sr().querySelector('[data-hp-live-editor] .furniture-placement-preview')
|| sr().querySelector('.furniture-placement-preview');
const beforePreview = {
decor: c._decorList.length,
epoch: c._cfgEpoch,
@@ -148,7 +168,7 @@ const res = await page.evaluate(async () => {
redo: c._geometryHistory.redoName,
};
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
await c.updateComplete;
await settleLive();
const firstPreview = c._editorRuntime?._furniturePreviewPlacement()
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
const firstTransform = ghost()?.getAttribute('transform');
@@ -176,7 +196,7 @@ const res = await page.evaluate(async () => {
stageEl().dispatchEvent(new PointerEvent('pointerleave', {
bubbles: true, composed: true, pointerId: 11, pointerType: 'mouse',
}));
await c.updateComplete;
await settleLive();
out.pointerLeaveClearsPreview = !ghost() && c._furnPreviewInput === null;
// Unknown/stale symbols fail dark: neither an invisible ghost nor a decor
@@ -184,7 +204,7 @@ const res = await page.evaluate(async () => {
c._furnPalette = { symbol: 'future_unknown_symbol', w: 180, h: 90 };
await c.updateComplete;
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
await c.updateComplete;
await settleLive();
const beforeUnknown = c._decorList.length;
ev('pointerdown', stageEl(), 300, 300, { pointerType: 'mouse' });
await c.updateComplete;
@@ -196,7 +216,7 @@ const res = await page.evaluate(async () => {
// ================= 4. размещение в реальном размере ======================
// середина комнаты r1 (40..550 × 140..580) — до стен дальше порога магнита
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
await c.updateComplete;
await settleLive();
const committedPreview = c._editorRuntime?._furniturePreviewPlacement()
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
const committedPreviewStroke = Number(ghost()?.getAttribute('stroke-width'));
@@ -266,7 +286,7 @@ const res = await page.evaluate(async () => {
category('bed')?.click(); await c.updateComplete;
pick('bed_double'); await c.updateComplete;
ev('pointermove', stageEl(), 300, 140 + wallHalf, { pointerType: 'mouse' });
await c.updateComplete;
await settleLive();
const thickWallPreview = c._editorRuntime?._furniturePreviewPlacement()
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
ev('pointerdown', stageEl(), 300, 140 + wallHalf);
@@ -282,6 +302,39 @@ const res = await page.evaluate(async () => {
&& ['x', 'y', 'w', 'h'].every((key) => near(bed[key], thickWallPreview[key], 1e-12))
&& (bed.angle || 0) === thickWallPreview.angle;
// #447: the same finite outer atom also has its exterior physical face.
// Raw intent above the wall must never be pulled through to the room side;
// preview, commit and exact-axis drag all share that decision.
await openFurniture();
c._furnPalette = { symbol: 'chair', w: 50, h: 50 };
c.requestUpdate(); await c.updateComplete;
const exteriorIntentY = 140 - wallHalf;
ev('pointermove', stageEl(), 300, exteriorIntentY, { pointerType: 'mouse' });
await settleLive();
const exteriorPreview = c._editorRuntime?._furniturePreviewPlacement()
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
const exteriorDepth = cmToUnits(50);
const exteriorCentreY = 140 - wallHalf - exteriorDepth / 2;
out.exteriorPreviewStaysOutside = !!exteriorPreview
&& near((exteriorPreview.y + exteriorPreview.h / 2) * 1000, exteriorCentreY, 1e-6)
&& Math.abs(Math.abs(exteriorPreview.angle) - 180) < 1e-6;
ev('pointerdown', stageEl(), 300, exteriorIntentY);
await c.updateComplete;
const exteriorChair = c._decorList.find((shape) => shape.symbol === 'chair');
const exteriorChairId = exteriorChair?.id ?? null;
out.exteriorCommitMatchesPreview = !!exteriorChair && !!exteriorPreview
&& ['x', 'y', 'w', 'h'].every((key) => near(exteriorChair[key], exteriorPreview[key], 1e-12))
&& (exteriorChair.angle || 0) === exteriorPreview.angle;
ev('pointerdown', el(exteriorChairId), 300, exteriorCentreY);
ev('pointermove', stageEl(), 300, 140);
await settleLive();
const exteriorAxisDrag = c._decorList.find((shape) => shape.id === exteriorChairId);
out.exteriorExactAxisDragPreservesSide = !!exteriorAxisDrag
&& near((exteriorAxisDrag.y + exteriorAxisDrag.h / 2) * 1000, exteriorCentreY, 1e-6)
&& Math.abs(Math.abs(exteriorAxisDrag.angle || 0) - 180) < 1e-6;
ev('pointerup', stageEl(), 300, 140);
await c.updateComplete;
// The regular decor snap resolves this pointer to the shared wall axis.
// Surface-side selection must still use the raw point on the r2 side.
await openFurniture();
@@ -289,10 +342,10 @@ const res = await page.evaluate(async () => {
pick('chair'); await c.updateComplete;
const sharedIntentX = 550 + wallHalf / 2;
ev('pointermove', stageEl(), sharedIntentX, 300, { pointerType: 'mouse' });
await c.updateComplete;
await settleLive();
ev('pointerdown', stageEl(), sharedIntentX, 300);
await c.updateComplete;
const sharedChair = c._decorList.find((s) => s.symbol === 'chair');
const sharedChair = c._decorList.find((s) => s.symbol === 'chair' && s.id !== exteriorChairId);
const chairDepth = (sharedChair?.h ?? NaN) * 1000;
const rightSurfaceCentre = 550 + wallHalf + chairDepth / 2;
out.sharedWallUsesRawPointerSide = !!sharedChair
@@ -304,7 +357,7 @@ const res = await page.evaluate(async () => {
const sharedChairY = sharedChair ? (sharedChair.y + sharedChair.h / 2) * 1000 : NaN;
ev('pointerdown', el(sharedChairId), rightSurfaceCentre, sharedChairY);
ev('pointermove', stageEl(), 550, sharedChairY);
await c.updateComplete;
await settleLive();
const axisDraggedChair = c._decorList.find((s) => s.id === sharedChairId);
out.exactAxisDragPreservesWallSide = !!axisDraggedChair
&& near((axisDraggedChair.x + axisDraggedChair.w / 2) * 1000, rightSurfaceCentre, 1e-6);
@@ -352,14 +405,14 @@ const res = await page.evaluate(async () => {
ev('pointerdown', el(sofaId), 300, 300);
out.dragStarted = !!sofaId && c._decorMove?.id === sofaId;
ev('pointermove', stageEl(), 540, 300);
await c.updateComplete;
await settleLive();
const pulled = sofaNow();
out.dragMagnetTurnsItToTheWall = Math.abs(Math.abs(pulled.angle || 0) - 90) < 1e-6;
out.dragMagnetPressesTheBack =
near((pulled.x + pulled.w / 2) * 1000,
550 - wallHalf - cmToUnits(90) / 2, 1e-6);
ev('pointermove', stageEl(), 300, 300, { shiftKey: true });
await c.updateComplete;
await settleLive();
const shiftDragGridIndex = (sofaNow().x * 1000) / PITCH;
out.shiftDragRemainsGridBound = near(shiftDragGridIndex, Math.round(shiftDragGridIndex), 1e-6);
ev('pointerup', stageEl(), 300, 300, { shiftKey: true });
@@ -377,7 +430,7 @@ const res = await page.evaluate(async () => {
c._cfgEpoch++;
c._decorSel = sofaId; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const frame = () => sr().querySelector('.dtframe');
const frame = () => sr().querySelector('[data-hp-live-editor] .dtframe') || sr().querySelector('.dtframe');
out.frameOnSelection = !!frame();
out.furnitureFrameHasFourSideHandles = !!frame()
&& frame().classList.contains('dtfurnitureframe')
@@ -391,9 +444,10 @@ const res = await page.evaluate(async () => {
const rOf = (sel) => { const e = frame()?.querySelector(sel); return e ? +e.getAttribute('r') : NaN; };
out.handleSizeIsTheTaskOneSize = Math.abs(rOf('.dtknob') * 4 - rOf('.dthandle.dtrot'))
/ Math.max(rOf('.dthandle.dtrot'), 1e-9) < 0.05;
out.frameBoxIsTheShapeBox = !!c._dtBox
&& near(c._dtBox.w, sofaNow().w * 1000, 1e-6)
&& near(c._dtBox.h, sofaNow().h * 1000, 1e-6);
const frameBox = frame()?.querySelector('.dtbox');
out.frameBoxIsTheShapeBox = !!frameBox
&& near(+frameBox.getAttribute('width'), sofaNow().w * 1000, 1e-6)
&& near(+frameBox.getAttribute('height'), sofaNow().h * 1000, 1e-6);
const visiblePath = el(sofaId);
const hitPath = sr().querySelector(`.dfurniturehit[data-id="${sofaId}"]`);
@@ -406,7 +460,7 @@ const res = await page.evaluate(async () => {
// Shift: independent, continuous width/depth + live plates.
const before = { w: sofaNow().w, h: sofaNow().h };
const b = c._dtBox || { x: 0, y: 0, w: 0, h: 0 };
const b = box() || { x: 0, y: 0, w: 0, h: 0 };
const handles = [...(frame()?.querySelectorAll('.dthandle') || [])];
// #400: угловая ручка ищется по РОЛИ, а не по индексу в DOM. Индекс держался
// на порядке отрисовки, а порядок — это решение о приоритете хита (углы
@@ -418,7 +472,7 @@ const res = await page.evaluate(async () => {
ev('pointerdown', se, b.x + b.w, b.y + b.h);
out.cornerDragStarted = c._dtDrag?.kind === 'scale' && !!c._dtDrag?.orig;
ev('pointermove', stageEl(), b.x + b.w + 100, b.y + b.h + 20, { shiftKey: true });
await c.updateComplete;
await settleLive();
const grown = sofaNow();
out.cornerGrowsTheWidth = grown.w > before.w && near(grown.w * 1000, b.w + 100, 1e-6);
out.cornerGrowsTheDepthINDEPENDENTLY = grown.h > before.h
@@ -433,7 +487,7 @@ const res = await page.evaluate(async () => {
&& plates.some((p) => /m|м|′/.test(p.textContent));
// Without Shift the original ratio is preserved, still without grid snap.
ev('pointermove', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
await c.updateComplete;
await settleLive();
const proportional = sofaNow();
const wCells = (proportional.w * 1000) / PITCH;
out.defaultResizePreservesRatio = near(proportional.w / proportional.h, before.w / before.h, 1e-5);
@@ -445,12 +499,12 @@ const res = await page.evaluate(async () => {
// A middle handle changes only one axis and keeps the opposite edge fixed.
const edgeBefore = { ...sofaNow() };
const eb = c._dtBox;
const eb = box();
const rightEdge = [...frame().querySelectorAll('.dthandle.dtedge')].find((handle) =>
near(+handle.getAttribute('cx'), eb.x + eb.w, 1e-6));
ev('pointerdown', rightEdge, eb.x + eb.w, eb.y + eb.h / 2);
ev('pointermove', stageEl(), eb.x + eb.w + 13.7, eb.y + eb.h / 2);
await c.updateComplete;
await settleLive();
const edgeAfter = sofaNow();
out.edgeHandleChangesOneAxisContinuously = near(edgeAfter.h, edgeBefore.h, 1e-9)
&& near(edgeAfter.w * 1000, edgeBefore.w * 1000 + 13.7, 1e-6)
@@ -461,13 +515,13 @@ const res = await page.evaluate(async () => {
// Crossing the fixed corner toggles only the crossed axis. pointercancel
// must restore the exact pre-gesture object and create no persisted flip.
const crossBefore = JSON.parse(JSON.stringify(sofaNow()));
const cb = c._dtBox;
const cb = box();
const crossHandles = [...frame().querySelectorAll('.dthandle')]
.filter((el) => !el.classList.contains('dtrot') && !el.classList.contains('dtedge'));
const crossSe = crossHandles[2]; // #400: по роли, не по позиции в DOM
ev('pointerdown', crossSe, cb.x + cb.w, cb.y + cb.h);
ev('pointermove', stageEl(), cb.x - 25, cb.y + cb.h + 10, { shiftKey: true });
await c.updateComplete;
await settleLive();
const crossed = sofaNow();
out.crossingTogglesOnlyHorizontalFlip = crossed.flip_h === true && !crossed.flip_v
&& crossed.w > 0 && crossed.h > 0 && near(crossed.x * 1000 + crossed.w * 1000, cb.x, 1e-6);
@@ -484,17 +538,17 @@ const res = await page.evaluate(async () => {
ev('pointerdown', frame()?.querySelector('.dthandle.dtrot'), cx + 100, cy);
out.rotateStarted = c._dtDrag?.kind === 'rotate';
ev('pointermove', stageEl(), cx + 100, cy + 100); // ровно 45°
await c.updateComplete;
await settleLive();
out.rotates45 = near(sofaNow().angle || 0, 45, 1e-6);
ev('pointermove', stageEl(), cx + 100, cy + 2); // ~1.1° → липнет к 0
await c.updateComplete;
await settleLive();
const fine = sofaNow().angle;
out.rotationIsFreeWithoutShift = fine > 0.5 && fine < 5;
ev('pointermove', stageEl(), cx + 100, cy + 2, { shiftKey: true });
await c.updateComplete;
await settleLive();
out.shiftSnapsRotationTo45 = (sofaNow().angle || 0) === 0;
ev('pointermove', stageEl(), cx + 100, cy + 100);
await c.updateComplete;
await settleLive();
ev('pointerup', stageEl(), cx + 100, cy + 100);
await c.updateComplete;
out.rotationIsRendered = /rotate\(/.test(attr(sofaId, 'transform') || '');
+5 -1
View File
@@ -101,7 +101,11 @@ await page.evaluate(async () => {
// raster pair and prove the precision ratio separately below.
runtime._gridLevels = mode === 'view' ? originalGridLevels : () => null;
card._gridLevels = () => runtime._gridLevels();
card._labs = { ...card._labs, active: projection === 'iso' ? ['iso'] : [] };
card._labs = {
...card._labs,
alpha: projection === 'iso',
active: projection === 'iso' ? ['iso'] : [],
};
card._viewPreference = { ...card._viewPreference, f1: projection };
card._isoFallback.clear();
card._isoGeometryCache.clear();
+2 -2
View File
@@ -271,9 +271,9 @@ const farIcon = await page.evaluate(async () => {
const o = {};
c._resetZoom(); await window.__waitForCamera(c);
const at1 = size();
c._applyView(4); await c.updateComplete;
c._applyView(4); await new Promise((resolve) => requestAnimationFrame(resolve));
const at4 = size();
c._applyView(1 / 3); await c.updateComplete;
c._applyView(1 / 3); await new Promise((resolve) => requestAnimationFrame(resolve));
const atOut = size();
c._resetZoom(); await window.__waitForCamera(c);
o.iconGrowsWithZoomIn = at1 > 4 && Math.abs(at4 / at1 - 4) < 0.05;
+14 -10
View File
@@ -11,18 +11,15 @@ const out = await page.evaluate(async () => {
return [rect.left + rect.width / 2, rect.top + rect.height / 2];
};
const enable = async () => {
history.replaceState(null, '', '#space=f1&hp-labs=iso');
history.replaceState(null, '', '#space=f1&hp_alpha=1');
dispatchEvent(new HashChangeEvent('hashchange'));
if (typeof card._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
const active = Object.freeze(['iso']);
// The product flag expires at 1.65.0. Keep this renderer/lifecycle smoke
// explicit without extending the public Labs registry contract.
card._onLabsSnapshot({ active, space: '' });
window.__hpLabs = active;
await card.updateComplete;
await frame();
};
const result = {};
result.cleanProfileStartsOff = window.__hpAlpha === false
&& JSON.stringify(window.__hpLabs) === '[]'
&& !root().querySelector('[data-hp="projection-toggle"]');
const configSpace = card._serverCfg.spaces.find((space) => space.id === 'f1');
configSpace.settings = {
...(configSpace.settings || {}), show_borders: true, show_names: true,
@@ -66,8 +63,9 @@ const out = await page.evaluate(async () => {
result.isoGateFlipReversesTurn = centredGate?.leaves[0]?.turnDeg
=== -flippedGate?.leaves[0]?.turnDeg;
const toggle = root().querySelector('[data-hp="projection-toggle"]');
result.labsSnapshotFrozen = Object.isFrozen(window.__hpLabs)
result.alphaSnapshotFrozen = window.__hpAlpha === true && Object.isFrozen(window.__hpLabs)
&& JSON.stringify(window.__hpLabs) === '["iso"]';
result.alphaStored = localStorage.getItem('houseplan_card_alpha_v1') === '1';
result.toggleShown = !!toggle;
result.toggleMinHitTarget = toggle && toggle.getBoundingClientRect().width >= 44
&& toggle.getBoundingClientRect().height >= 44;
@@ -144,14 +142,20 @@ const out = await page.evaluate(async () => {
card.requestUpdate();
await card.updateComplete;
history.replaceState(null, '', '#space=f1&hp-labs=-iso');
history.replaceState(null, '', '#space=f1&hp_alpha=0');
dispatchEvent(new HashChangeEvent('hashchange'));
await card.updateComplete;
await frame();
result.removalIsImmediateFlat = JSON.stringify(window.__hpLabs) === '[]'
result.removalIsImmediateFlat = window.__hpAlpha === false
&& localStorage.getItem('houseplan_card_alpha_v1') === '0'
&& JSON.stringify(window.__hpLabs) === '[]'
&& !root().querySelector('[data-hp="projection-toggle"]')
&& !root().querySelector('[data-hp="iso-walls"]')
&& !root().querySelector('[id^="hp-iso-"]');
await enable();
result.reenableRestoresPreference = window.__hpAlpha === true
&& root().querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true'
&& !!root().querySelector('[data-hp="iso-walls"]');
return result;
});
+5 -10
View File
@@ -9,14 +9,8 @@ const out = await page.evaluate(async () => {
const root = (card) => card.renderRoot;
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
history.replaceState(null, '', '?hp-labs=iso#space=f1');
history.replaceState(null, '', '?hp_alpha=1#space=f1');
dispatchEvent(new HashChangeEvent('hashchange'));
if (typeof original._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
const testOnlyIso = Object.freeze(['iso']);
// The product registry remains expired; this direct snapshot exercises the
// dormant Stage 2 renderer and is removed by the real -iso path below.
original._onLabsSnapshot({ active: testOnlyIso, space: '' });
window.__hpLabs = testOnlyIso;
await original.updateComplete;
const configSpace = original._serverCfg.spaces.find((space) => space.id === 'f1');
configSpace.settings = {
@@ -301,7 +295,6 @@ const out = await page.evaluate(async () => {
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', kiosk });
host.replaceChildren(card);
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
card.hass = window.__mkHass();
const started = performance.now();
while ((!card._loadOk || card._booting) && performance.now() - started < 9000) await wait(30);
@@ -333,11 +326,13 @@ out.orientationResizeKeepsIso = await page.evaluate(async () => {
});
out.kioskEmergencyOffIsFlat = await page.evaluate(async () => {
const card = document.querySelector('houseplan-card');
history.replaceState(null, '', '?hp-labs=-iso#space=f1');
history.replaceState(null, '', '?hp_alpha=0#space=f1');
dispatchEvent(new HashChangeEvent('hashchange'));
await card.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
return !card.renderRoot.querySelector('[data-hp="iso-walls"]')
return window.__hpAlpha === false
&& localStorage.getItem('houseplan_card_alpha_v1') === '0'
&& !card.renderRoot.querySelector('[data-hp="iso-walls"]')
&& !card.renderRoot.querySelector('[data-hp="projection-toggle"]');
});
+2 -1
View File
@@ -210,7 +210,8 @@ const result = await page.evaluate(async (spaceFixture) => {
staticCard.remove();
const labs = Object.freeze(['iso']);
card._onLabsSnapshot({ active: labs, space: '' });
card._onLabsSnapshot({ alpha: true, active: labs, space: '' });
window.__hpAlpha = true;
window.__hpLabs = labs;
card._setProjection('iso');
await update(false);
+8 -7
View File
@@ -22,21 +22,22 @@ const res = await page.evaluate(async () => {
// 3) свайп при 1:1 переключает пространство (и точки показываются)
const s0 = c._space;
const stage = sr().querySelector('.stage');
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, clientX: 450, clientY: 305 }));
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0, clientX: 450, clientY: 305 }));
await c.updateComplete;
out.swipeSwitches = c._space !== s0;
out.dotsShown = !!sr().querySelector('.kioskdots');
// 4) при зуме свайп не переключает
c._zoom = 2; const s1 = c._space;
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 2, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 2, clientX: 450, clientY: 305 }));
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 2, pointerType: 'touch', isPrimary: true, button: 0, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 2, pointerType: 'touch', isPrimary: true, button: 0, clientX: 450, clientY: 305 }));
await c.updateComplete;
out.noSwipeZoomed = c._space === s1;
// 5) двойной тап сбрасывает зум
c._lastTap = Date.now() - 100;
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 3, clientX: 500, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 3, clientX: 501, clientY: 300 }));
for (const pointerId of [3, 4]) {
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId, pointerType: 'touch', isPrimary: true, button: 0, clientX: 500, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId, pointerType: 'touch', isPrimary: true, button: 0, clientX: 501, clientY: 300 }));
}
const resetStarted = performance.now();
while (c._cameraTransition?.active) {
if (performance.now() - resetStarted > 1000) throw new Error('double-tap camera transition did not settle');
+6 -4
View File
@@ -30,7 +30,8 @@ const out = await page.evaluate(async () => {
const sr = k.shadowRoot || k.renderRoot;
const stage = sr.querySelector('.stage');
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
bubbles: true, composed: true, cancelable: true, pointerId: id,
pointerType: 'touch', isPrimary: true, button: 0, clientX: x, clientY: y,
}));
o.kioskHasSeveralSpaces = k._model.length > 1;
@@ -108,9 +109,10 @@ const out = await page.evaluate(async () => {
await home();
k._applyView(2);
await k.updateComplete;
k._lastTap = Date.now() - 100;
fire('pointerdown', 58, 500, 300);
fire('pointerup', 58, 501, 300);
for (const id of [58, 60]) {
fire('pointerdown', id, 500, 300);
fire('pointerup', id, 501, 300);
}
const resetStarted = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (k._cameraTransition.active && performance.now() - resetStarted < 1000);
+2 -1
View File
@@ -201,7 +201,8 @@ const result = await page.evaluate(async (source) => {
staticCard.remove();
const labs = Object.freeze(['iso']);
card._onLabsSnapshot({ active: labs, space: '' });
card._onLabsSnapshot({ alpha: true, active: labs, space: '' });
window.__hpAlpha = true;
window.__hpLabs = labs;
card._setProjection('iso');
await update(false);
+2 -1
View File
@@ -211,7 +211,8 @@ const result = await page.evaluate(async (source) => {
staticCard.remove();
const labs = Object.freeze(['iso']);
card._onLabsSnapshot({ active: labs, space: '' });
card._onLabsSnapshot({ alpha: true, active: labs, space: '' });
window.__hpAlpha = true;
window.__hpLabs = labs;
card._setProjection('iso');
await update(false);
+5 -2
View File
@@ -42,7 +42,10 @@ await page.evaluate(() => {
return c.updateComplete && true;
});
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
await page.waitForTimeout(220); // editor chrome transition changes stage coordinates
await page.waitForFunction(() => {
const c = window.__card;
return !!c._editorRuntime && !c._modeTransitionBusy && !c._cameraTransition?.active;
});
await settle();
const screenPt = (x, y) => page.evaluate(([x, y]) => {
@@ -288,7 +291,7 @@ const previews = () => page.evaluate(() =>
// the click places it AT THE MAGNET and clears every hint
{
const [hx, hy] = await screenPt(293.7, 141);
const [hx, hy] = await screenPt(295, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
await page.mouse.click(hx, hy);
+2
View File
@@ -125,6 +125,7 @@ Object.assign(out, await page.evaluate(async () => {
fire(h, 'pointerdown', 21, r.left + r.width / 2, r.top + r.height / 2);
o.resizeHandleTakesThePointer = c._resize?.dragging === true && c._panStart === null;
fire(h, 'pointermove', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
await Promise.resolve();
const v1 = { ...c._viewOr(c._baseVb()) };
o.resizeHandleDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
o.resizeHandleResizes = c._resize?.dragging === true && c._resize.moved === true;
@@ -143,6 +144,7 @@ Object.assign(out, await page.evaluate(async () => {
fire(dev, 'pointerdown', 22, r.left + r.width / 2, r.top + r.height / 2);
o.deviceTakesThePointer = !!c._deviceDrag && c._panStart === null;
fire(dev, 'pointermove', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
await Promise.resolve();
c._stagePointerMove({ pointerId: 22, clientX: r.left + 60, clientY: r.top + 50 });
const v1 = { ...c._viewOr(c._baseVb()) };
o.deviceDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;

Some files were not shown because too many files have changed in this diff Show More