Compare commits

...
Author SHA1 Message Date
Matysh ed9ee026dc build: prepare v1.73.0-beta.6 candidate
Проверка (CI) / Классификация изменённых файлов (push) Successful in 41s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Successful in 55s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 24s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 31s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 51s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Failing after 10m22s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 10m20s
Проверка (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) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Failing after 20m14s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 10m33s
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Failing after 12m20s
Issue: #487
Issue: #488
User-Visible: yes
Release: v1.73.0-beta.6
2026-09-08 12:09:47 +03:00
claude[bot] 3a702b3124 docs: review document for #487
Проверка (CI) / Классификация изменённых файлов (push) Successful in 24s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 43s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 32s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 32s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 46s
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Failing after 4m0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 11m21s
Проверка (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) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Failing after 13m11s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 10m1s
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Failing after 18m36s
Issue: #487
User-Visible: no
2026-09-08 08:11:43 +00:00
Matysh 68462b81e0 docs: refresh screenshot provenance after rebase
All eleven documented frames remain byte-identical in the canonical Linux capture after combining #487 with current dev.

Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34201357630
Issue: #487
User-Visible: no
2026-09-08 10:54:17 +03:00
Matysh 89deee0fb2 test: accept room temperature golden baselines
The two reviewed scenes come from the canonical full Linux Validate capture for the current source fingerprint.

Issue: #487
Release: v1.73.0-beta.6
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34198109380
User-Visible: no
2026-09-08 10:49:07 +03:00
Matysh ea12650f33 fix: type room temperature controls
Issue: #487
User-Visible: no
2026-09-08 10:44:49 +03:00
Matysh e2751062dc feat: add per-room temperature thresholds
Issue: #487
User-Visible: yes
2026-09-08 10:44:13 +03:00
claude[bot]andMatysh d2d9b1ac5d docs: review document for #487
Issue: #487
User-Visible: no
2026-09-08 10:42:13 +03:00
Matysh 42b44c7564 docs: specify room temperature thresholds
Issue: #487
User-Visible: no
2026-09-08 10:42:13 +03:00
claude[bot] b13a63f587 docs: review document for #488
Issue: #488
User-Visible: no
2026-09-08 07:27:14 +00:00
Codex 2b2fc94479 docs: refresh screenshot provenance for the panel mount fix
Fingerprint-only: the panel change does not alter any documented frame
(the docs harness already gave the panel host the viewport height), so the
committed images stay and only the source provenance moves.

Issue: #488
User-Visible: no
2026-09-08 10:10:20 +03:00
Codex e97e568111 docs: record the real HA panel container and mount order in the #486 spec
Issue: #488
User-Visible: no
2026-09-08 10:05:45 +03:00
Codex 2877213396 test: replace the content-slot height assumption with the #488 contract
Issue: #488
User-Visible: no
2026-09-08 10:05:45 +03:00
Codex 471527c474 fix: mount the House Plan panel the way Home Assistant does
HA assigns panel/hass/narrow/route before the top-level-await entry has
defined the element, so the values landed as own properties that shadowed
the accessors and the card never received hass. And <ha-panel-custom> has
no height, so the percentage host height collapsed the stage to 0 px.
Adopt pre-upgrade properties through the accessors and size the panel from
the viewport minus HA's safe-area padding. The smoke now reproduces HA's
real mount order and container; two mutants guard both contracts. The
bundle is rebuilt from these sources.

Issue: #488
User-Visible: yes
2026-09-08 10:05:45 +03:00
Matysh 9750330d77 build: prepare v1.73.0-beta.5 candidate
Issue: #484
Issue: #486
User-Visible: yes
Release: v1.73.0-beta.5
2026-09-08 08:16:35 +03:00
claude[bot] 1747d1f05a docs: review document for #486
Проверка (CI) / Классификация изменённых файлов (push) Successful in 26s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 44s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 24s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 31s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Failing after 45s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Failing after 3m59s
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Failing after 3m58s
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Failing after 4m6s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 6m9s
Проверка (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: #486
User-Visible: no
2026-09-07 22:58:22 +00:00
Matysh 603ee3584b test: accept House Plan panel golden baselines
Issue: #486
Release: v1.73.0-beta.5
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34166326951
User-Visible: no
2026-09-08 01:37:17 +03:00
Matysh 8bf30947fa docs: refresh screenshot provenance for House Plan panel
Issue: #486
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34166114162
User-Visible: no
2026-09-08 01:19:59 +03:00
Matysh 597488f77e test: close House Plan panel acceptance gaps
Issue: #486
User-Visible: no
2026-09-08 00:55:40 +03:00
Matysh 8a97d4e30d feat: add the House Plan sidebar panel
Issue: #486
User-Visible: yes
2026-09-08 00:31:25 +03:00
claude[bot] bf4c1bb674 docs: review document for #486
Issue: #486
User-Visible: no
2026-09-07 20:32:51 +00:00
Matysh 2821c35963 docs: specify House Plan sidebar panel
Issue: #486
User-Visible: no
2026-09-07 23:24:02 +03:00
claude[bot] 072bd0c3f0 docs: review document for #484
Issue: #484
User-Visible: no
2026-09-07 18:53:00 +00:00
Matysh 904b8b2251 test: accept complete PDF dimension chains
Issue: #484
Release: v1.73.0-beta.4
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34151259422
User-Visible: no
2026-09-07 21:31:29 +03:00
Matysh 7b826c19a7 docs: refresh screenshot fingerprint for PDF fix
Issue: #484
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34150593861
User-Visible: no
2026-09-07 21:14:58 +03:00
Matysh 66a2996a65 test: link PDF collision changes to browser smoke
Issue: #484
User-Visible: no
2026-09-07 21:10:03 +03:00
Matysh b4c1da6c0d refactor: name PDF collision sampler narrowly
Issue: #484
User-Visible: no
2026-09-07 21:09:16 +03:00
Matysh a51aada4f0 test: isolate post-exit tangent mutation
Issue: #484
User-Visible: no
2026-09-07 21:07:03 +03:00
Matysh e88ef93c3c fix: preserve stepped PDF dimensions
Issue: #484
User-Visible: yes
2026-09-07 21:05:57 +03:00
claude[bot] 66f7bdd0aa docs: review document for #484
Issue: #484
User-Visible: no
2026-09-07 17:42:28 +00:00
Matysh d53e4f70d3 docs: specify stepped PDF dimension chain
Issue: #484
User-Visible: no
2026-09-07 20:37:04 +03:00
Matysh d53064f65e build: refresh v1.73.0-beta.4 candidate
Проверка (CI) / Классификация изменённых файлов (push) Successful in 52s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 2m14s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 1m3s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 54s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 2m16s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Failing after 6m15s
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Failing after 5m35s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 10m52s
Проверка (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) Failing after 7m3s
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Failing after 12m1s
Issue: #482
Issue: #483
User-Visible: no
Release: v1.73.0-beta.4
2026-09-07 17:29:21 +03:00
claude[bot] f39bc7f7ae docs: review document for #483
Issue: #483
User-Visible: no
2026-09-07 14:27:42 +00:00
Matysh e67244c2a1 ci: recalibrate interaction aggregate ceiling
Issue: #483
User-Visible: no
2026-09-07 17:20:23 +03:00
Matysh 862cf151a5 build: prepare v1.73.0-beta.4 candidate
Issue: #482
User-Visible: yes
Release: v1.73.0-beta.4
2026-09-07 16:36:32 +03:00
claude[bot] e9a1954506 docs: review document for #482
Проверка (CI) / Классификация изменённых файлов (push) Successful in 38s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m40s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 2m22s
Проверка (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: #482
User-Visible: no
2026-09-07 09:05:57 +00:00
Matysh 9130c4fc59 test: accept PDF export baselines
Issue: #482
User-Visible: no
Release: v1.73.0-beta.4
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34101566145
2026-09-07 11:45:54 +03:00
Matysh dfac7cb8d3 test: strengthen PDF normalization witness
Issue: #482
User-Visible: no
2026-09-07 11:36:23 +03:00
Matysh ae03ad4219 docs: refresh PDF export screenshot
Issue: #482
User-Visible: no
2026-09-07 11:27:34 +03:00
Matysh 63d519e385 test: isolate PDF performance budget
Issue: #482
User-Visible: no
2026-09-07 11:24:33 +03:00
Matysh 7e7c9f3b49 fix: harden PDF dimension placement
Issue: #482
User-Visible: yes
2026-09-07 11:21:25 +03:00
Matysh 15b060ac38 fix: polish PDF export
Issue: #482
User-Visible: yes
2026-09-07 11:06:28 +03:00
claude[bot] 91331041d3 docs: review document for #482
Issue: #482
User-Visible: no
2026-09-07 06:23:59 +00:00
Matysh 5c1175cebc docs: pin PDF compass provenance
Issue: #482
User-Visible: no
2026-09-07 09:18:03 +03:00
claude[bot] 609a9bef2d docs: review document for #482
Issue: #482
User-Visible: no
2026-09-07 06:15:34 +00:00
Matysh bc93babdf8 docs: specify PDF export polish
Issue: #482
User-Visible: no
2026-09-07 09:07:35 +03:00
Matysh 08aad891fe build: prepare v1.73.0-beta.3 candidate
Проверка (CI) / Классификация изменённых файлов (push) Successful in 41s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m1s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 24s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 30s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 50s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Failing after 3m58s
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Failing after 4m14s
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Failing after 4m16s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 6m9s
Проверка (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) Failing after 4m33s
Issue: #53
Issue: #481
User-Visible: yes
Release: v1.73.0-beta.3
2026-09-07 05:34:56 +03:00
claude[bot] 00fa61a213 docs: review document for #53
Проверка (CI) / Классификация изменённых файлов (push) Successful in 33s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 59s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 45s
Проверка (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: #53
User-Visible: no
2026-09-07 02:28:36 +00:00
Matysh 6562d48204 test: accept PDF export golden baselines
Release: v1.73.0-beta.2
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34075026829
Issue: #53
User-Visible: no
2026-09-07 05:12:20 +03:00
Matysh 3be3e46a5d docs: refresh compact header screenshot
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34075005646
Issue: #53
User-Visible: no
2026-09-07 05:06:48 +03:00
Matysh 63646f069c fix: preserve projection geometry with PDF action
Issue: #53
User-Visible: no
2026-09-07 04:59:26 +03:00
Matysh b88233cfac docs: refresh PDF cache fingerprint
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34074100695
Issue: #53
User-Visible: no
2026-09-07 04:48:47 +03:00
Matysh 137616fa62 perf: reuse clean room areas in PDF
Issue: #53
User-Visible: no
2026-09-07 04:46:20 +03:00
Matysh 008efbe920 docs: refresh responsive PDF screenshot
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34073461748
Issue: #53
User-Visible: no
2026-09-07 04:37:44 +03:00
Matysh 42decbef53 fix: keep editor header geometry stable
Issue: #53
User-Visible: no
2026-09-07 04:34:16 +03:00
Matysh 66bce93b28 docs: refresh PDF screenshot fingerprint
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34072334310
Issue: #53
User-Visible: no
2026-09-07 04:16:20 +03:00
Matysh c226a0adcd perf: reuse visible room contours for PDF
Issue: #53
User-Visible: no
2026-09-07 04:13:10 +03:00
Matysh b6811ba7c7 test: stabilize PDF scene performance witness
Issue: #53
User-Visible: no
2026-09-07 04:07:19 +03:00
Matysh e86b689d65 docs: accept PDF export screenshots
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34071499278
Issue: #53
User-Visible: no
2026-09-07 04:03:09 +03:00
Matysh 8504f1b7e3 test: recognize lazy PDF dialog in docs capture
Issue: #53
User-Visible: no
2026-09-07 03:58:32 +03:00
Matysh d7d2e61884 test: prove PDF dialog reconnect recovery
Issue: #53
User-Visible: no
2026-09-07 03:56:13 +03:00
Matysh 47951e85db test: close PDF export witness gaps
Issue: #53
User-Visible: no
2026-09-07 03:50:36 +03:00
Matysh d277eeb73b feat: export current space as architectural PDF
Issue: #53
User-Visible: yes
2026-09-07 03:47:57 +03:00
claude[bot] 1c59e16514 docs: review document for #53
Issue: #53
User-Visible: no
2026-09-06 23:37:16 +00:00
Matysh 299629d2d2 docs: address PDF export spec review
Issue: #53
User-Visible: no
2026-09-07 02:28:56 +03:00
claude[bot] ee72e393a7 docs: review document for #53
Issue: #53
User-Visible: no
2026-09-06 23:21:30 +00:00
Codex cc93e93da9 docs: ТЗ #53 — внутренние размеры комнат грань-в-грань и внешние размеры граней
Issue: #53
User-Visible: no
2026-09-07 02:09:38 +03:00
Codex 48a17f5dc9 docs: spec #53 — экспорт пространства в PDF по обновлённой постановке
Issue: #53
User-Visible: no
2026-09-07 02:08:17 +03:00
Codex afbee9d328 docs: #52 объединён с #53 — спека остаётся контрактом измерений для PDF
Проверка (CI) / Классификация изменённых файлов (push) Successful in 47s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m23s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 20s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 34s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 48s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Failing after 4m4s
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Failing after 4m3s
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Failing after 4m0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 8m21s
Проверка (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) Failing after 5m37s
Issue: #53
User-Visible: no
2026-09-07 01:51:31 +03:00
claude[bot] c1e6199b46 docs: review document for #481
Issue: #481
User-Visible: no
2026-09-06 21:14:31 +00:00
claude[bot] df4104ab38 docs: review document for #481
Issue: #481
User-Visible: no
2026-09-06 21:00:39 +00:00
Codexandclaude[bot] f8ae546fb0 test: бамп версии не меняет отпечатки мутантов ядра; бандл пересобран
source-fingerprint.mjs входит в отпечаток сборки — три копии бандла
обновлены вместе с тестом.

Issue: #481
User-Visible: no
2026-09-06 21:00:39 +00:00
Codexandclaude[bot] c2913b2866 docs: refresh screenshot source fingerprint
source-fingerprint.mjs входит в корпус отпечатка; пиксели не менялись.

Issue: #481
User-Visible: no
2026-09-06 21:00:39 +00:00
Codexandclaude[bot] 52aa332311 docs: журнал свидетелей в TESTING.md
Issue: #481
User-Visible: no
2026-09-06 21:00:39 +00:00
Codexandclaude[bot] 97aabddce7 ci: журнал пойманных свидетелей для changed_mutants
witnessFingerprint (файлы патча и гарда + объявление, без строки версии),
readLedger/recordCaught/splitByLedger, флаг --ledger только с --changed;
журнал пишется после каждого пойманного мутанта, в CI — cache restore по
префиксу шарда и save при любом исходе. Три свидетеля.

Issue: #481
User-Visible: no
2026-09-06 21:00:39 +00:00
claude[bot] 2f65a184fd docs: review document for #481
Issue: #481
User-Visible: no
2026-09-06 20:48:14 +00:00
Matysh 5d281171c8 ci: shard changed mutants before release
Issue: #480
User-Visible: no
Release: v1.73.0-beta.2
2026-09-06 21:44:09 +03:00
Matysh f2e38e4815 build: prepare v1.73.0-beta.2 candidate
Issue: #471
Issue: #472
Issue: #473
Issue: #474
Issue: #476
Issue: #477
Issue: #478
User-Visible: yes
Release: v1.73.0-beta.2
2026-09-06 21:10:24 +03:00
claude[bot] 8e5667b9ec docs: review document for #477
Issue: #477
User-Visible: no
2026-09-06 18:00:59 +00:00
Matyshandclaude[bot] 04f4cd10e1 docs: refresh screenshot source fingerprint
Issue: #477
User-Visible: no
2026-09-06 17:52:05 +00:00
Matyshandclaude[bot] 93c9cfae22 test: align wall-chain smoke with finish contract
Issue: #477
User-Visible: no
2026-09-06 17:52:05 +00:00
Matyshandclaude[bot] ed63e4784f test: make seeded merge mutation effective
Issue: #477
User-Visible: no
2026-09-06 17:52:05 +00:00
Matyshandclaude[bot] 7f464deb37 test: target free transform mutation
Issue: #477
User-Visible: no
2026-09-06 17:52:05 +00:00
Matyshandclaude[bot] 28f8ea8f21 test: target room reference mutations
Issue: #477
User-Visible: no
2026-09-06 17:52:05 +00:00
Matyshandclaude[bot] 3f3b9ad723 test: make writer safety mutation observable
Issue: #477
User-Visible: no
2026-09-06 17:52:05 +00:00
Matyshandclaude[bot] a979699a57 fix: keep editor writers at optimizer fixed point
Issue: #477
User-Visible: yes
2026-09-06 17:52:05 +00:00
claude[bot] 80ff541cef docs: review document for #477
Issue: #477
User-Visible: no
2026-09-06 17:52:05 +00:00
Matyshandclaude[bot] 4693d97d2f docs: specify editor writer fixed point
Issue: #477
User-Visible: no
2026-09-06 17:52:05 +00:00
Codex 052549fa20 test: accept golden baselines after #471 and #476
Fifteen reviewed scenes from the CI matrix on dev 55d832ec: ten isometric
scenes without the painted overlay plates (#471) and five color-popover
scenes with the OK button (#476). Pixels taken from the Validate artifact,
not from a local capture.

Issue: #471
Release: v1.73.0-beta.2
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34046189201
User-Visible: no
2026-09-06 19:52:56 +03:00
Claude 55d832ec8b ci: dispatch-прогон Validate в своей группе concurrency
Первый ручной запуск nightly (run 2645) был отменён через 1:31 очередным
push в dev: обе стороны делили группу validate-refs/heads/dev. Ночной
полный набор не должен обнуляться коммитом, пришедшим в то же окно.

Issue: #479
User-Visible: no
2026-09-06 19:39:01 +03:00
claude[bot]andCodex a4bcf834e6 docs: review document for #475
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m16s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 34s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу: затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m11s
Проверка (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: #475
User-Visible: no
2026-09-06 19:33:08 +03:00
Codex 0145f9bafe ci: правка реестра мутантов сама запускает гейт по диффу (#475 r1)
Классификатор отдаёт выход mutants по scripts/mutation-gate.mjs, job
changed_mutants получает третий дизъюнкт из ТЗ §2; fallback-ветки
выставляют mutants=true. Тест AC7 отбирает бэкенд-мутанты по файлу патча.

Issue: #475
User-Visible: no
2026-09-06 19:33:08 +03:00
claude[bot]andCodex d2d145427b docs: review document for #475
Issue: #475
User-Visible: no
2026-09-06 19:31:48 +03:00
Codex a6077ac922 ci: мутанты по диффу на каждом пуше, отбор и по файлам гарда
User-Visible: no
Issue: #475
2026-09-06 19:31:48 +03:00
claude[bot] d12b1f20b0 docs: review document for #478
Проверка (CI) / Классификация изменённых файлов (push) Successful in 48s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Successful in 53s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 43s
Проверка (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: #478
User-Visible: no
2026-09-06 16:20:37 +00:00
Matysh 4beb5c8829 docs: describe deterministic legacy id repair
Issue: #478
User-Visible: no
2026-09-06 19:15:51 +03:00
claude[bot] c9194bfbb7 docs: review document for #478
Issue: #478
User-Visible: no
2026-09-06 16:14:56 +00:00
Matysh 0c670a9862 docs: accept corrected room contour capture
Issue: #478
User-Visible: no
2026-09-06 18:50:46 +03:00
Matysh 38abe78c9a fix: address room draft review findings
Issue: #478
User-Visible: no
2026-09-06 18:46:50 +03:00
claude[bot] b754d362ca docs: review document for #478
Issue: #478
User-Visible: no
2026-09-06 15:31:44 +00:00
Matysh 1cadd520c0 test: align HA fixtures with model v10
Issue: #478
User-Visible: no
2026-09-06 18:07:56 +03:00
Matysh 0b577be59b docs: accept room contour screenshot for #478
Issue: #478
User-Visible: no
2026-09-06 17:53:14 +03:00
Matysh 04d6c5cddb refactor: type legacy room draft migration
Issue: #478
User-Visible: no
2026-09-06 17:39:54 +03:00
Matysh 5ab41a7299 refactor: remove persisted room drafts
Issue: #478
User-Visible: yes
2026-09-06 17:38:47 +03:00
claude[bot]andMatysh f1f05198a5 docs: review document for #478
Issue: #478
User-Visible: no
2026-09-06 17:37:09 +03:00
Matysh 9dabf84652 docs: specify removal of persisted room drafts
Issue: #478
User-Visible: no
2026-09-06 17:37:09 +03:00
claude[bot] c82ba84cc7 docs: review document for #474
Проверка (CI) / Классификация изменённых файлов (push) Successful in 1m46s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 2m20s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m3s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 23s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 34s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 16m17s
Проверка (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) Failing after 9m53s
Issue: #474
User-Visible: no
2026-09-06 13:38:29 +00:00
Codex 605a9a1c78 docs: ленивый арт мебели — ARCHITECTURE, FURNITURE, отпечаток скриншотов
Issue: #474
User-Visible: no
2026-09-06 16:10:53 +03:00
Codex 74eca9bdd1 test: мутант eager-import #474 использует привязку (Rollup выкидывает пустой импорт)
Issue: #474
User-Visible: no
2026-09-06 16:10:46 +03:00
Codex 9b4e1efb1d feat: арт мебели в ленивый чанк — стартовый граф 299 658 → 289 727 Б
Каталог (id, группа, размеры) остаётся eager; SVG-пути 44 дизайнерских
символов — отдельный чанк за FurnitureArtRuntime (ready|pending|fallback,
нонс на повторе, отпечаток сборки, осевший отказ). Запуск при приёме плана,
бут-вуаль ждёт арт в пределах BOOT_MAX_MS, редактор отдаёт арт рантайму
синхронно (adopt при загрузке чанка). Магнит проверяет каталог, не арт.
Потолок initial View 300 500 → 290 500. Семь свидетелей, смок
smoke_furniture_lazy_art, golden-harness требует все предметы после бута.
Ядро −2 строки.

Issue: #474
User-Visible: no
2026-09-06 16:10:46 +03:00
Codex a31d8981a7 docs: ТЗ #474 r2 — число свидетелей в AC9 и §8 сверено с таблицей §4.6
Issue: #474
User-Visible: no
2026-09-06 16:10:01 +03:00
claude[bot]andCodex e37285c756 docs: review document for #474
Issue: #474
User-Visible: no
2026-09-06 16:10:01 +03:00
Codex 9f572b85b2 docs: ТЗ #474 r1 — редактор передаёт арт рантайму синхронно, golden-ожидание по образцу iso
Issue: #474
User-Visible: no
2026-09-06 16:10:01 +03:00
claude[bot]andCodex c0426dadd8 docs: review document for #474
Issue: #474
User-Visible: no
2026-09-06 16:10:01 +03:00
Codex f35c054eaf docs: #474 в разделе P2 реестра ТЗ
Issue: #474
User-Visible: no
2026-09-06 16:10:01 +03:00
Codex c3716b6930 docs: spec #474 — арт мебели в ленивый чанк
Issue: #474
User-Visible: no
2026-09-06 16:09:50 +03:00
claude[bot] ab01586b32 docs: review document for #476
Issue: #476
User-Visible: no
2026-09-06 12:56:14 +00:00
claude[bot] b5ce94a7e2 docs: review document for #476
Issue: #476
User-Visible: no
2026-09-06 12:56:14 +00:00
Sergey Matyuninandclaude[bot] 28c653991a test: harden color picker confirmation guards
Issue: #476
User-Visible: no
2026-09-06 12:56:14 +00:00
claude[bot] 5d84511a9b docs: review document for #476
Issue: #476
User-Visible: no
2026-09-06 12:56:14 +00:00
Sergey Matyuninandclaude[bot] 194df5251a test: register color picker browser coverage
Issue: #476
User-Visible: no
2026-09-06 12:56:14 +00:00
Sergey Matyuninandclaude[bot] cb298b10f9 docs: refresh screenshot fingerprint
Issue: #476
User-Visible: no
2026-09-06 12:56:14 +00:00
Sergey Matyuninandclaude[bot] 98bdd46a52 feat: add color picker confirmation
Issue: #476
User-Visible: yes
2026-09-06 12:56:13 +00:00
claude[bot] ddb2d5e07b docs: review document for #476
Issue: #476
User-Visible: no
2026-09-06 12:56:13 +00:00
Sergey Matyuninandclaude[bot] 9ce994452d docs: clarify color picker invalid confirmation
Issue: #476
User-Visible: no
2026-09-06 12:56:13 +00:00
claude[bot] 7fb15fcf43 docs: review document for #476
Issue: #476
User-Visible: no
2026-09-06 12:56:13 +00:00
Sergey Matyuninandclaude[bot] 3fbf312bbc docs: specify color picker confirmation
Issue: #476
User-Visible: no
2026-09-06 12:56:13 +00:00
Claude 7195ad1914 infra: heavy CI gates on the beta candidate, bundle rebase script, gate:small
Validate ran three smoke shards, golden and performance_smoke on every push,
check-docs went red on any src/** change until screenshots were re-captured,
and a parallel bundle build made every second task branch fail to rebase.
None of these gates ever failed at review time; they fail before betas.

- `heavy` output in job `changes` (scripts/classify-changes.mjs): smoke,
  smoke_done, golden, performance_smoke run only for a head commit with a
  `Release:` trailer, `workflow_dispatch full=true` and pull requests.
- nightly.yml dispatches Validate on dev with full=true every night.
- check-docs `--screenshots=warn|strict`: freshness of the screenshot index
  warns on a plain push, errors on the candidate; everything else still errors.
- publish-prerelease.yml and release.yml refuse a candidate without the
  `Release:` trailer and (prerelease) require fresh screenshots — a green
  Validate without the heavy jobs cannot pass for a release.
- scripts/rebase-on-dev.mjs: rebase on origin/dev taking dev's copy of the
  committed bundle, rebuild with bundle:sync, amend; any other conflict aborts.
- npm run gate:small: mandatory PROCESS §8 part in one parallel run.

Issue: #479
User-Visible: no
2026-09-06 15:39:34 +03:00
claude[bot] 88e4cf50e3 docs: review document for #472
Issue: #472
User-Visible: no
2026-09-06 12:37:04 +00:00
Codex 57a4a0ed69 ci: SHA отчёта mutation-gate — от чекаута dev, не github.sha (#472 r1)
Issue: #472
User-Visible: no
2026-09-06 15:29:50 +03:00
claude[bot]andCodex d77d1953c7 docs: review document for #472
Issue: #472
User-Visible: no
2026-09-06 15:29:50 +03:00
Codex e76f3909d9 ci: отчёт об отказе расписания мутантов — issue и Telegram
User-Visible: no
Issue: #472
2026-09-06 15:29:50 +03:00
claude[bot] a3396a6c99 docs: review document for #473
Issue: #473
User-Visible: no
2026-09-06 12:14:44 +00:00
Codex d95255ada2 test: свидетели #473 после переименования plate→footprint (#471)
Issue: #473
User-Visible: no
2026-09-06 15:01:53 +03:00
Codex 0c85307bf4 ci: перф-смок по диффу — изометрия и взаимодействие в код-ревью
Классификация changes вынесена в scripts/classify-changes.mjs (выходы
perf_iso/perf_interaction, fallback --all). performance_smoke добавляет
large-house-isometric-v1 при правке src/iso-* и large-house-interaction-v1
при правке живого пути, по 3 образца против hardMaxMs полных профилей
(budgets-*-smoke.json). Набор профилей входит в ключ reuse. PROCESS.md §8.

Issue: #473
User-Visible: no
2026-09-06 15:00:29 +03:00
Codex 90a5b3f6e6 test: W4 #473 — компактная стена, чтобы AABB-отсечение решало
Issue: #473
User-Visible: no
2026-09-06 15:00:29 +03:00
Codex 0cf6cf4016 test: мутант AABB #473 снимает зазор по обеим осям
Issue: #473
User-Visible: no
2026-09-06 15:00:29 +03:00
Codex 49d4854221 test: свидетели на перф-дельту #160 — кэш размещений, зум внутрь, AABB
User-Visible: no
Issue: #473
2026-09-06 15:00:29 +03:00
claude[bot]andCodex b6afa6e501 docs: review document for #473
Issue: #473
User-Visible: no
2026-09-06 15:00:29 +03:00
Codex 5fbb9339c6 docs: spec #473 — сценарий, риски, доказательство AC7, флаги бенчмарка
User-Visible: no
Issue: #473
2026-09-06 15:00:29 +03:00
claude[bot]andCodex 7a662d93ed docs: review document for #473
Issue: #473
User-Visible: no
2026-09-06 15:00:29 +03:00
Codex dffe77e32d docs: specify iso perf witnesses and diff-aware performance smoke
User-Visible: no
Issue: #473
2026-09-06 15:00:29 +03:00
claude[bot] fc5973c223 docs: review document for #471
Issue: #471
User-Visible: no
2026-09-06 11:34:51 +00:00
Matyshandclaude[bot] 2b80641db8 docs: refresh screenshot source fingerprint
Accept the reproducible Linux CI capture for the issue 471 source tree. All ten documentation screenshots are byte-identical; only their source fingerprint changes.

Issue: #471
User-Visible: no
2026-09-06 11:23:52 +00:00
Matyshandclaude[bot] be5eeb41c3 fix: remove isometric overlay plates
Keep the raised overlay footprint as calculation-only geometry for collision, nudge and fit while removing its painted SVG surface and texture. Update Stage 3 contracts, smokes, golden harness rules and synchronized bundles.

Issue: #471
User-Visible: no
2026-09-06 11:23:51 +00:00
claude[bot] 73a2fc66d3 docs: review document for #471
Issue: #471
User-Visible: no
2026-09-06 11:23:51 +00:00
Matyshandclaude[bot] 5d6c1992e4 docs: specify removal of isometric overlay plates
Issue: #471
User-Visible: no
2026-09-06 11:23:51 +00:00
claude[bot] 90b185f603 docs: review document for #472
Issue: #472
User-Visible: no
2026-09-06 11:10:43 +00:00
claude[bot] 2a7e09ee39 docs: review document for #475
Issue: #475
User-Visible: no
2026-09-06 11:09:53 +00:00
claude[bot] f52b2449ea docs: review document for #475
Issue: #475
User-Visible: no
2026-09-06 11:00:53 +00:00
claude[bot] a91a231e61 docs: review document for #472
Issue: #472
User-Visible: no
2026-09-06 11:00:46 +00:00
Sergey Matyunin 615045cb18 docs: refresh screenshot source fingerprint
Проверка (CI) / Классификация изменённых файлов (push) Successful in 28s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 49s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m5s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 26s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 33s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 4m40s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 6m59s
Проверка (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: #160
User-Visible: no
2026-09-06 10:30:05 +03:00
Sergey Matyunin 1ee302d9e5 test: refresh golden source fingerprint
Issue: #160
User-Visible: no
Release: v1.73.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34018590429
2026-09-06 10:29:59 +03:00
Sergey Matyunin b3ca0ca2dd perf: cull distant isometric collision edges
Issue: #160
User-Visible: no
2026-09-06 10:13:49 +03:00
Sergey Matyunin 196fa09696 perf: cache scale-safe isometric nudges
Issue: #160
User-Visible: no
2026-09-06 10:00:24 +03:00
Sergey Matyunin 9d2256c090 docs: refresh screenshot source fingerprint
Issue: #160
User-Visible: no
2026-09-06 09:43:10 +03:00
Sergey Matyunin b69743c0bd perf: reuse isometric overlay presentation
Issue: #160
User-Visible: no
2026-09-06 09:37:55 +03:00
Sergey Matyunin c41292d49f docs: refresh screenshot fingerprint after isometric optimization
Issue: #160
User-Visible: no
2026-09-06 09:23:49 +03:00
Sergey Matyunin f1b9bbf33e perf: bound isometric overlay collision work
Issue: #160
User-Visible: no
2026-09-06 09:17:54 +03:00
Sergey Matyunin de2155787d test: accept isometric stage 3 golden baselines
Issue: #160
User-Visible: no
Release: v1.73.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34013473075
2026-09-06 08:26:22 +03:00
Sergey Matyunin 1f28734875 test: align smoke contracts with lazy isometric runtime
Issue: #160
User-Visible: no
2026-09-06 08:26:12 +03:00
Sergey Matyunin c49611967d build: prepare v1.73.0-beta.1 candidate
Issue: #160
Issue: #464
User-Visible: yes
Release: v1.73.0-beta.1
2026-09-06 08:14:02 +03:00
claude[bot] 9cf5cdf3bd docs: review document for #160
Проверка (CI) / Классификация изменённых файлов (push) Successful in 28s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 56s
Проверка (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
Issue: #160
User-Visible: no
2026-09-06 05:06:45 +00:00
Sergey Matyunin 8c12474531 docs: refresh screenshot fingerprint for stage 3
Issue: #160
User-Visible: no
2026-09-06 07:51:14 +03:00
Sergey Matyunin bd60edd238 fix: address isometric stage 3 review
Issue: #160
User-Visible: no
2026-09-06 07:48:03 +03:00
claude[bot] 77c28076a2 docs: review document for #160
Issue: #160
User-Visible: no
2026-09-06 04:39:45 +00:00
Sergey Matyunin ee7d486924 feat: implement isometric stage 3
Issue: #160
User-Visible: no
2026-09-06 07:06:54 +03:00
claude[bot]andSergey Matyunin 3bf9e2d6d3 docs: review document for #160
Issue: #160
User-Visible: no
2026-09-06 07:01:00 +03:00
Sergey Matyunin e0c27fb750 docs(issue): preserve legacy iso benchmark semantics
Issue: #160
User-Visible: no
2026-09-06 07:01:00 +03:00
Sergey Matyunin 672570330b docs(issue): specify isometric stage 3
Issue: #160
User-Visible: no
2026-09-06 07:01:00 +03:00
claude[bot] afba49a3fa docs: review document for #464
Проверка (CI) / Классификация изменённых файлов (push) Successful in 29s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m9s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 42s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 26s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 31s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 8m56s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 11m56s
Проверка (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: #464
User-Visible: no
2026-09-05 22:44:26 +00:00
Matysh 87cab133ac Refresh documentation screenshot fingerprint
Issue: #464
User-Visible: no
2026-09-06 01:29:01 +03:00
Matysh 0f7b0dff14 Fix hovered Zigbee topology endpoint layer
Issue: #464
User-Visible: yes
2026-09-06 01:23:51 +03:00
claude[bot] b7f82d54dd docs: review document for #464
Issue: #464
User-Visible: no
2026-09-05 22:17:16 +00:00
Matysh 276bf193a2 Refresh documentation screenshot fingerprint
Issue: #464
User-Visible: no
2026-09-06 00:58:05 +03:00
Matysh 565acb6046 Fix Zigbee topology layer order
Issue: #464
User-Visible: yes
2026-09-06 00:48:54 +03:00
claude[bot] a6004fc5f3 docs: review document for #464
Issue: #464
User-Visible: no
2026-09-05 21:14:15 +00:00
Matysh aae9efb9ff docs: specify Zigbee topology layer order
Issue: #464
User-Visible: no
2026-09-06 00:08:16 +03:00
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
620 changed files with 84732 additions and 22746 deletions
+106 -2
View File
@@ -31,8 +31,13 @@ on:
permissions:
contents: read
# Группа зависит от события (#472). Прежде она была одна на всё, и ручной
# запуск перед релизом отменял идущий по расписанию — так 24.08 погиб
# еженедельный прогон, а отменённый в списке выглядит «не красным». Поймать
# отмену изнутри нельзя: вместе с прогоном отменяются и не начавшиеся job,
# включая любой репортёр. Значит отмену надо не ловить, а не допускать.
concurrency:
group: mutation-gate
group: mutation-gate-${{ github.event_name }}
cancel-in-progress: true
jobs:
@@ -88,5 +93,104 @@ jobs:
- name: Тёплый test-build для инкрементальной компиляции мутантов
run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
# Вывод шарда сохраняется артефактом (#472): строки
# `FAIL <id>: тест остался зелёным…` — единственное место, где названо,
# ЧТО сбежало. Без артефакта отказ безымянный. `PIPESTATUS` — чтобы
# `tee` не съел код выхода раннера.
- name: Каждый тест ловит свою поломку
run: node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4
run: |
mkdir -p artifacts
set -o pipefail
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}.log
- name: Сохранить лог шарда
if: always()
uses: actions/upload-artifact@v7
with:
name: mutation-shard-${{ matrix.shard }}
path: artifacts/mutation-shard-${{ matrix.shard }}.log
if-no-files-found: warn
retention-days: 30
# Адресат у отказа (#472). Только по расписанию: ручные прогоны перед
# релизом падают по замыслу, их результат владелец смотрит сам — issue на
# каждый такой отказ был бы шумом, который снова перестанут читать.
#
# Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент —
# validate.yml, job с actions: read): перечислены все три.
report:
name: "Отказ расписания: issue и Telegram"
needs: mutants
if: always() && github.event_name == 'schedule' && needs.mutants.result != 'success'
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
issues: write
steps:
- uses: actions/checkout@v7
with:
ref: dev
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Забрать логи шардов
uses: actions/download-artifact@v7
with:
pattern: mutation-shard-*
path: artifacts/mutation-logs
- name: Собрать отчёт
id: report
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# SHA — того дерева, которое чекаутили и гоняли (dev), а не
# github.sha: для расписания это вершина default-ветки main, и отчёт
# называл бы «dev @ <SHA main>» (ревью r1). Образец — process.yml.
SHA=$(git rev-parse HEAD)
mkdir -p artifacts
node scripts/mutation-gate-report.mjs \
--logs=artifacts/mutation-logs --shards=4 \
--run-url="$RUN_URL" --ref=dev --sha="$SHA" \
--body-out=artifacts/mutation-report.md \
--telegram-out=artifacts/mutation-telegram.txt >> "$GITHUB_OUTPUT"
# Одно issue, а не одно на неделю: открытое с тем же маркером в заголовке
# получает комментарий, новое заводится только если открытого нет.
- name: Issue — создать или дописать
id: issue
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
MARKER: ${{ steps.report.outputs.marker }}
TITLE: ${{ steps.report.outputs.title }}
run: |
existing=$(gh issue list --repo "$REPO" --state open --search "\"$MARKER\" in:title" \
--json number,title --jq '[.[] | select(.title | startswith(env.MARKER))][0].number // empty')
if [ -n "$existing" ]; then
gh issue comment "$existing" --repo "$REPO" --body-file artifacts/mutation-report.md
url="${{ github.server_url }}/$REPO/issues/$existing"
else
url=$(gh issue create --repo "$REPO" --title "$TITLE" \
--label infra --label process --label tests \
--body-file artifacts/mutation-report.md)
fi
echo "url=$url" >> "$GITHUB_OUTPUT"
echo "issue: $url"
# Тот же канал, что у релизов (announce.yml). Нет секретов — не отказ:
# issue уже заведено, а Telegram — второй адресат, не единственный.
- name: Telegram
if: always() && steps.issue.outcome == 'success'
env:
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
ISSUE_URL: ${{ steps.issue.outputs.url }}
run: |
if [ -z "$TOKEN" ] || [ -z "$CHAT" ]; then
echo "::warning::TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID не заданы — оповещение пропущено, issue заведено"
exit 0
fi
TEXT=$(sed "s|(issue)|$ISSUE_URL|" artifacts/mutation-telegram.txt)
curl -sS --fail-with-body -X POST \
"https://api.telegram.org/bot$TOKEN/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=$TEXT" \
-d disable_web_page_preview=true
+38
View File
@@ -0,0 +1,38 @@
# Ночной полный прогон (#479).
#
# Тяжёлые job Validate — смоки, golden, performance_smoke — на обычном пуше не
# идут: они ни разу не ловили дефект в момент ревью и стоили ~6 минут
# критического пути на каждую итерацию. Полный набор идёт на кандидате беты
# (трейлер `Release:`), по кнопке и здесь — каждую ночь на голове `dev`.
#
# Почему не `schedule` прямо в validate.yml: расписание исполняется на ветке
# по умолчанию (`main`), а проверять надо `dev`. Один dispatch с `--ref dev`
# делает это без переписывания checkout во всех job. Reuse (#208) сохраняется:
# при неизменённом дереве ночной прогон обойдётся маркерами.
#
# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт:
# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и
# там полный набор идёт заново.
name: Ночной полный прогон dev
on:
schedule:
- cron: '30 2 * * *'
workflow_dispatch: {}
permissions:
actions: write
contents: read
jobs:
dispatch:
name: "Запустить Validate на dev с полным набором"
runs-on: ubuntu-latest
steps:
- env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
gh workflow run validate.yml --repo "$REPO" --ref dev -f full=true
echo "Validate(dev, full=true) поставлен в очередь: $(date -u +%FT%TZ)"
+17 -1
View File
@@ -36,7 +36,9 @@ jobs:
profile:
- large-house
- isometric
- isometric-stage3
- plan-snap
- interaction
- blend
- overlay
- space-default
@@ -186,10 +188,18 @@ jobs:
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
;;
isometric-stage3)
npm run benchmark:isometric-stage3-dense -- --allow-stage2-base --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-stage3-baseline.json
npm run benchmark:isometric-stage3-dense -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-stage3-candidate.json
;;
plan-snap)
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
@@ -230,11 +240,17 @@ jobs:
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
;;
isometric)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-comparison.json
;;
isometric-stage3)
npm run benchmark:compare -- --budgets=demo/performance/budgets-isometric-stage3-dense.json --baseline=../artifacts/performance/isometric-stage3-baseline.json --candidate=../artifacts/performance/isometric-stage3-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-stage3-comparison.json
;;
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, а не только в логе прогона.
+18
View File
@@ -54,6 +54,23 @@ jobs:
env:
TAG: ${{ inputs.tag }}
run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
# #479: тяжёлые job Validate идут только на коммите с трейлером `Release:`.
# Зелёный Validate без трейлера означал бы прогон без смоков и golden —
# класс тихого пропуска #171/#207, поэтому трейлер проверяется здесь явно.
- name: Require the Release trailer on the candidate commit
env:
SHA: ${{ steps.candidate.outputs.sha }}
run: |
set -euo pipefail
git log -1 --format=%B "$SHA" > /tmp/head-message.txt
if ! grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+' /tmp/head-message.txt; then
echo "::error::Candidate $SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
exit 1
fi
# #479: свежесть скриншотов на обычном пуше — предупреждение; на
# кандидате она обязана быть доказана строгим режимом.
- name: Documentation screenshots are fresh for the candidate
run: node scripts/check-docs.mjs --screenshots=strict
- name: Require green Validate for this exact SHA
env:
GH_TOKEN: ${{ github.token }}
@@ -90,6 +107,7 @@ jobs:
node scripts/verify-houseplan-zip.mjs houseplan.zip \
custom_components/houseplan/frontend "$VERSION"
test -s dist/houseplan-card.js
test -s dist/houseplan-panel.js
test -s houseplan.zip
- name: Create or verify the annotated tag
env:
+10 -1
View File
@@ -34,6 +34,12 @@ jobs:
# target_commitish (it may be a branch name) or an event-context SHA.
SHA=$(git rev-parse HEAD)
echo "release tag: $TAG; exact commit: $SHA"
# #479: тяжёлые job Validate идут только на коммите с трейлером
# `Release:`; без него зелёный Validate прогона без смоков не доказывает.
if ! git log -1 --format=%B "$SHA" | grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::$SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
exit 1
fi
node scripts/release-gate.mjs "$SHA"
- name: Require full performance for a stable release
if: ${{ !github.event.release.prerelease }}
@@ -68,7 +74,10 @@ jobs:
name: continuity-screencast
path: artifacts/continuity-screencast
- name: Verify the complete committed frontend tree
run: node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
run: |
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
test -s dist/houseplan-card.js
test -s dist/houseplan-panel.js
- name: Attach card to release
uses: softprops/action-gh-release@v3
with:
+197 -34
View File
@@ -13,11 +13,22 @@ on:
paths-ignore:
- 'docs/reviews/**'
pull_request:
# Полный набор тяжёлых job по кнопке и по ночам (#479): nightly.yml делает
# `gh workflow run validate.yml --ref dev -f full=true`.
workflow_dispatch:
inputs:
full:
description: 'Полный набор: смоки, golden, performance_smoke'
type: boolean
default: true
# A new push supersedes an unfinished validation for the same branch or PR.
# Exact-SHA release gates never depend on an obsolete commit.
# Полный прогон по кнопке/ночью (#479) живёт в своей группе: первый ручной
# запуск отменил очередной push конвейера через полторы минуты — ночной
# результат обнулялся бы любым коммитом, пришедшим в то же окно.
concurrency:
group: validate-${{ github.event.pull_request.number || github.ref }}
group: validate-${{ github.event_name == 'workflow_dispatch' && 'dispatch-' || '' }}${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
@@ -53,28 +64,47 @@ jobs:
- uses: actions/setup-node@v7
with: { node-version: 22 }
# Свежесть скриншотов на обычном пуше — предупреждение, на кандидате беты
# (трейлер `Release:`), по кнопке и на PR — ошибка (#479). Остальные
# проверки документации красят всегда. Режим считает тот же скрипт, что и
# выход `heavy` job `changes`, чтобы два места не разошлись.
- name: "Документация: гайды, ченджлоги, скриншот-индекс"
id: docs
continue-on-error: true
run: node scripts/check-docs.mjs --external
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
FULL_INPUT: ${{ inputs.full }}
run: |
heavy=$(node scripts/classify-changes.mjs --heavy)
mode=warn
[ "$heavy" = "heavy=true" ] && mode=strict
echo "скриншоты документации: режим $mode ($heavy)"
node scripts/check-docs.mjs --external --screenshots=$mode
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
- name: "Процесс: process.yml идентичен в main и dev"
- name: "Процесс: process.yml и mutation-gate.yml идентичны в main и dev"
id: workflow_sync
continue-on-error: true
run: |
git fetch --quiet origin main dev
if diff <(git show origin/main:.github/workflows/process.yml) \
<(git show origin/dev:.github/workflows/process.yml); then
echo "main и dev идентичны"
else
echo "РАСХОЖДЕНИЕ: process.yml в main и dev различаются."
echo "Конвейер исполняет версию из ветки по умолчанию, поэтому"
echo "правку нужно отправить в обе ветки."
exit 1
fi
# #472: расписание mutation-gate.yml тоже исполняется из ветки по
# умолчанию — та же ловушка, что у process.yml. Сверяются оба.
status=0
for file in process.yml mutation-gate.yml; do
if diff <(git show "origin/main:.github/workflows/$file") \
<(git show "origin/dev:.github/workflows/$file"); then
echo "$file: main и dev идентичны"
else
echo "РАСХОЖДЕНИЕ: $file в main и dev различаются."
echo "Файл исполняется из ветки по умолчанию, поэтому"
echo "правку нужно отправить в обе ветки."
status=1
fi
done
exit $status
# Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до
# #388 брали его от головы предыдущего пуша. Прогон предыдущего пуша
@@ -158,7 +188,7 @@ jobs:
}
echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY"
check "документация" "$DOCS"
check "process.yml в main и dev" "$WORKFLOW_SYNC"
check "process.yml и mutation-gate.yml в main и dev" "$WORKFLOW_SYNC"
check "провенанс коммитов" "$PROVENANCE"
check "процессный гейт" "$PROCESS_GATE"
exit $fail
@@ -182,6 +212,11 @@ jobs:
frontend: ${{ steps.classify.outputs.frontend }}
backend: ${{ steps.classify.outputs.backend }}
integration: ${{ steps.classify.outputs.integration }}
perf_iso: ${{ steps.classify.outputs.perf_iso }} # #473 §5
perf_interaction: ${{ steps.classify.outputs.perf_interaction }}
mutants: ${{ steps.classify.outputs.mutants }}
# Тяжёлые job только на кандидате/по кнопке/на PR (#479), см. шаг heavy.
heavy: ${{ steps.heavy.outputs.heavy }}
base: ${{ steps.base.outputs.base }}
# Разные вещи под разными именами намеренно: `base` — до какого коммита
# классифицировать файлы ветки (#387), `range_base` — от какого коммита
@@ -198,6 +233,15 @@ jobs:
# то есть допущение «до этого всё проверено». Concurrency отменяет прогон
# предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки
# без единого исполненного тяжёлого гейта.
# Смоки, golden, performance_smoke — на кандидате беты (трейлер
# `Release:`), по `workflow_dispatch full=true`, на PR (#479). Логика и
# её тест — scripts/classify-changes.mjs.
- id: heavy
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
FULL_INPUT: ${{ inputs.full }}
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
- id: base
if: github.event_name != 'pull_request'
env:
@@ -237,7 +281,7 @@ jobs:
run: |
if [ "$REF" = "refs/heads/dev" ]; then
echo "dev: без фильтров, всё true"
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
exit 0
fi
zero=$(printf '%040d' 0)
@@ -261,7 +305,7 @@ jobs:
echo "\`before\` (=$BEFORE_SHA) переписан force-push'ем: диапазон недоказуем,"
echo "все тяжёлые job запускаются без фильтра путей (#347)."
} >> "$GITHUB_STEP_SUMMARY"
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
exit 0
fi
# Диапазон считается от последнего ДОКАЗАННО зелёного предка (#387),
@@ -271,17 +315,14 @@ jobs:
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
if [ -z "$base" ]; then
echo "база не определена — полный прогон без классификации"
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
exit 0
fi
files=$(git diff --name-only "$base" "$HEAD_SHA")
printf '%s\n' "$files" | head -50
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
{
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|scripts/support-relay/|pytest\.ini$)')"
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
} >> "$GITHUB_OUTPUT"
# Шаблоны — в scripts/classify-changes.mjs: там они проверяемы
# unit-тестом, здесь — только переписываются в выходы (#473 AC8).
printf '%s\n' "$files" | node scripts/classify-changes.mjs | tee -a "$GITHUB_OUTPUT"
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
@@ -298,6 +339,10 @@ jobs:
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
reuse:
name: "Переиспользование: это дерево уже проверено"
# `changes` нужна ради набора профилей перф-смока (#473): ключ
# performance_smoke включает набор, иначе glow-only результат засчитался бы
# прогону, которому нужен изометрический профиль.
needs: changes
runs-on: ubuntu-latest
outputs:
smoke: ${{ steps.probe.outputs.smoke }}
@@ -307,6 +352,7 @@ jobs:
smoke_key: ${{ steps.keys.outputs.smoke }}
golden_key: ${{ steps.keys.outputs.golden }}
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
performance_smoke_set: ${{ steps.keys.outputs.performance_smoke_set }}
backend_key: ${{ steps.keys.outputs.backend }}
steps:
- uses: actions/checkout@v7
@@ -314,12 +360,21 @@ jobs:
with: { node-version: 22 }
- name: Ключи переиспользования
id: keys
env:
PERF_ISO: ${{ needs.changes.outputs.perf_iso }}
PERF_INTERACTION: ${{ needs.changes.outputs.perf_interaction }}
run: |
for job in smoke golden performance_smoke backend; do
key=$(node scripts/gate-reuse.mjs --job="$job")
echo "$job=$key" >> "$GITHUB_OUTPUT"
echo "$job: $key"
done
# Набор профилей перф-смока — часть ключа (#473 AC5).
set=glow
[ "$PERF_ISO" = "true" ] && set="$set-iso"
[ "$PERF_INTERACTION" = "true" ] && set="$set-interaction"
echo "performance_smoke_set=$set" >> "$GITHUB_OUTPUT"
echo "performance_smoke set: $set"
# lookup-only: маркер только проверяется, но не восстанавливается —
# сохранять его в этой job нечего, она ничего не прогоняла.
- name: Маркер smoke
@@ -341,7 +396,7 @@ jobs:
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}-${{ steps.keys.outputs.performance_smoke_set }}
lookup-only: true
- name: Маркер backend
id: m_backend
@@ -398,6 +453,97 @@ jobs:
- name: Hassfest validation
uses: home-assistant/actions/hassfest@master
# Мутанты по диффу (#475). Свидетель гниёт двумя способами: изменился
# файл, который он патчит, либо изменился его гард. `--check` этого не
# видит — он проверяет существование якоря, а не достижимость ветки.
# Полный прогон раз в неделю ловит гниль с опозданием до релиза (#466,
# #467). Здесь гоняются только затронутые мутанты с той же базы диапазона,
# что и остальные гейты ветки (#387/#388). Релизный диапазон всё же может
# задеть сотню свидетелей, поэтому #480 делит тот же набор на три
# детерминированных непересекающихся шарда вместо ослабления проверки.
changed_mutants:
name: "Мутанты по диффу (${{ matrix.shard }}/3): затронутые свидетели краснеют"
needs: changes
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mutants == 'true'
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- uses: actions/setup-python@v7
with:
python-version: '3.14'
- name: Backend test dependencies
run: pip install -r tests_backend/requirements.txt
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
# Журнал пойманных свидетелей (#481). Отменённый или упавший по таймауту
# прогон не пропадает: журнал пишется после каждого пойманного мутанта и
# сохраняется при любом исходе шага, а следующий пуш начинает с того
# места, где предыдущий остановился. Мутант с тем же отпечатком входов
# (файлы патча и гарда, без строки версии) повторно не гоняется.
- name: Журнал свидетелей (последний по шарду)
uses: actions/cache/restore@v6
with:
path: artifacts/mutation-ledger
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}
restore-keys: mutation-ledger-${{ matrix.shard }}-
- name: Затронутые мутанты ловятся
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
PROVEN_BASE: ${{ needs.changes.outputs.range_base }}
HEAD_SHA: ${{ github.sha }}
REF: ${{ github.ref }}
SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
git fetch -q origin dev
zero=$(printf '%040d' 0)
base=""
if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then
base="${PROVEN_BASE:-$BEFORE_SHA}"
fi
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|| ! git cat-file -e "$base" 2>/dev/null; then
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "")
fi
if [ -z "$base" ]; then
echo "не нашёл базу диапазона — мутанты по диффу пропущены, это увидит следующий пуш"
exit 0
fi
echo "диапазон: $base..$HEAD_SHA"
npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/3" \
--ledger="artifacts/mutation-ledger/shard-$SHARD.json"
# Сохраняется всегда: и после красного шага (пойманные до отказа уже
# записаны), и при отмене прогона — post-шаг cache/save исполняется,
# пока job не убита целиком. Ключ уникален на прогон: cache не
# перезаписывает существующий ключ, а restore-keys берёт самый новый.
- name: Сохранить журнал свидетелей
if: always()
uses: actions/cache/save@v6
with:
path: artifacts/mutation-ledger
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}
frontend:
name: "Фронтенд: типы, юниты, мутанты, синхрон бандла"
needs: changes
@@ -486,8 +632,8 @@ jobs:
smoke:
name: Смоки в браузере (шард ${{ matrix.shard }} из 3)
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
needs: [frontend, reuse]
if: needs.reuse.outputs.smoke != 'true'
needs: [changes, frontend, reuse]
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
@@ -590,8 +736,8 @@ jobs:
# частично прогнанная матрица не имеет права выглядеть как выполненная работа.
smoke_done:
name: "Смоки: все шарды зелёные"
needs: [smoke, reuse]
if: needs.reuse.outputs.smoke != 'true'
needs: [changes, smoke, reuse]
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true'
runs-on: ubuntu-latest
steps:
- name: Записать маркер успеха
@@ -613,8 +759,8 @@ jobs:
name: Golden-кадры против принятых эталонов
# Deterministic visual correctness stays in every prerelease gate: it is
# inexpensive and catches a different class of regressions than timings.
needs: [frontend, reuse]
if: needs.reuse.outputs.golden != 'true'
needs: [changes, frontend, reuse]
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.golden != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -711,13 +857,14 @@ jobs:
name: "Перф-смок: бюджет времени кадра"
# Candidate-only catastrophic-regression guard for ordinary pushes and
# prereleases. The expensive same-runner comparison lives in performance.yml.
needs: [frontend, reuse]
if: needs.reuse.outputs.performance_smoke != 'true'
needs: [changes, frontend, reuse]
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.performance_smoke != 'true'
runs-on: ubuntu-latest
# 15 минут не хватало, когда установка браузера шла через apt: замер
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
# job укладывается в те же минуты, что и раньше.
timeout-minutes: 20
# job укладывается в те же минуты, что и раньше. Диффозависимые профили
# (#473) добавляют до двух замеров по 3 образца — окно расширено под них.
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
@@ -757,10 +904,26 @@ jobs:
run: |
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-space-glow-smoke.json --candidate=artifacts/performance-smoke/space-candidate.json --output=artifacts/performance-smoke/space-comparison.json
# Диффозависимые профили (#473 §5): изометрия — когда дифф задел
# `src/iso-*`, взаимодействие — когда живой путь или оркестраторы кадра.
# Абсолютные потолки — `hardMaxMs` полных профилей: регрессия #160
# (9 870 мс против 3 500) ловится с одного образца, шум — нет.
- name: Изометрический профиль по диффу (#473)
if: needs.changes.outputs.perf_iso == 'true'
run: |
npm run benchmark:large-house -- --profile=large-house-isometric-v1 --samples=3 --warmups=1 --output=artifacts/performance-smoke/isometric-candidate.json
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-isometric-smoke.json --candidate=artifacts/performance-smoke/isometric-candidate.json --output=artifacts/performance-smoke/isometric-comparison.json
- name: Профиль взаимодействия по диффу (#473)
if: needs.changes.outputs.perf_interaction == 'true'
run: |
npm run benchmark:large-house -- --profile=large-house-interaction-v1 --samples=3 --warmups=1 --output=artifacts/performance-smoke/interaction-candidate.json
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-interaction-smoke.json --candidate=artifacts/performance-smoke/interaction-candidate.json --output=artifacts/performance-smoke/interaction-comparison.json
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
- 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
@@ -782,7 +945,7 @@ jobs:
continue-on-error: true
with:
path: .reuse-marker
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}-${{ needs.reuse.outputs.performance_smoke_set }}
backend:
name: "Бэкенд: pytest в Home Assistant"
+1
View File
@@ -12,5 +12,6 @@ artifacts/
# она только росла — 364 версии по 1.16 МБ за семь недель (#255). Обязательных
# копий две: `dist/` (артефакт сборки) и `custom_components/` (её ставит HACS).
demo/srv/assets/houseplan-card.js
demo/srv/assets/houseplan-panel.js
demo/srv/assets/houseplan-assets.json
demo/srv/assets/houseplan-assets/
+44 -5
View File
@@ -153,8 +153,12 @@ in between.
If the rebase conflicts the pipeline says so in the issue and sends the task back
to `S6-in-progress`. The verdict still stands: nothing needs reviewing again, the
remaining work is the rebase. Resolve it, push the branch, re-apply
`S7-code-review`. The second review run is not a formality — after a rebase onto a
remaining work is the rebase. When the conflict is only in the committed bundle
(`dist/**`, `custom_components/houseplan/frontend/**` — the usual case when two
tasks built it in parallel), run `node scripts/rebase-on-dev.mjs` (#479): it takes
`dev`'s copy through the rebase, rebuilds with `npm run bundle:sync` and amends
the result into your last commit; a conflict anywhere else aborts and leaves the
tree as it was. Then push the branch and re-apply `S7-code-review`. The second review run is not a formality — after a rebase onto a
moved `dev` this is different code, and accepting it unchecked is how regressions
arrive. Cycles are counted per stage, so a code review spends its own budget.
@@ -334,6 +338,23 @@ npm run bundle:sync # dist → custom_components + demo/srv/assets (#255)
npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
```
`npm run gate:small` runs the mandatory part of PROCESS §8 in one go (#479):
unit tests, build with typecheck, `no-new-any` and `smoke-select` in parallel,
then the bundle-tree comparison and the bundle budget. It prints the smokes the
diff selects but does not run them — those, `golden`, `pytest` and `check-docs
--screenshots=strict` remain the author's call by diff and AC.
**Heavy CI gates run on the beta candidate, nightly and on demand — not on every
push (#479).** `smoke`, `golden` and `performance_smoke` in Validate are gated
on the `heavy` output: true for a head commit carrying a `Release:` trailer, for
`workflow_dispatch full=true` (which `nightly.yml` issues on `dev` every night)
and for pull requests. A plain push to `dev` runs preflight, frontend (types,
units, build, bundle sync, no-new-any), backend, hacs and hassfest. Screenshot
freshness in `check-docs` is likewise a warning on a plain push and an error on
the candidate; `publish-prerelease.yml` and `release.yml` refuse a candidate
without the `Release:` trailer, so a green Validate without the heavy jobs can
never pass for a release.
During the implementation cycle the fast gates always run. Since 2026-08-14 the
owner's machine also carries Playwright with Chromium (Windows) and a full WSL
environment, which changes one thing (#151): **before moving an issue to
@@ -344,9 +365,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.
+11
View File
@@ -661,6 +661,17 @@ python -m pytest tests_backend -q # py3.13, если менялся бэке
при следующей задаче (#237). Пересъёмка — `npm run build && node
demo/docs/capture.mjs`, коммит вместе с задачей.
**Перф-смок в Validate зависит от диффа** (#473). Два glow-профиля
гоняются всегда; при правке `src/iso-*` добавляется `large-house-isometric-v1`,
при правке `src/live-*`, `src/render-*`, `houseplan-render-lifecycle.ts`,
`houseplan-card.ts` — `large-house-interaction-v1`, оба по три образца против
абсолютных потолков `hardMaxMs` полных профилей (`budgets-*-smoke.json`).
Это гейт на «в разы», а не «на проценты»: регрессия #160 (первый кадр 9 870 мс
против потолка 3 500) ловится ещё в ревью, а не предрелизным гейтом под тегом.
Классификацию делает `scripts/classify-changes.mjs`, набор профилей входит в
ключ переиспользования `performance_smoke`. Ревьюер по-прежнему принимает
зелёный Validate на SHA как подтверждение дешёвых гейтов — смок его часть.
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
пропуском.
+57 -12
View File
@@ -12,8 +12,9 @@
## Your whole home at a glance
House Plan turns Home Assistant into a live map of your home. Upload a plan or
draw rooms directly on the dashboard, bind them to Home Assistant areas, and
House Plan turns Home Assistant into a live map of your home. Open the dedicated
**House Plan** item in the Home Assistant sidebar, upload a plan or draw rooms,
bind them to Home Assistant areas, and
the area's devices appear automatically. You can immediately see where a light
is on, a door is open, a room is too cold, Zigbee signal is weak, or a leak
sensor has fired.
@@ -54,7 +55,7 @@ sync across screens.
## Your first working room
1. Install the integration and add the card to a dashboard.
1. Install the integration and open **House Plan** in the Home Assistant sidebar.
2. Create the first **space**: upload SVG/PNG/JPG/WebP, reuse an uploaded image,
or choose no image and draw the plan by hand.
3. In Plan, select **Room outline**, place vertices, and click the first point to
@@ -94,17 +95,47 @@ 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 sidebar page and optional dashboard cards are 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
@@ -113,9 +144,23 @@ Copy the complete `custom_components/houseplan` release folder to
integration. Do not copy only `houseplan-card.js`: the card also uses an
internal manifest and content-hashed modules from the same release.
### Add the card
### Open House Plan
Create a dashboard view (Panel works best) and add the card in the UI or as:
After the integration is added, open **House Plan** in the Home Assistant
sidebar. This full-page view is the primary entry point and needs no dashboard
or YAML setup. It remembers the last space but always returns from another HA
page in View rather than reopening an editor. Users without editing permission
see the same live plan without editor controls.
If the sidebar entry cannot be registered, the integration and existing
dashboard cards continue to work; check **Settings → System → Repairs → System
information → House Plan** after restarting and hard-refreshing HA.
### Optional dashboard card
Add the card only when House Plan must be embedded in a dashboard. In a Sections
view it requests full width by default, while a manual size chosen in HA remains
authoritative. Add it in the UI or as:
```yaml
type: custom:houseplan-card
+57 -12
View File
@@ -12,8 +12,9 @@
## Дом целиком — одним взглядом
House Plan превращает Home Assistant в живую карту дома. Загрузите изображение
плана или нарисуйте комнаты прямо на дашборде, свяжите их с зонами Home
House Plan превращает Home Assistant в живую карту дома. Откройте отдельный
пункт **House Plan** в боковом меню Home Assistant, загрузите изображение плана
или нарисуйте комнаты, свяжите их с зонами Home
Assistant — и устройства появятся на плане автоматически. Сразу видно, где
горит свет, открыта дверь, слишком холодно, слабый Zigbee-сигнал или сработал
датчик протечки.
@@ -55,7 +56,7 @@ Assistant — и устройства появятся на плане авто
## Первая рабочая комната
1. Установите интеграцию и добавьте карточку на дашборд.
1. Установите интеграцию и откройте **House Plan** в боковом меню Home Assistant.
2. Создайте первое **пространство**: загрузите SVG/PNG/JPG/WebP либо выберите
вариант без изображения, чтобы нарисовать план вручную.
3. В редакторе «План» выберите **Контур комнаты**, поставьте вершины и замкните
@@ -98,17 +99,47 @@ 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-модуль.
### Вручную
@@ -117,9 +148,23 @@ resources:
House Plan. Одного `houseplan-card.js` недостаточно: карточке также нужны
внутренний манифест и хешированные модули из того же релиза.
### Добавление карточки
### Открытие House Plan
Создайте представление дашборда (лучше Panel) и добавьте карточку через UI либо:
После добавления интеграции откройте **House Plan** в боковом меню Home
Assistant. Это основной полноэкранный способ работы: отдельный дашборд и YAML
не нужны. Запоминается последнее пространство, но после перехода на другую
страницу HA панель всегда возвращается в режим просмотра, а не в редактор.
Пользователь без права редактирования видит тот же живой план без редакторов.
Если пункт бокового меню зарегистрировать не удалось, интеграция и уже
добавленные карточки на дашбордах продолжают работать. После перезапуска и
жёсткого обновления HA проверьте состояние House Plan в системной информации.
### Необязательная карточка для дашборда
Добавляйте карточку, только если план нужно встроить в дашборд. В представлении
Sections она по умолчанию занимает всю ширину; вручную выбранный в HA размер
остаётся главным. Добавьте карточку через UI либо:
```yaml
type: custom:houseplan-card
+31
View File
@@ -0,0 +1,31 @@
# Third-party notices
## VMware Clarity Assets - `compass-line.svg`
Source: <https://github.com/vmware-archive/clarity-assets/blob/bf6bdd0dd3f247f1a320d44d13fecdeda18c071c/icons/travel/compass-line.svg>
Copyright (c) 2018 VMware, Inc.
The MIT license (the "License") set forth below applies to all parts of the
clarity-assets project. You may not use this file except in compliance with the
License.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Binary file not shown.
@@ -0,0 +1,31 @@
# Third-party notices
## VMware Clarity Assets - `compass-line.svg`
Source: <https://github.com/vmware-archive/clarity-assets/blob/bf6bdd0dd3f247f1a320d44d13fecdeda18c071c/icons/travel/compass-line.svg>
Copyright (c) 2018 VMware, Inc.
The MIT license (the "License") set forth below applies to all parts of the
clarity-assets project. You may not use this file except in compliance with the
License.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+31 -107
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,11 +15,17 @@ 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 .panel_registration import (
async_setup_panel_registration,
remove_panel_registration,
)
from .plans import collect_attachments, collect_plans, sweep_upload_temps
from .repairs import async_check_plan_files
from .store import (
@@ -99,49 +104,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
@@ -303,77 +271,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
entry.async_on_unload(
async_track_time_interval(hass, _sweep, timedelta(hours=24))
)
# The application panel is intentionally the final setup step. It is a
# discoverability surface over the already-working stores and APIs, so a
# missing/colliding/broken panel remains fail-soft and cannot strand a
# sidebar entry after a migration, repair or initial housekeeping failure.
panel_path = Path(__file__).parent / "frontend" / "houseplan-panel.js"
await async_setup_panel_registration(hass, entry, panel_path)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
if rec:
rec.teardown()
"""Unload the entry.
WS commands and the HTTP view are global (async_setup) and stay registered —
their handlers resolve runtime data per call and answer `not_ready` while no
entry is loaded. Static paths cannot be unregistered by design.
"""
remove_panel_registration(hass)
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
if rec:
rec.teardown()
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."""
remove_panel_registration(hass)
await async_remove_frontend_registration(hass, entry)
+6 -1
View File
@@ -14,7 +14,12 @@ class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
if user_input is not None:
return self.async_create_entry(title="House Plan", data={}, options=user_input)
return self.async_create_entry(
title="House Plan",
data={},
options=user_input,
description="panel_ready",
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=True): bool}),
+8 -2
View File
@@ -8,6 +8,12 @@ STORAGE_VERSION = 1
STORAGE_MINOR_VERSION = 2
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
FRONTEND_ASSETS_URL = "/houseplan_files/houseplan-assets"
PANEL_FRONTEND_URL = "/houseplan_files/houseplan-panel.js"
PANEL_URL_PATH = "houseplan"
PANEL_URL = f"/{PANEL_URL_PATH}"
PANEL_COMPONENT_NAME = "houseplan-panel"
PANEL_TITLE = "House Plan"
PANEL_ICON = "mdi:floor-plan"
PLANS_URL = "/houseplan_files/plans"
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files"
@@ -51,7 +57,7 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
ASSETS_DIR = "houseplan/assets"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.72.0-beta.1"
VERSION = "1.73.0-beta.6"
DECOR_ASSETS_API_VERSION = 1
# #43: the support transport is deliberately not configurable. A user supplied
@@ -70,7 +76,7 @@ MAX_SUPPORT_CONTACT_CODEPOINTS = 320
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
# user have different compatibility lifecycles.
PLAN_MODEL_VERSION = 9
PLAN_MODEL_VERSION = 10
EXPORT_VERSION = 2
MAX_EXPORT_BYTES = 8 * 1024 * 1024
IMPORT_PREVIEW_TTL_S = 10 * 60
@@ -158,6 +158,8 @@ def canonicalize_config_geometry(config: Any) -> Any:
_lattice_fields(decor, ("x", "y"))
_scalar_fields(decor, ("scale", "angle"))
# Legacy-only read boundary: v9 drafts are canonicalized before the
# wall-model migration turns their edges into current partitions.
for draft in _records(space.get("room_drafts")):
_lattice_points(draft.get("points"))
@@ -31,7 +31,6 @@ async def async_get_config_entry_diagnostics(
"has_plan": bool(s.get("plan_url")),
"rooms": len(s.get("rooms", [])),
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
"room_drafts": len(s.get("room_drafts", [])),
"partitions": len(s.get("partitions", [])),
"wall_columns": len(s.get("wall_columns", [])),
}
@@ -1,169 +1,273 @@
{
"schema": 1,
"fingerprint": "53ac71b2f6ce21ea7cf23cc2a1d9d4f35897a012eca18d4aac6ace3994022019",
"fingerprint": "8a4813c3e7440e71d81cb61ed581bfc8597a880bc223eabf7085d0acf7be0202",
"entry": "houseplan-card.js",
"panelEntry": "houseplan-panel.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-BgNWi9NC.js",
"houseplan-assets/houseplan-card-Duc9J2aR.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 295066,
"initialViewGzipBytes": 290929,
"initialPanelFiles": [
"houseplan-assets/houseplan-card-Duc9J2aR.js",
"houseplan-card.js",
"houseplan-panel.js"
],
"initialPanelGzipBytes": 293204,
"initialPanelOnlyFiles": [
"houseplan-panel.js"
],
"initialPanelOnlyGzipBytes": 2275,
"lazyFiles": [
"houseplan-assets/backdrop-pick-DA2OhjB4.js",
"houseplan-assets/de-DYVDL4UZ.js",
"houseplan-assets/editor-gOvMD27j.js",
"houseplan-assets/fr-BQuWNyOh.js",
"houseplan-assets/houseplan-editor-runtime-BObeFI2c.js",
"houseplan-assets/houseplan-onboarding-runtime-CBT0JFO5.js",
"houseplan-assets/hp-zigbee-topology-overlay-Dp23y7v4.js",
"houseplan-assets/zigbee-topology-Dm2vd-Wv.js",
"houseplan-assets/zigbee-topology-runtime-CZFgtAgE.js"
"houseplan-assets/backdrop-pick-BfY64C-A.js",
"houseplan-assets/de-BCkHkiB5.js",
"houseplan-assets/editor-D8PGN0VZ.js",
"houseplan-assets/fr-DTetoHpc.js",
"houseplan-assets/furniture-plan-art.generated-a9xIuGDF.js",
"houseplan-assets/guard-DMZQ9X4F.js",
"houseplan-assets/houseplan-editor-runtime-BvmYpobh.js",
"houseplan-assets/houseplan-onboarding-runtime-YsKtBNzk.js",
"houseplan-assets/hp-zigbee-topology-overlay-DpLafSXG.js",
"houseplan-assets/iso-scene-render-347DoW0f.js",
"houseplan-assets/live-interaction-runtime-BKx6xnMX.js",
"houseplan-assets/pdf-export-DCr3EZQi.js",
"houseplan-assets/zigbee-topology-INhNMylG.js",
"houseplan-assets/zigbee-topology-runtime-C3PwFKI_.js"
],
"lazyGzipBytes": 234149,
"lazyGzipBytes": 383720,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DA2OhjB4.js",
"houseplan-assets/editor-gOvMD27j.js",
"houseplan-assets/houseplan-editor-runtime-BObeFI2c.js",
"houseplan-assets/zigbee-topology-Dm2vd-Wv.js"
"houseplan-assets/backdrop-pick-BfY64C-A.js",
"houseplan-assets/editor-D8PGN0VZ.js",
"houseplan-assets/furniture-plan-art.generated-a9xIuGDF.js",
"houseplan-assets/guard-DMZQ9X4F.js",
"houseplan-assets/houseplan-editor-runtime-BvmYpobh.js",
"houseplan-assets/zigbee-topology-INhNMylG.js"
],
"lazyEditorGzipBytes": 173953,
"lazyEditorGzipBytes": 195398,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DA2OhjB4.js",
"houseplan-assets/houseplan-onboarding-runtime-CBT0JFO5.js"
"houseplan-assets/backdrop-pick-BfY64C-A.js",
"houseplan-assets/houseplan-onboarding-runtime-YsKtBNzk.js"
],
"lazyOnboardingGzipBytes": 14235,
"lazyOnboardingGzipBytes": 14078,
"lazyLocaleFiles": [
"houseplan-assets/de-DYVDL4UZ.js",
"houseplan-assets/fr-BQuWNyOh.js"
"houseplan-assets/de-BCkHkiB5.js",
"houseplan-assets/fr-DTetoHpc.js"
],
"lazyLocaleGzipBytes": 49100,
"lazyLocaleGzipBytes": 50567,
"lazyIsometricFiles": [
"houseplan-assets/guard-DMZQ9X4F.js",
"houseplan-assets/iso-scene-render-347DoW0f.js"
],
"lazyIsometricGzipBytes": 13455,
"lazyFurnitureArtFiles": [
"houseplan-assets/furniture-plan-art.generated-a9xIuGDF.js"
],
"lazyFurnitureArtGzipBytes": 10253,
"lazyPdfFiles": [
"houseplan-assets/furniture-plan-art.generated-a9xIuGDF.js",
"houseplan-assets/pdf-export-DCr3EZQi.js"
],
"lazyPdfGzipBytes": 120333,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DA2OhjB4.js",
"sha256": "311e9077a8c1df7a1e141916b32b8d41f1f89ab89c7dcb28da442b653b72b014",
"rawBytes": 21418,
"gzipBytes": 7281,
"path": "houseplan-assets/backdrop-pick-BfY64C-A.js",
"sha256": "dc8afca7d127c63374c7d83d6456233c823481afe796c7775ff76a9581dac73a",
"rawBytes": 21012,
"gzipBytes": 7109,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BgNWi9NC.js"
"houseplan-assets/houseplan-card-Duc9J2aR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-DYVDL4UZ.js",
"sha256": "16ab3d88b362b395401ca37a85dc1e5eb2df459db43754d9a9669fe381ccd68a",
"rawBytes": 82182,
"gzipBytes": 24820,
"path": "houseplan-assets/de-BCkHkiB5.js",
"sha256": "ca051daaf982b232476d88ef900b5919b9a6665f3ca4d46dc9e47c3ff0845810",
"rawBytes": 84771,
"gzipBytes": 25538,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-gOvMD27j.js",
"sha256": "dd0ead80dc7fb402a56e8a4f63cd2bd1c483673616d45bf2c30234b20c7fedef",
"rawBytes": 3826,
"gzipBytes": 1580,
"path": "houseplan-assets/editor-D8PGN0VZ.js",
"sha256": "e77bb90ff6ee40321a9bd6a49afd831f3377f33e0424b31d3d9c860bd282545a",
"rawBytes": 3837,
"gzipBytes": 1584,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BgNWi9NC.js"
"houseplan-assets/houseplan-card-Duc9J2aR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-BQuWNyOh.js",
"sha256": "d25b21cf34fda70e65edea471a4643343591ca36fe273878423fd74969a17649",
"rawBytes": 84312,
"gzipBytes": 24280,
"path": "houseplan-assets/fr-DTetoHpc.js",
"sha256": "fdd4eb2dbedffde5fe2d1d0fcae4bb36ee5b9b0cda9dc532a4257c412904c53c",
"rawBytes": 86828,
"gzipBytes": 25029,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-BgNWi9NC.js",
"sha256": "1c1e542ec2eed4382b3b9cc5e1f7bf36e5289273fa3c4b78b1dc49243d31336e",
"rawBytes": 1047873,
"gzipBytes": 294272,
"path": "houseplan-assets/furniture-plan-art.generated-a9xIuGDF.js",
"sha256": "4c1de41f496c79aacf341e70ed983edb641bccf9f3a498521a456d3ac715c50e",
"rawBytes": 33655,
"gzipBytes": 10253,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/guard-DMZQ9X4F.js",
"sha256": "58efffcc2771d139912709937ae98ca3bcccd058774934f5eed1914f6f7dfb53",
"rawBytes": 518,
"gzipBytes": 368,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Duc9J2aR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-Duc9J2aR.js",
"sha256": "8f7dedc243700c7236e3e5a44e46d85f3626d6933759dbda46f05108b0b57c2b",
"rawBytes": 1045917,
"gzipBytes": 290127,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-DYVDL4UZ.js",
"houseplan-assets/editor-gOvMD27j.js",
"houseplan-assets/fr-BQuWNyOh.js",
"houseplan-assets/houseplan-editor-runtime-BObeFI2c.js",
"houseplan-assets/houseplan-onboarding-runtime-CBT0JFO5.js",
"houseplan-assets/hp-zigbee-topology-overlay-Dp23y7v4.js"
"houseplan-assets/de-BCkHkiB5.js",
"houseplan-assets/editor-D8PGN0VZ.js",
"houseplan-assets/fr-DTetoHpc.js",
"houseplan-assets/furniture-plan-art.generated-a9xIuGDF.js",
"houseplan-assets/houseplan-editor-runtime-BvmYpobh.js",
"houseplan-assets/houseplan-onboarding-runtime-YsKtBNzk.js",
"houseplan-assets/hp-zigbee-topology-overlay-DpLafSXG.js",
"houseplan-assets/iso-scene-render-347DoW0f.js",
"houseplan-assets/live-interaction-runtime-BKx6xnMX.js",
"houseplan-assets/pdf-export-DCr3EZQi.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-BObeFI2c.js",
"sha256": "de43beb5ec24ab5477cf8aed38574a8625e2b79cb44a10269a6235f2573f7fdd",
"rawBytes": 608951,
"gzipBytes": 160248,
"path": "houseplan-assets/houseplan-editor-runtime-BvmYpobh.js",
"sha256": "35ed0163e57788ee86e9e541e2cf7db02b940af33798d86aadff28acade1652d",
"rawBytes": 633719,
"gzipBytes": 168660,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DA2OhjB4.js",
"houseplan-assets/houseplan-card-BgNWi9NC.js",
"houseplan-assets/zigbee-topology-Dm2vd-Wv.js"
"houseplan-assets/backdrop-pick-BfY64C-A.js",
"houseplan-assets/furniture-plan-art.generated-a9xIuGDF.js",
"houseplan-assets/guard-DMZQ9X4F.js",
"houseplan-assets/houseplan-card-Duc9J2aR.js",
"houseplan-assets/zigbee-topology-INhNMylG.js"
],
"dynamicImports": [
"houseplan-assets/zigbee-topology-runtime-CZFgtAgE.js"
"houseplan-assets/zigbee-topology-runtime-C3PwFKI_.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-CBT0JFO5.js",
"sha256": "040689090c5232b0fda016cf5866a21d0203523015dac062b6b7d072b9c7f188",
"rawBytes": 28088,
"gzipBytes": 6954,
"path": "houseplan-assets/houseplan-onboarding-runtime-YsKtBNzk.js",
"sha256": "5fedf430826ec097c618e3bc3569c78c51a151e853d55cf3cfa48d48d71fb81b",
"rawBytes": 28075,
"gzipBytes": 6969,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DA2OhjB4.js",
"houseplan-assets/houseplan-card-BgNWi9NC.js"
"houseplan-assets/backdrop-pick-BfY64C-A.js",
"houseplan-assets/houseplan-card-Duc9J2aR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/hp-zigbee-topology-overlay-Dp23y7v4.js",
"sha256": "ee382f9dc514051bc974bde8d0c2238c137a2713f7fbad1a46f9660f78140242",
"rawBytes": 6012,
"gzipBytes": 2381,
"path": "houseplan-assets/hp-zigbee-topology-overlay-DpLafSXG.js",
"sha256": "948b81fc8cd1dbd686df531efdd3ec8ed4eca0cf7f4c2abdd550700ed52831c8",
"rawBytes": 11034,
"gzipBytes": 3729,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BgNWi9NC.js",
"houseplan-assets/zigbee-topology-Dm2vd-Wv.js",
"houseplan-assets/zigbee-topology-runtime-CZFgtAgE.js"
"houseplan-assets/houseplan-card-Duc9J2aR.js",
"houseplan-assets/zigbee-topology-INhNMylG.js",
"houseplan-assets/zigbee-topology-runtime-C3PwFKI_.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-Dm2vd-Wv.js",
"sha256": "ef26620e3d4ee2fe40f461498ec074996a602445b14d5d3d7dd635edfa69efa0",
"rawBytes": 12382,
"gzipBytes": 4844,
"path": "houseplan-assets/iso-scene-render-347DoW0f.js",
"sha256": "17b1e80e0927a19ab1272b4e398ae10ab60f7e133904edc8ff4909cfb25fea96",
"rawBytes": 38562,
"gzipBytes": 13087,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BgNWi9NC.js"
"houseplan-assets/guard-DMZQ9X4F.js",
"houseplan-assets/houseplan-card-Duc9J2aR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-runtime-CZFgtAgE.js",
"sha256": "f5d307d2c163f7e26c2540ffa8345cd1f0b5395d210c71a59649f4a493c5aacf",
"path": "houseplan-assets/live-interaction-runtime-BKx6xnMX.js",
"sha256": "233a0fa667f5a53fdd65179a74be44d39d9607927f3c220d6f6ae9770dee23d1",
"rawBytes": 5196,
"gzipBytes": 2127,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Duc9J2aR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/pdf-export-DCr3EZQi.js",
"sha256": "ee45829a7a26ca7c3982181823f7bf80d939bdaf645f38ba5e07d844beeb9bec",
"rawBytes": 237163,
"gzipBytes": 110080,
"isEntry": false,
"imports": [
"houseplan-assets/furniture-plan-art.generated-a9xIuGDF.js",
"houseplan-assets/houseplan-card-Duc9J2aR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-INhNMylG.js",
"sha256": "48b568a451b477f2bc6061951803ea54c510e8f4913d54d2f92783ee24b80692",
"rawBytes": 18961,
"gzipBytes": 7424,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Duc9J2aR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-runtime-C3PwFKI_.js",
"sha256": "54ebcbf2de61c75f57ae825b1f1a58f5a5e3c158b6aceb7e5965791311230826",
"rawBytes": 3941,
"gzipBytes": 1761,
"gzipBytes": 1763,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BgNWi9NC.js",
"houseplan-assets/zigbee-topology-Dm2vd-Wv.js"
"houseplan-assets/houseplan-card-Duc9J2aR.js",
"houseplan-assets/zigbee-topology-INhNMylG.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "b8beb25d0d36a897e2a7a773d7a062a79aa8f8ef92ff145334f4ac4655e237a1",
"rawBytes": 1183,
"gzipBytes": 794,
"sha256": "92f7259a874eaf87c8e9ce73e6ad856267d674314c626d8181eeb7587db6b79c",
"rawBytes": 1205,
"gzipBytes": 802,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-BgNWi9NC.js"
"houseplan-assets/houseplan-card-Duc9J2aR.js"
],
"dynamicImports": []
},
{
"path": "houseplan-panel.js",
"sha256": "6de660ea8288d042ea175475c4ee71247d85b747c5e7e337f29732fcbbedfe1f",
"rawBytes": 6004,
"gzipBytes": 2275,
"isEntry": true,
"imports": [
"houseplan-card.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
@@ -0,0 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="8a4813c3e7440e71d81cb61ed581bfc8597a880bc223eabf7085d0acf7be0202";import{t as e,l as o,x as t,e3 as s,A as a,e4 as i,e5 as l,e6 as n,dU as r,b as c}from"./houseplan-card-Duc9J2aR.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);
@@ -1,14 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="53ac71b2f6ce21ea7cf23cc2a1d9d4f35897a012eca18d4aac6ace3994022019";import{k as e,l as o,t,dq as s,A as a,dr as i,ds as l,dt as r,E as n,c}from"./houseplan-card-BgNWi9NC.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 r="string"==typeof this._config?.default_floor?this._config.default_floor:"",n=[...e];return r&&!e.some(e=>e.value===r)&&n.unshift({value:r,label:r}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:n}}}:{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 r();if("warm"===e)return n;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
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="8a4813c3e7440e71d81cb61ed581bfc8597a880bc223eabf7085d0acf7be0202";import{dS as r,dT as t,dU as e}from"./houseplan-card-Duc9J2aR.js";const s={},a=r(class extends t{constructor(){super(...arguments),this.ot=s}render(r,t){return t()}update(r,[t,s]){if(Array.isArray(t)){if(Array.isArray(this.ot)&&this.ot.length===t.length&&t.every((r,t)=>r===this.ot[t]))return e}else if(this.ot===t)return e;return this.ot=Array.isArray(t)?Array.from(t):t,this.render(t,s)}});export{a as i};
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,36 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="53ac71b2f6ce21ea7cf23cc2a1d9d4f35897a012eca18d4aac6ace3994022019";import{k as e,A as t,du as i,aw as s,c as o,l as r,j as a}from"./houseplan-card-BgNWi9NC.js";import{a as n,r as h,t as d}from"./zigbee-topology-Dm2vd-Wv.js";import{zigbeeTopologyRuntimeSnapshot as p,subscribeZigbeeTopology as c}from"./zigbee-topology-runtime-CZFgtAgE.js";const l={revision:0,topologies:[],states:{}};class v extends e{constructor(){super(...arguments),this.devices=[],this.currentSpace="",this._runtime=l,this._hovered="",this._pointerOver=e=>{if(!this._mouseAllowed(e))return void this._clear();const t=this._deviceFromEvent(e),i=t?.dataset.id||"";i&&i!==this._hovered&&(this._hovered=i,this.requestUpdate())},this._pointerOut=e=>{const t=this._deviceFromEvent(e);if(!t||t.dataset.id!==this._hovered)return;const i=e.relatedTarget instanceof Element?e.relatedTarget.closest('[data-hp="device"]'):null;i?.dataset.id!==this._hovered&&this._clear()},this._pointerDown=e=>{"mouse"!==e.pointerType&&this._clear()}}connectedCallback(){super.connectedCallback();const e=this.getRootNode();e.host&&"undefined"!=typeof MutationObserver&&(this._hoverGateObserver=new MutationObserver(()=>{e.host.hasAttribute("data-pointer-hover")||this._clear()}),this._hoverGateObserver.observe(e.host,{attributes:!0,attributeFilter:["data-pointer-hover"]})),queueMicrotask(()=>this._connectParent())}disconnectedCallback(){this._release?.(),this._release=void 0,this._disconnectParent(),this._hoverGateObserver?.disconnect(),this._hoverGateObserver=void 0,this._hovered="",super.disconnectedCallback()}updated(e){e.has("hass")&&(this._release?.(),this._runtime=p(this.hass),this._mappedMemo=void 0,this._release=c(this.hass,()=>{this._runtime=p(this.hass),this._mappedMemo=void 0,this.requestUpdate()})),(e.has("currentSpace")||e.has("devices")||e.has("registry"))&&(this._mappedMemo=void 0,this.devices.some(e=>e.id===this._hovered&&e.space===this.currentSpace)||(this._hovered=""))}_connectParent(){const e=this.parentElement;e&&e!==this._parent&&(this._disconnectParent(),this._parent=e,e.addEventListener("pointerover",this._pointerOver),e.addEventListener("pointerout",this._pointerOut),e.addEventListener("pointerdown",this._pointerDown,!0))}_disconnectParent(){this._parent?.removeEventListener("pointerover",this._pointerOver),this._parent?.removeEventListener("pointerout",this._pointerOut),this._parent?.removeEventListener("pointerdown",this._pointerDown,!0),this._parent=void 0}_deviceFromEvent(e){for(const t of e.composedPath())if(t instanceof HTMLElement&&"device"===t.dataset.hp)return t;return null}_mouseAllowed(e){const t=this.getRootNode();return"mouse"===e.pointerType&&t.host?.hasAttribute?.("data-pointer-hover")}_clear(){this._hovered&&(this._hovered="",this.requestUpdate())}_position(e){const t=[...this._parent?.querySelectorAll(".dev[data-id]")||[]].find(t=>t.dataset.id===e);if(!t)return null;const i=Number.parseFloat(t.style.left),s=Number.parseFloat(t.style.top),o=t.getBoundingClientRect();return Number.isFinite(i)&&Number.isFinite(s)?{x:i,y:s,width:o.width,height:o.height}:null}render(){if(!this._hovered||!this.registry||!this._runtime.topologies.length)return t;const e=this._mappedMemo,a=e&&e.runtime===this._runtime&&e.devices===this.devices&&e.registry===this.registry?e.mapped:n(this._runtime.topologies,this.devices,this.registry);e&&a===e.mapped||(this._mappedMemo={runtime:this._runtime,devices:this.devices,registry:this.registry,mapped:a});const p=h(a,this.currentSpace,this._hovered),c=this._position(this._hovered);if(!c)return t;const l=p.lines.map(e=>({...e,point:this._position(e.neighborMarkerId)})).filter(e=>!!e.point);return o`
${l.length?s`<svg viewBox="0 0 100 100" preserveAspectRatio="none"
aria-hidden="true" data-hp="zigbee-topology-lines">
${l.map(e=>s`<line x1=${c.x} y1=${c.y}
x2=${e.point.x} y2=${e.point.y}
stroke=${void 0===e.lqi?"rgba(145,155,165,.85)":i(e.lqi)}
stroke-width=${void 0===e.lqi?2:2.2}
stroke-dasharray=${void 0===e.lqi?"5 5":t} opacity=".9"></line>`)}
</svg>`:t}
${l.map(e=>o`<div class="halo" data-hp="zigbee-topology-neighbor"
data-id=${e.neighborMarkerId} style="left:${e.point.x}%;top:${e.point.y}%;width:${1.22*e.point.width}px;height:${1.22*e.point.height}px"></div>`)}
${p.remoteCount?o`<div class="remote" data-hp="zigbee-topology-remote"
style="left:${c.x}%;top:${c.y}%">${d(r(this.hass),"remote_count",{n:p.remoteCount})}</div>`:t}
`}}v.properties={hass:{attribute:!1},devices:{attribute:!1},registry:{attribute:!1},currentSpace:{type:String,attribute:"current-space"},viewKey:{attribute:!1}},v.styles=a`
:host { position: absolute; inset: 0; z-index: 5; display: block; pointer-events: none; }
svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
line { vector-effect: non-scaling-stroke; stroke-linecap: round; }
.halo {
position: absolute; width: calc(var(--device-base-size, 4cqw) * 1.22);
height: calc(var(--device-base-size, 4cqw) * 1.22); transform: translate(-50%, -50%);
box-sizing: border-box; border: 2px solid rgba(120, 190, 220, .82); border-radius: 50%;
box-shadow: 0 0 0 4px rgba(120, 190, 220, .16);
}
.remote {
position: absolute; transform: translate(12px, calc(-100% - 12px));
border: 1px solid rgba(255,255,255,.7); border-radius: 999px;
padding: 3px 7px; color: #fff; background: rgba(28,31,36,.88);
box-shadow: 0 2px 7px rgba(0,0,0,.28); white-space: nowrap;
font: 600 11px/1.2 system-ui, sans-serif;
}
@media (forced-colors: active) {
line { stroke: Highlight !important; }
.halo { border-color: Highlight; box-shadow: none; }
.remote { color: CanvasText; background: Canvas; border-color: CanvasText; }
}
`,customElements.get("hp-zigbee-topology-overlay")||customElements.define("hp-zigbee-topology-overlay",v);export{v as HpZigbeeTopologyOverlay};
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
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="53ac71b2f6ce21ea7cf23cc2a1d9d4f35897a012eca18d4aac6ace3994022019";import{n as t,b as n}from"./zigbee-topology-Dm2vd-Wv.js";import{y as e}from"./houseplan-card-BgNWi9NC.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 p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(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(p(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,f=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=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 p=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&&f?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),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{f as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="8a4813c3e7440e71d81cb61ed581bfc8597a880bc223eabf7085d0acf7be0202";import{n as t,b as n}from"./zigbee-topology-INhNMylG.js";import{F as e}from"./houseplan-card-Duc9J2aR.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 p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(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(p(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,f=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=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 p=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&&f?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),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{f as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="53ac71b2f6ce21ea7cf23cc2a1d9d4f35897a012eca18d4aac6ace3994022019";try{await import("./houseplan-assets/houseplan-card-BgNWi9NC.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__="8a4813c3e7440e71d81cb61ed581bfc8597a880bc223eabf7085d0acf7be0202";try{await import("./houseplan-assets/houseplan-card-Duc9J2aR.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 houseplan-card.js: the implementation chunk is unavailable",e)}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,715 @@
"""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 is ready"
_NOTICE_DESCRIPTION_FALLBACK = (
"Open House Plan from the sidebar. After an update, fully reload the page "
"to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` "
"on macOS). The dashboard card remains available; if you manage its "
"resource manually in storage mode, open Settings → Dashboards → "
"Resources. If the sidebar item is missing, check House Plan in System Health."
)
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(frozen=True, slots=True)
class StaticPathRegistrationOutcome:
"""Result of registering one exact public frontend URL."""
registered: bool
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 _static_registered_urls(hass: HomeAssistant) -> set[str]:
"""Return the exact static URLs registered for this Core process.
Before the panel entry existed this state was a boolean. A legacy ``True``
proves only that the card URL was registered; treating it as a generic
truthy flag would incorrectly skip the panel route after an integration
reload.
"""
domain_data = hass.data.setdefault(DOMAIN, {})
value = domain_data.get(FRONTEND_STATIC_REGISTERED_KEY)
if value is True:
urls = {FRONTEND_URL}
domain_data[FRONTEND_STATIC_REGISTERED_KEY] = urls
return urls
if isinstance(value, set):
return value
if isinstance(value, dict):
urls = {url for url, registered in value.items() if registered is True}
domain_data[FRONTEND_STATIC_REGISTERED_KEY] = urls
return urls
return set()
async def async_register_frontend_static_path(
hass: HomeAssistant,
frontend_url: str,
file_path: Path,
) -> StaticPathRegistrationOutcome:
"""Register one exact bundle entry across supported Home Assistant APIs."""
registered_urls = _static_registered_urls(hass)
if frontend_url in registered_urls:
return StaticPathRegistrationOutcome(True)
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(file_path), cache_headers=False
)
else:
await hass.http.async_register_static_paths(
[StaticPathConfig(frontend_url, str(file_path), cache_headers=False)]
)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001 - callers intentionally fail soft
return StaticPathRegistrationOutcome(
False, _safe_error("static_path", err)
)
registered_urls.add(frontend_url)
hass.data.setdefault(DOMAIN, {})[
FRONTEND_STATIC_REGISTERED_KEY
] = registered_urls
return StaticPathRegistrationOutcome(True)
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 card_file_present:
static_outcome = await async_register_frontend_static_path(
hass, FRONTEND_URL, card_path
)
state.static_path_registered = static_outcome.registered
if not static_outcome.registered:
state.last_error = static_outcome.last_error
_LOGGER.warning(
"Could not register the House Plan card static path (%s)",
static_outcome.last_error or "static_path:UnknownError",
)
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)
+29 -54
View File
@@ -70,8 +70,7 @@ _LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
_LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$")
_PLAN_ONLY_DASH = "—"
_IMPORT_ID_NAMESPACES = {
"space", "room", "marker", "partition", "wall", "opening", "decor", "draft",
"draft_segment", "column",
"space", "room", "marker", "partition", "wall", "opening", "decor", "column",
}
_MAX_IMPORT_LINEAGE_DEPTH = 16
_REPORT_EXAMPLE_LIMIT = 24
@@ -97,7 +96,7 @@ _SPACE_DISPLAY_FIELDS = (
)
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly", "wall_ids")
_ROOM_DISPLAY_FIELDS = (
"fill_mode", "custom_fill", "glow", "name_scale", "label_scale",
"fill_mode", "custom_fill", "glow", "temp_min", "temp_max", "name_scale", "label_scale",
)
_DECOR_COMMON_FIELDS = ("id", "kind", "color", "opacity", "width_cm", "width")
_DECOR_KIND_FIELDS = {
@@ -266,7 +265,6 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
collections: tuple[tuple[str, tuple[str, ...]], ...] = (
("walls", ("key", "cm", "a", "b")),
("wall_segments", ("id", "a", "b", "cm")),
("room_drafts", ("id", "points", "segments")),
("partitions", ("id", "a", "b", "cm")),
("wall_columns", ("id", "shape", "center", "cm", "angle")),
("open_spans", ("a", "b")),
@@ -277,11 +275,6 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
values = []
for item in space.get(name) or []:
selected = _pick_fields(item, fields)
if name == "room_drafts" and "segments" in selected:
selected["segments"] = [
_pick_fields(segment, ("id", "cm"))
for segment in selected.get("segments") or []
]
values.append(selected)
projected[name] = values
if "openings" in space:
@@ -511,7 +504,16 @@ def create_export(
# Read before voluptuous coercions: bool is an int subclass and must not
# become a plausible model 1 in an otherwise valid export.
model_version = _stored_model_version(raw_config)
config = CONFIG_SCHEMA(raw_config)
if model_version > PLAN_MODEL_VERSION:
raise ImportFailure("future_model", "The stored plan uses a newer data model")
try:
raw_config["model_version"] = model_version
config, _ = commit_wall_segment_model(raw_config)
config = CONFIG_SCHEMA(config)
except (WallSegmentMigrationError, vol.Invalid) as err:
code = err.code if isinstance(err, WallSegmentMigrationError) else "invalid_config"
raise ImportFailure(code, str(err)) from err
model_version = PLAN_MODEL_VERSION
# Compatibility belongs to the envelope. Keeping this field inside the
# payload would either duplicate it or tempt an exporter to silently stamp
# the current version over an older/future stored model.
@@ -1278,7 +1280,7 @@ def build_space_merge(
str(value)
for sp in current_config.get("spaces") or []
for collection in (
(sp,), sp.get("rooms") or [], sp.get("room_drafts") or [],
(sp,), sp.get("rooms") or [],
sp.get("partitions") or [], sp.get("wall_segments") or [],
sp.get("wall_columns") or [],
sp.get("openings") or [], sp.get("decor") or [],
@@ -1290,18 +1292,11 @@ def build_space_merge(
} | {
str(m.get("id")) for m in current_config.get("markers") or []
if m.get("id") is not None
} | {
str(segment.get("id"))
for sp in current_config.get("spaces") or []
for draft in sp.get("room_drafts") or []
for segment in draft.get("segments") or []
if isinstance(segment, dict) and segment.get("id") is not None
} | {
str(value)
for incoming_space in spaces
for collection in (
(incoming_space,), incoming_space.get("rooms") or [],
incoming_space.get("room_drafts") or [],
incoming_space.get("partitions") or [], incoming_space.get("wall_segments") or [],
incoming_space.get("wall_columns") or [],
incoming_space.get("openings") or [], incoming_space.get("decor") or [],
@@ -1313,12 +1308,6 @@ def build_space_merge(
} | {
str(m.get("id")) for m in incoming.get("markers") or []
if m.get("id") is not None
} | {
str(segment.get("id"))
for incoming_space in spaces
for draft in incoming_space.get("room_drafts") or []
for segment in draft.get("segments") or []
if isinstance(segment, dict) and segment.get("id") is not None
}
old_space_id = str(space.get("id"))
_record_bounded_lineage(
@@ -1331,7 +1320,7 @@ def build_space_merge(
}
id_maps["space"][old_space_id] = new_space_id
for collection, prefix in (
("rooms", "room"), ("room_drafts", "draft"), ("partitions", "partition"),
("rooms", "room"), ("partitions", "partition"),
("wall_segments", "wall"),
("wall_columns", "column"), ("openings", "opening"), ("decor", "decor"),
):
@@ -1344,18 +1333,6 @@ def build_space_merge(
id_map[old] = _fresh(prefix, old, used)
id_maps[prefix][old] = id_map[old]
item["id"] = id_map[old]
for draft in space.get("room_drafts") or []:
for segment in draft.get("segments") or []:
if not isinstance(segment, dict) or segment.get("id") is None:
continue
old = str(segment["id"])
_record_bounded_lineage(
reference_report, bounded_seen, "draft_segment", old,
)
new = _fresh("draft_segment", old, used)
id_map[old] = new
id_maps["draft_segment"][old] = new
segment["id"] = new
old_room_ids = id_maps["room"]
for room in space.get("rooms") or []:
if room.get("open_to"):
@@ -1798,21 +1775,20 @@ def _materialize_import_candidate(
if confirmation_required:
_detach_missing(imported_config, content)
incoming_model = int(prepared.get("model_version", 0) or 0)
target_model = int(current_config.get("model_version", 0) or 0)
requires_v8 = incoming_model >= 8 or target_model >= 8
if requires_v8:
try:
# A v8 target cannot be downgraded by a v7 backup, while an
# incoming v8 graph must retain its already-persisted IDs. When
# both sides are v7 the accepted compatibility contract keeps the
# import v7 until a later structural write or explicit Optimize.
imported_config["model_version"] = incoming_model
imported_config, _ = commit_wall_segment_model(imported_config)
prepared["payload"]["config"] = imported_config
if prepared["kind"] == "space":
current_config, _ = commit_wall_segment_model(current_config)
except WallSegmentMigrationError as err:
raise ImportFailure(err.code, str(err)) from err
migration: dict[str, int] = {}
try:
# Import preview and apply operate only on the current materialized
# wall model. Legacy drafts are converted before copy/remap, so no
# current merge path can recreate their carrier.
imported_config["model_version"] = incoming_model
imported_config, _ = commit_wall_segment_model(
imported_config, migration_report=migration,
)
prepared["payload"]["config"] = imported_config
if prepared["kind"] == "space":
current_config, _ = commit_wall_segment_model(current_config)
except WallSegmentMigrationError as err:
raise ImportFailure(err.code, str(err)) from err
if prepared["kind"] == "space":
config, layout, details = build_space_merge(
prepared, current_config, current_layout, duplicate_policy,
@@ -1820,8 +1796,6 @@ def _materialize_import_candidate(
)
else:
config = imported_config
if not requires_v8 and incoming_model > 0:
config["model_version"] = incoming_model
_materialize_global_background(config)
layout = _json_copy(prepared["payload"]["layout"])
if not same_source:
@@ -1841,6 +1815,7 @@ def _materialize_import_candidate(
except vol.Invalid as err:
raise ImportFailure("invalid_layout", str(err)) from err
details = _json_copy(details)
details["migration"] = migration
details["counts"] = _counts(config, layout)
return config, layout, details
+1 -14
View File
@@ -97,7 +97,7 @@ def cm_to_units(cm: float, cell_cm: float, grid_pitch: float = GRID_STEP_N) -> f
def limit_segments(space: dict[str, Any]) -> list[dict[str, Any]]:
"""Every wall the limits judge: contour atoms, partitions, draft segments."""
"""Every current wall the limits judge: contour atoms and partitions."""
segments: list[dict[str, Any]] = []
for segment in space.get("wall_segments") or []:
if _finite_point(segment.get("a")) and _finite_point(segment.get("b")):
@@ -113,19 +113,6 @@ def limit_segments(space: dict[str, Any]) -> list[dict[str, Any]]:
"a": partition["a"], "b": partition["b"],
"cm": float(partition.get("cm") or 0),
})
for draft in space.get("room_drafts") or []:
points = draft.get("points") or []
drafted = draft.get("segments") or []
for index in range(max(len(points) - 1, 0)):
if not (_finite_point(points[index]) and _finite_point(points[index + 1])):
continue
piece = drafted[index] if index < len(drafted) else {}
segments.append({
"id": str((piece or {}).get("id")
or f"{draft.get('id') or 'draft'}-{index}"),
"a": points[index], "b": points[index + 1],
"cm": float((piece or {}).get("cm") or 0),
})
return [segment for segment in segments
if _length(segment["a"], segment["b"]) > _EPS]
+5 -1
View File
@@ -1,6 +1,9 @@
{
"domain": "houseplan",
"name": "House Plan",
"after_dependencies": [
"lovelace"
],
"codeowners": [
"@Matysh"
],
@@ -8,6 +11,7 @@
"dependencies": [
"http",
"frontend",
"panel_custom",
"websocket_api"
],
"documentation": "https://github.com/Matysh/houseplan-card",
@@ -16,5 +20,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.72.0-beta.1"
"version": "1.73.0-beta.6"
}
@@ -0,0 +1,241 @@
"""Register the House Plan application panel with strict lifecycle ownership."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Mapping
from dataclasses import dataclass, field
from functools import partial
from pathlib import Path
from typing import Literal
from homeassistant.components import frontend, panel_custom
from homeassistant.core import HomeAssistant, callback
from .const import (
DOMAIN,
PANEL_COMPONENT_NAME,
PANEL_FRONTEND_URL,
PANEL_ICON,
PANEL_TITLE,
PANEL_URL,
PANEL_URL_PATH,
VERSION,
)
from .frontend_registration import async_register_frontend_static_path
from .store import HouseplanConfigEntry
_LOGGER = logging.getLogger(__name__)
PANEL_REGISTRATION_KEY = "panel_registration"
PANEL_GENERATION_KEY = "panel_generation"
PanelStatus = Literal[
"not_attempted",
"registered",
"missing_asset",
"collision",
"static_error",
"registration_error",
"ownership_error",
"removed",
]
@dataclass(slots=True)
class PanelRegistrationState:
"""Observable state for one config-entry setup generation."""
panel_file_present: bool
generation: int
panel_static_path_registered: bool = False
panel_status: PanelStatus = "not_attempted"
panel_url: str | None = None
panel_module_url: str | None = None
panel_error: str | None = None
_owned_panel: object | None = field(default=None, repr=False)
def _safe_error(phase: str, err: BaseException) -> str:
"""Return a support-safe fingerprint without an exception message."""
return f"{phase}:{type(err).__name__}"
def _panel_registry(hass: HomeAssistant) -> Mapping[str, object] | None:
"""Read HA's in-memory panel registry without mutating private storage."""
key = getattr(frontend, "DATA_PANELS", None)
if not isinstance(key, str):
return None
panels = hass.data.get(key)
return panels if isinstance(panels, Mapping) else None
def _is_current(hass: HomeAssistant, state: PanelRegistrationState) -> bool:
"""Return whether state still owns the current setup generation."""
domain_data = hass.data.get(DOMAIN, {})
return (
domain_data.get(PANEL_REGISTRATION_KEY) is state
and domain_data.get(PANEL_GENERATION_KEY) == state.generation
)
@callback
def _remove_owned_panel(hass: HomeAssistant, state: PanelRegistrationState) -> None:
"""Remove only the exact registry object owned by this generation."""
if not _is_current(hass, state) or state._owned_panel is None:
return
panels = _panel_registry(hass)
if panels is None:
# An unknown registry shape is not authority to remove a route by name.
return
if panels.get(PANEL_URL_PATH) is not state._owned_panel:
# The route was removed or replaced outside this integration. It is no
# longer ours, and a later cleanup must not reconsider that identity.
state._owned_panel = None
state.panel_status = "removed"
state.panel_url = None
state.panel_module_url = None
return
try:
# Keep the HA 2024.6-compatible two-argument call. In particular, do
# not pass the newer warn_if_unknown keyword.
frontend.async_remove_panel(hass, PANEL_URL_PATH)
except Exception as err: # noqa: BLE001 - unload remains fail-soft
fingerprint = _safe_error("remove", err)
state.panel_error = fingerprint
_LOGGER.warning("Could not remove the owned House Plan panel (%s)", fingerprint)
return
state._owned_panel = None
state.panel_status = "removed"
state.panel_url = None
state.panel_module_url = None
@callback
def remove_panel_registration(hass: HomeAssistant) -> None:
"""Idempotently clean up the current owned panel, if one still exists."""
state = get_panel_registration_state(hass)
if state is not None:
_remove_owned_panel(hass, state)
async def async_setup_panel_registration(
hass: HomeAssistant,
entry: HouseplanConfigEntry,
panel_path: Path,
) -> PanelRegistrationState:
"""Register the panel last without making integration setup depend on it."""
domain_data = hass.data.setdefault(DOMAIN, {})
previous = domain_data.get(PANEL_REGISTRATION_KEY)
if isinstance(previous, PanelRegistrationState):
# Normally entry unload already did this. Keeping setup self-contained
# also makes a direct repeated setup deterministic and duplicate-free.
_remove_owned_panel(hass, previous)
old_generation = domain_data.get(PANEL_GENERATION_KEY)
generation = old_generation + 1 if isinstance(old_generation, int) else 1
domain_data[PANEL_GENERATION_KEY] = generation
panel_file_present = panel_path.is_file()
state = PanelRegistrationState(
panel_file_present=panel_file_present,
generation=generation,
)
domain_data[PANEL_REGISTRATION_KEY] = state
if not panel_file_present:
state.panel_status = "missing_asset"
_LOGGER.warning("houseplan-panel.js was not found next to the integration")
return state
static_outcome = await async_register_frontend_static_path(
hass, PANEL_FRONTEND_URL, panel_path
)
state.panel_static_path_registered = static_outcome.registered
if not static_outcome.registered:
state.panel_status = "static_error"
state.panel_error = static_outcome.last_error
_LOGGER.warning(
"Could not register the House Plan panel static path (%s)",
static_outcome.last_error or "static_path:UnknownError",
)
return state
module_url = f"{PANEL_FRONTEND_URL}?v={VERSION}"
try:
await panel_custom.async_register_panel(
hass=hass,
frontend_url_path=PANEL_URL_PATH,
webcomponent_name=PANEL_COMPONENT_NAME,
sidebar_title=PANEL_TITLE,
sidebar_icon=PANEL_ICON,
module_url=module_url,
embed_iframe=False,
trust_external=False,
require_admin=False,
)
except asyncio.CancelledError:
raise
except ValueError as err:
state.panel_error = _safe_error("registration", err)
panels = _panel_registry(hass)
if panels is not None and PANEL_URL_PATH in panels:
state.panel_status = "collision"
_LOGGER.warning(
"House Plan cannot register /%s because that panel route is "
"already in use; open House Plan in System Health for details",
PANEL_URL_PATH,
)
else:
state.panel_status = "registration_error"
_LOGGER.warning(
"Could not register the House Plan panel (%s)", state.panel_error
)
return state
except Exception as err: # noqa: BLE001 - the card/backend remain available
state.panel_status = "registration_error"
state.panel_error = _safe_error("registration", err)
_LOGGER.warning(
"Could not register the House Plan panel (%s)", state.panel_error
)
return state
panels = _panel_registry(hass)
owned_panel = panels.get(PANEL_URL_PATH) if panels is not None else None
if owned_panel is None:
# The public register API has no ownership return value. If the exact
# object cannot be captured now, leaving a route until a later blind
# cleanup would be unsafe. Remove the just-created route immediately.
state.panel_status = "ownership_error"
state.panel_error = "ownership:Unavailable"
try:
frontend.async_remove_panel(hass, PANEL_URL_PATH)
except Exception as err: # noqa: BLE001 - setup must still succeed
_LOGGER.warning(
"Could not clean up an unowned House Plan panel (%s)",
_safe_error("ownership_remove", err),
)
_LOGGER.warning(
"House Plan panel ownership could not be verified; the panel was removed"
)
return state
state._owned_panel = owned_panel
state.panel_status = "registered"
state.panel_url = PANEL_URL
state.panel_module_url = module_url
entry.async_on_unload(partial(_remove_owned_panel, hass, state))
return state
@callback
def get_panel_registration_state(
hass: HomeAssistant,
) -> PanelRegistrationState | None:
"""Return the current panel state for System Health."""
state = hass.data.get(DOMAIN, {}).get(PANEL_REGISTRATION_KEY)
return state if isinstance(state, PanelRegistrationState) else None
+25
View File
@@ -6,6 +6,9 @@
"data": { "admin_only": "Only administrators may edit the layout" }
}
},
"create_entry": {
"panel_ready": "Open House Plan from the sidebar when the item is available. A desktop browser is recommended for editing. If the item is missing, use the House Plan dashboard card and check System Health."
},
"abort": { "single_instance_allowed": "Already configured — only one entry is allowed." }
},
"options": {
@@ -19,6 +22,28 @@
"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 is ready",
"description": "Open House Plan from the sidebar. After an update, fully reload the page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). The dashboard card remains available; if you manage its resource manually in storage mode, open Settings → Dashboards → Resources. If the sidebar item is missing, check House Plan in System Health."
}
},
"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",
"panel_file": "House Plan panel file",
"panel_static_path": "Panel static frontend path",
"panel_status": "Panel registration status",
"panel_url": "House Plan panel URL",
"panel_module_url": "Panel module URL",
"panel_error": "Panel registration error"
}
}
}
@@ -325,21 +325,6 @@ def _project_space(ids: _Pseudonyms, space: dict[str, Any], index: int) -> dict[
}
for item in space.get("walls") or [] if isinstance(item, dict)
]
out["room_drafts"] = []
for draft in space.get("room_drafts") or []:
if not isinstance(draft, dict):
continue
out["room_drafts"].append({
"id": ids.get("draft", draft.get("id")),
"points": _points(draft.get("points")),
"segments": [
{
**({"id": ids.get("wall", segment.get("id"))} if segment.get("id") else {}),
"cm": segment.get("cm"),
}
for segment in draft.get("segments") or [] if isinstance(segment, dict)
],
})
out["partitions"] = [
{"id": ids.get("partition", item.get("id")), "a": _point(item.get("a")),
"b": _point(item.get("b")), "cm": item.get("cm")}
@@ -429,7 +414,6 @@ def _summary(config: object, layout: object) -> dict[str, Any]:
return {
"spaces": len(spaces),
"rooms": sum(len(space.get("rooms") or []) for space in spaces if isinstance(space, dict)),
"room_drafts": sum(len(space.get("room_drafts") or []) for space in spaces if isinstance(space, dict)),
"walls": sum(len(space.get("wall_segments") or space.get("walls") or []) for space in spaces if isinstance(space, dict)),
"partitions": sum(len(space.get("partitions") or []) for space in spaces if isinstance(space, dict)),
"columns": sum(len(space.get("wall_columns") or []) for space in spaces if isinstance(space, dict)),
+73 -2
View File
@@ -6,6 +6,8 @@ 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 .panel_registration import get_panel_registration_state
from .store import get_data
@@ -23,13 +25,82 @@ 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", [])),
"room_drafts": sum(len(s.get("room_drafts", [])) for s in config.get("spaces", [])),
"partitions": sum(len(s.get("partitions", [])) for s in config.get("spaces", [])),
"wall_columns": sum(len(s.get("wall_columns", [])) for s in config.get("spaces", [])),
"markers": len(config.get("markers", [])),
"layout_entries": len(layout_raw.get("layout", {})),
}
panel = get_panel_registration_state(hass)
if panel is None:
result.update(
{
"panel_file": "missing",
"panel_static_path": "not_registered",
"panel_status": "not_attempted",
"panel_url": "unavailable",
"panel_module_url": "unavailable",
"panel_error": "none",
}
)
else:
result.update(
{
"panel_file": (
"present" if panel.panel_file_present else "missing"
),
"panel_static_path": (
"registered"
if panel.panel_static_path_registered
else "not_registered"
),
"panel_status": panel.panel_status,
"panel_url": panel.panel_url or "unavailable",
"panel_module_url": panel.panel_module_url or "unavailable",
"panel_error": panel.panel_error or "none",
}
)
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",
}
)
else:
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
@@ -8,6 +8,9 @@
}
}
},
"create_entry": {
"panel_ready": "Öffnen Sie House Plan über die Seitenleiste, wenn der Eintrag verfügbar ist. Für die Bearbeitung wird ein Desktop-Browser empfohlen. Fehlt der Eintrag, verwenden Sie die House-Plan-Dashboard-Karte und prüfen Sie die Systeminformationen."
},
"abort": {
"single_instance_allowed": "Bereits konfiguriert – es ist nur eine Instanz zulässig."
}
@@ -25,6 +28,28 @@
"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 ist bereit",
"description": "Öffnen Sie House Plan über die Seitenleiste. Laden Sie die Seite nach einem Update vollständig neu, um die aktuelle Benutzeroberfläche zu verwenden (`Ctrl+F5` unter Windows/Linux oder `Cmd+Shift+R` unter macOS). Die Dashboard-Karte bleibt verfügbar; wenn Sie deren Ressource im Speichermodus manuell verwalten, öffnen Sie Einstellungen → Dashboards → Ressourcen. Fehlt der Seitenleisteneintrag, prüfen Sie House Plan in den Systeminformationen."
}
},
"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",
"panel_file": "Datei des House-Plan-Panels",
"panel_static_path": "Statischer Frontend-Pfad des Panels",
"panel_status": "Status der Panel-Registrierung",
"panel_url": "URL des House-Plan-Panels",
"panel_module_url": "Modul-URL des Panels",
"panel_error": "Fehler der Panel-Registrierung"
}
}
}
@@ -8,6 +8,9 @@
}
}
},
"create_entry": {
"panel_ready": "Open House Plan from the sidebar when the item is available. A desktop browser is recommended for editing. If the item is missing, use the House Plan dashboard card and check System Health."
},
"abort": {
"single_instance_allowed": "Already configured — only one entry is allowed."
}
@@ -25,6 +28,28 @@
"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 is ready",
"description": "Open House Plan from the sidebar. After an update, fully reload the page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). The dashboard card remains available; if you manage its resource manually in storage mode, open Settings → Dashboards → Resources. If the sidebar item is missing, check House Plan in System Health."
}
},
"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",
"panel_file": "House Plan panel file",
"panel_static_path": "Panel static frontend path",
"panel_status": "Panel registration status",
"panel_url": "House Plan panel URL",
"panel_module_url": "Panel module URL",
"panel_error": "Panel registration error"
}
}
}
@@ -8,6 +8,9 @@
}
}
},
"create_entry": {
"panel_ready": "Ouvrez House Plan depuis la barre latérale lorsque l’entrée est disponible. Un navigateur sur ordinateur est recommandé pour la modification. Si l’entrée est absente, utilisez la carte House Plan du tableau de bord et consultez les informations système."
},
"abort": {
"single_instance_allowed": "Déjà configuré — une seule entrée est autorisée."
}
@@ -25,6 +28,28 @@
"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": "House Plan est prêt",
"description": "Ouvrez House Plan depuis la barre latérale. Après une mise à jour, rechargez complètement la page pour utiliser l’interface la plus récente (`Ctrl+F5` sous Windows/Linux ou `Cmd+Shift+R` sous macOS). La carte de tableau de bord reste disponible ; si vous gérez manuellement sa ressource en mode stockage, ouvrez Paramètres → Tableaux de bord → Ressources. Si l’entrée de la barre latérale est absente, consultez House Plan dans les informations système."
}
},
"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",
"panel_file": "Fichier du panneau House Plan",
"panel_static_path": "Chemin statique du panneau",
"panel_status": "État de l’enregistrement du panneau",
"panel_url": "URL du panneau House Plan",
"panel_module_url": "URL du module du panneau",
"panel_error": "Erreur d’enregistrement du panneau"
}
}
}
@@ -8,6 +8,9 @@
}
}
},
"create_entry": {
"panel_ready": "Откройте House Plan в боковом меню, если пункт появился. Для редактирования рекомендуется браузер на компьютере. Если пункта нет, используйте карточку House Plan на панели и проверьте информацию о системе."
},
"abort": {
"single_instance_allowed": "Уже настроено — допускается одна запись."
}
@@ -25,6 +28,28 @@
"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). Карточка для панели остаётся доступна; если вы управляете её ресурсом вручную в режиме хранения, откройте Настройки → Панели → Ресурсы. Если пункта в боковом меню нет, проверьте House Plan в информации о системе."
}
},
"system_health": {
"info": {
"card_file": "Файл карточки House Plan",
"static_path": "Статический путь интерфейса",
"resource_status": "Статус регистрации ресурса",
"resource_loader": "Способ загрузки ресурса",
"resource_url": "URL ресурса интерфейса",
"resource_retry": "Повтор регистрации ресурса",
"resource_error": "Ошибка регистрации интерфейса",
"first_reload_notice": "Первое уведомление о перезагрузке",
"panel_file": "Файл панели House Plan",
"panel_static_path": "Статический путь панели",
"panel_status": "Статус регистрации панели",
"panel_url": "URL панели House Plan",
"panel_module_url": "URL модуля панели",
"panel_error": "Ошибка регистрации панели"
}
}
}
+35 -10
View File
@@ -180,6 +180,17 @@ def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
except (TypeError, ValueError):
return # CONFIG_SCHEMA owns malformed values.
previous = previous or {}
if old_model >= 10 and new_model < 10 and any(
"room_drafts" in space for space in config.get("spaces") or []
if isinstance(space, dict)
):
# A v9 card can echo a cached draft without changing the contour
# projection at all. The old catalogue guard therefore cannot see the
# destructive downgrade, but v10 must never persist the removed
# carrier again (#478 AC2).
raise WallModelClientOutdatedError(
f"stored model={old_model}; submitted legacy room_drafts model={new_model}"
)
contour_geometry_changed = (
_catalog_coupled_wall_geometry_projection(config)
!= _catalog_coupled_wall_geometry_projection(previous)
@@ -1090,6 +1101,15 @@ def _finite(value):
return f
def _finite_number_or_none(value):
"""A persisted optional number: do not coerce strings or booleans."""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise vol.Invalid("temperature threshold must be a finite number or null")
return _finite(value)
# Persisted colours deliberately use one small, browser-independent format.
# Keep this exact contract in sync with src/color.ts.
# `^...$` accepts a trailing newline in Python. Persisted CSS tokens must
@@ -1114,8 +1134,8 @@ MAX_DECOR = 1000
# later constant declaration.
MAX_WALLS = 200_000
MAX_WALL_SEGMENTS = 200_000
MAX_ROOM_DRAFTS = 200
MAX_DRAFT_SEGMENTS = 2000
_LEGACY_MAX_ROOM_DRAFTS = 200
_LEGACY_MAX_DRAFT_SEGMENTS = 2000
MAX_PARTITIONS = 2000
MAX_WALL_COLUMNS = 500
# Open (virtual) wall stretches, docs/superpowers/specs/2026-08-05-open-spans-delete-design.md.
@@ -1269,6 +1289,8 @@ ROOM_SCHEMA = vol.All(
vol.Optional("glow"): vol.Any(bool, None),
vol.Optional("temp_source"): vol.Any(str, None),
vol.Optional("hum_source"): vol.Any(str, None),
vol.Optional("temp_min"): _finite_number_or_none,
vol.Optional("temp_max"): _finite_number_or_none,
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
vol.Optional("label_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
},
@@ -1484,7 +1506,7 @@ WALL_SEGMENT_SCHEMA = vol.All(
)
def _room_draft_segments(value: dict) -> dict:
def _legacy_room_draft_segments(value: dict) -> dict:
"""An open draft has exactly one thickness per consecutive edge."""
if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1):
raise vol.Invalid("room draft segments must match consecutive point pairs")
@@ -1493,7 +1515,7 @@ def _room_draft_segments(value: dict) -> dict:
return value
ROOM_DRAFT_SCHEMA = vol.All(
_LEGACY_ROOM_DRAFT_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
@@ -1509,7 +1531,7 @@ ROOM_DRAFT_SCHEMA = vol.All(
},
extra=vol.ALLOW_EXTRA,
),
_room_draft_segments,
_legacy_room_draft_segments,
)
def _partition_nonzero(value: dict) -> dict:
@@ -1592,7 +1614,7 @@ def _space_geometry_invariants(value: dict) -> dict:
draft_segments = sum(
len(item.get("segments", [])) for item in value.get("room_drafts", [])
)
if draft_segments > MAX_DRAFT_SEGMENTS:
if draft_segments > _LEGACY_MAX_DRAFT_SEGMENTS:
raise vol.Invalid("too many saved room-draft segments")
for draft in value.get("room_drafts", []):
for segment in draft.get("segments", []):
@@ -1706,7 +1728,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
),
vol.Optional("zero_wall_style"): vol.In(["dashed", "solid"]),
vol.Optional("room_drafts"): vol.All(
[ROOM_DRAFT_SCHEMA], vol.Length(max=MAX_ROOM_DRAFTS)
[_LEGACY_ROOM_DRAFT_SCHEMA], vol.Length(max=_LEGACY_MAX_ROOM_DRAFTS)
),
vol.Optional("partitions"): vol.All(
[PARTITION_SCHEMA], vol.Length(max=MAX_PARTITIONS)
@@ -1877,6 +1899,8 @@ def _config_wall_segment_invariants(value: dict) -> dict:
if model >= 9:
if "open_spans" in space or any("open_to" in room for room in space.get("rooms", [])):
raise vol.Invalid("v9 config must not contain legacy open boundaries")
if model >= 10 and "room_drafts" in space:
raise vol.Invalid("v10 config must not contain room_drafts")
segments = space.get("wall_segments")
if segments is None:
raise vol.Invalid("v8+ space requires wall_segments")
@@ -1917,9 +1941,10 @@ def _config_wall_segment_invariants(value: dict) -> dict:
if actual_walls != expected_walls:
raise vol.Invalid("wall compatibility projection must match wall_segments")
for draft in space.get("room_drafts", []):
if any(not segment.get("id") for segment in draft.get("segments", [])):
raise vol.Invalid("v8+ draft wall segments require ids")
if model < 10:
for draft in space.get("room_drafts", []):
if any(not segment.get("id") for segment in draft.get("segments", [])):
raise vol.Invalid("v8+ draft wall segments require ids")
partitions = {item["id"]: item for item in space.get("partitions", [])}
wall_opening_intervals: dict[str, list[tuple[float, float]]] = {}
+116 -35
View File
@@ -1,4 +1,4 @@
"""Deterministic persisted contour-wall identity (model v9, issues #282/#306).
"""Deterministic persisted contour-wall identity (model v10, issues #282/#478).
This is the backend twin of ``src/wall-segment-model.ts``. Import preview is
a server-side structural writer, so it cannot depend on a browser being open
@@ -10,14 +10,17 @@ import base64
import copy
import hashlib
import math
import re
import uuid
from typing import Any
from .coordinate_canonicalization import canonicalize_config_geometry
WALL_SEGMENT_MODEL_VERSION = 9
WALL_SEGMENT_MODEL_VERSION = 10
GRID_STEP_N = 1 / 240
EPS = 1e-9
LEGACY_DRAFT_POINT_EPSILON = 0.001
_LEGACY_NUMBER = re.compile(r"^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$")
class WallSegmentMigrationError(ValueError):
@@ -30,6 +33,29 @@ class WallSegmentMigrationError(ValueError):
super().__init__(f"{reason}: {detail}" if detail else reason)
def _legacy_finite_number(value: Any) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
return None
if isinstance(value, str) and not _LEGACY_NUMBER.fullmatch(value.strip()):
return None
try:
parsed = float(value)
except (TypeError, ValueError):
return None
return parsed if math.isfinite(parsed) else None
def _legacy_draft_point(value: Any) -> list[float] | None:
if not isinstance(value, list) or len(value) < 2:
return None
x, y = _legacy_finite_number(value[0]), _legacy_finite_number(value[1])
return None if x is None or y is None else [x, y]
def _legacy_identity(value: Any) -> str:
return value if isinstance(value, str) else ""
def _point_key(point: list[float]) -> str:
return f"{float(point[0]):.12f},{float(point[1]):.12f}"
@@ -618,7 +644,74 @@ def _host_openings(
opening.pop("host", None)
def _migrate_space(space: dict[str, Any], initial_migration: bool) -> int:
def _migrate_room_drafts_to_partitions(
space: dict[str, Any], initial_migration: bool,
) -> tuple[int, int]:
if "room_drafts" not in space:
return 0, 0
if not initial_migration:
raise WallSegmentMigrationError("duplicate-id", "model v10 must not contain room_drafts")
drafts = space.get("room_drafts") or []
used = {
str(item["id"])
for name in ("rooms", "openings", "decor", "partitions",
"wall_columns", "wall_segments")
for item in space.get(name) or []
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]
}
used.update(
str(draft["id"]) for draft in drafts
if isinstance(draft, dict) and isinstance(draft.get("id"), str) and draft["id"]
)
partitions = copy.deepcopy(space.get("partitions") or [])
converted = 0
for draft in drafts:
draft_identity = _legacy_identity(draft.get("id")) if isinstance(draft, dict) else ""
if not isinstance(draft, dict):
raise WallSegmentMigrationError("zero-length", draft_identity)
points = draft.get("points") if isinstance(draft.get("points"), list) else []
segments = draft.get("segments") if isinstance(draft.get("segments"), list) else []
if len(points) < 2 or len(segments) != len(points) - 1:
raise WallSegmentMigrationError("zero-length", draft_identity)
for index, segment in enumerate(segments):
a = _legacy_draft_point(points[index])
b = _legacy_draft_point(points[index + 1])
if a is None or b is None or (
abs(a[0] - b[0]) < LEGACY_DRAFT_POINT_EPSILON
and abs(a[1] - b[1]) < LEGACY_DRAFT_POINT_EPSILON
):
raise WallSegmentMigrationError("zero-length", draft_identity)
cm = _legacy_finite_number(segment.get("cm") if isinstance(segment, dict) else None)
if cm is None or cm < 0 or cm > 100:
raise WallSegmentMigrationError("thickness-conflict", draft_identity)
segment_id = segment.get("id") if isinstance(segment, dict) and isinstance(segment.get("id"), str) else ""
if not segment_id or segment_id in used:
base = deterministic_wall_segment_id(
_legacy_identity(space.get("id")), a, b,
[f"draft:{draft_identity}:{index}"],
)
segment_id = base
suffix = 2
while segment_id in used:
segment_id = f"{base}-{suffix}"
suffix += 1
used.add(segment_id)
partitions.append({"id": segment_id, "a": a, "b": b, "cm": cm})
converted += 1
if partitions:
space["partitions"] = partitions
else:
space.pop("partitions", None)
space.pop("room_drafts", None)
return len(drafts), converted
def _migrate_space(
space: dict[str, Any], initial_migration: bool,
) -> tuple[int, int, int]:
migrated_drafts, migrated_draft_segments = _migrate_room_drafts_to_partitions(
space, initial_migration,
)
old: dict[str, dict[str, Any]] = {}
for segment in space.get("wall_segments") or []:
segment_id = str(segment.get("id", ""))
@@ -652,47 +745,35 @@ def _migrate_space(space: dict[str, Any], initial_migration: bool) -> int:
space.pop("open_spans", None)
for room in space.get("rooms") or []:
room.pop("open_to", None)
draft_used = {
str(item["id"])
for name in ("rooms", "openings", "decor", "room_drafts", "partitions",
"wall_columns", "wall_segments")
for item in space.get(name) or []
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]
}
for draft in space.get("room_drafts") or []:
for index, segment in enumerate(draft.get("segments") or []):
if isinstance(segment.get("id"), str) and segment["id"]:
if segment["id"] in draft_used:
raise WallSegmentMigrationError("duplicate-id", segment["id"])
draft_used.add(segment["id"])
continue
try:
a, b = draft["points"][index], draft["points"][index + 1]
except (KeyError, IndexError, TypeError):
raise WallSegmentMigrationError("zero-length", str(draft.get("id", ""))) from None
if initial_migration:
base = deterministic_wall_segment_id(
str(space.get("id", "")), a, b, [f"draft:{draft.get('id', '')}"],
)
suffix, segment["id"] = 1, base
while segment["id"] in draft_used:
suffix += 1
segment["id"] = f"{base}-{suffix}"
else:
segment["id"] = _fresh_wall_segment_id(draft_used)
draft_used.add(segment["id"])
_host_openings(space, segments, initial_migration)
return sum(1 for segment in segments if segment["id"] not in old)
return (
sum(1 for segment in segments if segment["id"] not in old),
migrated_drafts,
migrated_draft_segments,
)
def commit_wall_segment_model(config: Any) -> tuple[Any, int]:
def commit_wall_segment_model(
config: Any, *, migration_report: dict[str, int] | None = None,
) -> tuple[Any, int]:
"""Return one migrated deep copy and the count of newly assigned wall ids."""
candidate = canonicalize_config_geometry(copy.deepcopy(config))
if not isinstance(candidate, dict):
raise WallSegmentMigrationError("invalid-room")
migrated = 0
migrated_drafts = 0
migrated_draft_segments = 0
initial_migration = int(candidate.get("model_version", 0) or 0) < WALL_SEGMENT_MODEL_VERSION
for space in candidate.get("spaces") or []:
migrated += _migrate_space(space, initial_migration)
wall_segments, drafts, draft_segments = _migrate_space(space, initial_migration)
migrated += wall_segments
migrated_drafts += drafts
migrated_draft_segments += draft_segments
candidate["model_version"] = WALL_SEGMENT_MODEL_VERSION
if migration_report is not None:
migration_report.clear()
migration_report.update({
"room_drafts": migrated_drafts,
"room_draft_segments": migrated_draft_segments,
})
return canonicalize_config_geometry(candidate), migrated
+737 -25
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
/** Reproducible browser benchmark and report producer for HP-PERF-01. */
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { launch } from './serve.mjs';
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
@@ -8,6 +9,10 @@ import { assertFreshDemoBundle } from './bundle-freshness.mjs';
import { ensureHarnessEditorRuntime } from './editor-runtime-compat.mjs';
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
import {
ISOMETRIC_STAGE3_DENSE_PROFILE,
makeIsometricStage3DenseFixture,
} from './performance/isometric-stage3-dense-fixture.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
@@ -15,26 +20,49 @@ 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', ISOMETRIC_STAGE3_DENSE_PROFILE,
'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 stage3Dense = profile === ISOMETRIC_STAGE3_DENSE_PROFILE;
const allowStage2Base = process.argv.includes('--allow-stage2-base');
if (allowStage2Base && !stage3Dense)
throw new Error('--allow-stage2-base is valid only for the Stage 3 dense profile');
const requireStage3 = stage3Dense && !allowStage2Base;
const isometric = profile === 'large-house-isometric-v1' || stage3Dense;
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 requiresIsoStructuralBuildCounter = requiresIsometric
&& readFileSync(resolve(targetRoot, 'src/houseplan-card.ts'), 'utf8')
.includes('private _isoStructuralBuildCount');
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
const fixture = makeLargeHouseFixture();
const requiresInteraction = interaction && existsSync(resolve(targetRoot, 'src/live-viewport.ts'));
const fixture = stage3Dense ? makeIsometricStage3DenseFixture() : makeLargeHouseFixture();
const fixtureCounts = stage3Dense ? fixture.counts : LARGE_HOUSE_COUNTS;
const sourceSha = (() => {
try {
return execFileSync('git', ['-C', targetRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
} catch {
return null;
}
})();
if (isometric && !sourceSha)
throw new Error(`${profile} requires an exact git source SHA`);
if (planSnap) {
for (const [floor, space] of fixture.config.spaces.entries()) {
space.room_drafts = [0, 1].map((draft) => {
const y = 0.985 + draft * 0.025;
return {
id: `perf-draft-${floor}-${draft}`,
points: [[0.10, y], [0.38, y], [0.46, y + 0.035]],
segments: [{ cm: 15 }, { cm: 20 }],
};
});
space.partitions ||= [];
for (const chain of [0, 1]) {
const y = 0.985 + chain * 0.025;
space.partitions.push(
{ id: `perf-chain-${floor}-${chain}-0`, a: [0.10, y], b: [0.38, y], cm: 15 },
{ id: `perf-chain-${floor}-${chain}-1`, a: [0.38, y], b: [0.46, y + 0.035], cm: 20 },
);
}
}
fixture.counts = { ...fixture.counts, drafts: 6, pointerMoves: 120 };
fixture.counts = { ...fixture.counts, chainSegments: 12, pointerMoves: 120 };
}
const viewport = { width: 1440, height: 1000 };
@@ -71,7 +99,8 @@ try {
const measuredSample = iteration - warmups;
const row = await page.evaluate(async ({
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
requiresWallFace,
requiresWallFace, interaction, requiresInteraction, stage3Dense, requireStage3,
requiresIsoStructuralBuildCounter, profile,
}) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
@@ -113,6 +142,15 @@ try {
longTasks: await longTasks.stop(),
};
};
// Current candidates split Stage 3 out of the initial graph. Comparison
// bundles remain valid: their older monolithic renderer has no preload
// hook and is already available synchronously.
const ensureIsoRuntime = async (card) => {
if (typeof card._ensureIsoSceneRuntime !== 'function') return;
if (!(await card._ensureIsoSceneRuntime()))
throw new Error(`${profile} isometric runtime did not load`);
await card.updateComplete;
};
const forceGc = async () => {
if (typeof globalThis.gc !== 'function') return false;
globalThis.gc();
@@ -131,6 +169,119 @@ try {
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
wallFaceGraph: card._wallFaceGraphCache?.length ?? 0,
});
const isoStructuralBuildCount = (card) => Number.isFinite(card._isoStructuralBuildCount)
? card._isoStructuralBuildCount : null;
const stage3Snapshot = (card) => {
const stage = card.renderRoot.querySelector('.stage');
// Count the interactive HTML owners, not the diagnostic SVG
// ground/tether/plate primitives which deliberately repeat kind/id.
const overlayRoots = [...card.renderRoot.querySelectorAll(
'[data-hp-iso-overlay-kind][data-hp-iso-floor][data-hp-iso-visual]',
)];
const overlaySelectors = {
device: '[data-hp="device"]',
'room-label': '[data-hp="room-label"]',
'opening-lock': '.oplock',
};
const totalRootsByKind = Object.fromEntries(Object.entries(overlaySelectors)
.map(([kind, selector]) => [kind, card.renderRoot.querySelectorAll(selector).length]));
const stage3RootsByKind = Object.fromEntries(Object.keys(overlaySelectors)
.map((kind) => [kind, overlayRoots.filter((root) =>
root.getAttribute('data-hp-iso-overlay-kind') === kind).length]));
const openingSurfaceCounts = Object.fromEntries(
fixture.stage3Dense.expectedOpeningKinds.map((kind) => [kind,
card.renderRoot.querySelectorAll(`.iso-opening-panel[data-kind="${kind}"]`).length,
]),
);
const vacuumRoots = [...card.renderRoot.querySelectorAll('.vacpuck, .vactrail')];
const materialDefinitions = card.renderRoot.querySelectorAll('[data-hp-iso-material-def]');
const definitionCounts = {
total: materialDefinitions.length,
patterns: card.renderRoot.querySelectorAll('pattern[data-hp-iso-material-def]').length,
filters: card.renderRoot.querySelectorAll('filter[data-hp-iso-material-def]').length,
};
const pulseDeviceId = fixture.stage3Dense?.pulseDeviceIdsBySpace?.[card._space] || '';
const pulseRoot = card.renderRoot.querySelector(
`.dev[data-id="${CSS.escape(pulseDeviceId)}"]`,
);
const facetCounts = {
value: card.renderRoot.querySelectorAll('.dev .value-badge').length,
lqi: card.renderRoot.querySelectorAll('.dev .lqi').length,
new: card.renderRoot.querySelectorAll('.dev .newdot').length,
pulse: pulseRoot?.querySelectorAll('.device-pulse, .activity-dot').length ?? 0,
roomMetrics: card.renderRoot.querySelectorAll('.roomlabel .rlmetrics').length,
};
const rawBuilds = stage?.getAttribute('data-hp-iso-structural-builds');
const structuralBuilds = rawBuilds == null ? null : Number(rawBuilds);
return {
effectiveProjection: typeof card._effectiveProjection === 'function'
? card._effectiveProjection() : null,
isoStageRevision: stage?.getAttribute('data-hp-iso-stage') ?? null,
structuralBuilds: Number.isFinite(structuralBuilds) ? structuralBuilds : null,
renderedOverlayCount: overlayRoots.length,
raisedOverlayCount: overlayRoots
.filter((root) => root.getAttribute('data-hp-iso-raised') === 'true').length,
nudgedOverlayCount: overlayRoots
.filter((root) => root.getAttribute('data-hp-iso-nudged') === 'true').length,
raisedVacuumCount: vacuumRoots.filter((root) =>
root.getAttribute('data-hp-iso-raised') === 'true'
|| root.hasAttribute('data-hp-iso-overlay-kind')).length,
overlayKinds: [...new Set(overlayRoots
.map((root) => root.getAttribute('data-hp-iso-overlay-kind'))
.filter(Boolean))].sort(),
totalRootsByKind,
stage3RootsByKind,
openingSurfaceCounts,
materialDefinitionCount: definitionCounts.total,
definitionCounts,
facetCounts,
pulseDiagnostics: pulseRoot ? {
id: pulseRoot.getAttribute('data-id'),
state: pulseRoot.getAttribute('data-state'),
className: pulseRoot.className,
ariaLabel: pulseRoot.getAttribute('aria-label'),
} : null,
};
};
const assertStage3Snapshot = (snapshot, label) => {
if (!requireStage3) return;
const expectedKinds = fixture.stage3Dense.expectedOverlayKinds;
const missingKinds = expectedKinds.filter((kind) => !snapshot.overlayKinds.includes(kind));
const failures = [];
if (snapshot.effectiveProjection !== 'iso') failures.push('effective projection is not iso');
if (snapshot.isoStageRevision !== '3') failures.push('Stage 3 revision marker is absent');
if (snapshot.structuralBuilds == null) failures.push('structural build counter is absent');
if (snapshot.renderedOverlayCount < 1) failures.push('no raised overlay roots rendered');
if (snapshot.raisedOverlayCount !== snapshot.renderedOverlayCount)
failures.push('one or more Stage 3 overlay roots remained on the floor plane');
if (snapshot.nudgedOverlayCount < 1) failures.push('dense fixture produced no bounded nudge');
if (snapshot.raisedVacuumCount !== 0) failures.push('floor-bound vacuum was marked as raised');
if (missingKinds.length) failures.push(`missing overlay kinds: ${missingKinds.join(', ')}`);
for (const kind of expectedKinds) {
const total = snapshot.totalRootsByKind[kind];
const stage3 = snapshot.stage3RootsByKind[kind];
if (total < 1) failures.push(`no rendered ${kind} roots`);
else if (stage3 !== total)
failures.push(`${kind} Stage 3 roots ${stage3}/${total}`);
}
for (const kind of fixture.stage3Dense.expectedOpeningKinds) {
if (!(snapshot.openingSurfaceCounts[kind] > 0))
failures.push(`no Stage 3 ${kind} opening surfaces`);
}
for (const [facet, count] of Object.entries(snapshot.facetCounts)) {
if (!(count > 0)) failures.push(`no observable ${facet} facet${facet === 'pulse'
? ` (${JSON.stringify(snapshot.pulseDiagnostics)})` : ''}`);
}
if (snapshot.materialDefinitionCount < 1
|| snapshot.materialDefinitionCount > fixture.stage3Dense.materialDefinitionLimit) {
failures.push(`material definitions ${snapshot.materialDefinitionCount}`
+ ` exceed 1..${fixture.stage3Dense.materialDefinitionLimit}`);
}
if (!(snapshot.definitionCounts.patterns > 0)) failures.push('no shared texture pattern');
if (!(snapshot.definitionCounts.filters > 0)) failures.push('no shared shadow filter');
if (failures.length)
throw new Error(`Stage 3 ${label} contract failed: ${failures.join('; ')}`);
};
window.__card?.remove?.();
localStorage.clear();
@@ -151,10 +302,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,7 +329,8 @@ try {
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
});
};
const hassFor = (states) => ({ ...hassBase, states });
const loadLongTasks = startLongTaskWindow();
const loadStarted = performance.now();
@@ -197,6 +349,7 @@ try {
// construction; monolithic stable bundles remain immediate (#380).
if (!await window.__hpEnsureHarnessEditorRuntime(card))
throw new Error('large-house editor runtime did not preload');
if (isometric) await ensureIsoRuntime(card);
window.__hpAssertCardContract(card, cardContract);
if (requiresIsometric && (typeof card._setProjection !== 'function'
|| !(card._isoGeometryCache instanceof Map))) {
@@ -207,14 +360,52 @@ 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 initialProjection = typeof card._effectiveProjection === 'function'
? card._effectiveProjection() : null;
if (requiresIsometric && initialProjection !== 'iso')
throw new Error(`${profile} entered Flat fallback instead of effective Iso`);
const initialIsoFingerprint = card.renderRoot
.querySelector('[data-hp="iso-walls"]')?.getAttribute('data-fingerprint') ?? null;
if (requiresIsometric && !initialIsoFingerprint)
throw new Error(`${profile} has no rendered Iso geometry fingerprint`);
const initialIsoStructuralBuilds = isometric ? isoStructuralBuildCount(card) : null;
if (requiresIsoStructuralBuildCounter && initialIsoStructuralBuilds == null)
throw new Error(`${profile} has no structural build counter`);
const initialStage3 = stage3Dense ? stage3Snapshot(card) : null;
if (initialStage3) assertStage3Snapshot(initialStage3, 'initial render');
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');
await card.updateComplete;
card._setProjection('iso');
await ensureIsoRuntime(card);
await card.updateComplete;
} else {
// Comparison SHAs before #89 intentionally ignore the Labs operation.
@@ -227,7 +418,19 @@ try {
await card.updateComplete;
});
const firstEntity = Object.keys(fixture.states)[0];
const steadyStage3 = stage3Dense ? stage3Snapshot(card) : null;
if (steadyStage3) assertStage3Snapshot(steadyStage3, 'steady floor');
const steadyIsoFingerprint = card.renderRoot
.querySelector('[data-hp="iso-walls"]')?.getAttribute('data-fingerprint') ?? null;
if (requiresIsometric && !steadyIsoFingerprint)
throw new Error(`${profile} lost Iso geometry after the space switch`);
const steadyIsoStructuralBuilds = isometric ? isoStructuralBuildCount(card) : null;
if (requiresIsoStructuralBuildCounter && steadyIsoStructuralBuilds == null)
throw new Error(`${profile} lost its structural build counter after the space switch`);
const firstEntity = stage3Dense
? fixture.stage3Dense.stateEntityId
: Object.keys(fixture.states)[0];
const nextStates = {
...fixture.states,
[firstEntity]: { ...fixture.states[firstEntity], state: fixture.states[firstEntity].state === 'on' ? 'off' : 'on' },
@@ -236,6 +439,464 @@ try {
card.hass = hassFor(nextStates);
await card.updateComplete;
});
const afterStateStage3 = stage3Dense ? stage3Snapshot(card) : null;
if (afterStateStage3) assertStage3Snapshot(afterStateStage3, 'HA-only update');
const afterStateFingerprint = card.renderRoot
.querySelector('[data-hp="iso-walls"]')?.getAttribute('data-fingerprint') ?? null;
if (requiresIsometric && afterStateFingerprint !== steadyIsoFingerprint)
throw new Error(`${profile} rebuilt Iso geometry for an HA-only state update`);
const afterStateIsoStructuralBuilds = isometric ? isoStructuralBuildCount(card) : null;
if (requiresIsoStructuralBuildCounter
&& afterStateIsoStructuralBuilds !== steadyIsoStructuralBuilds) {
throw new Error(`${profile} performed a structural rebuild for an HA-only state update`);
}
if (requireStage3 && afterStateStage3.structuralBuilds !== steadyStage3.structuralBuilds)
throw new Error(`${profile} rebuilt Stage 3 structure for an HA-only state update`);
let stage3States = nextStates;
const openingUpdate = stage3Dense ? await duration(async () => {
const entityId = fixture.stage3Dense.contactEntityId;
const current = stage3States[entityId];
stage3States = {
...stage3States,
[entityId]: { ...current, state: current.state === 'on' ? 'off' : 'on' },
};
card.hass = hassFor(stage3States);
await card.updateComplete;
}) : null;
const afterOpeningStage3 = stage3Dense ? stage3Snapshot(card) : null;
if (afterOpeningStage3) assertStage3Snapshot(afterOpeningStage3, 'opening update');
if (stage3Dense && requiresIsometric && afterOpeningStage3?.effectiveProjection !== 'iso')
throw new Error(`${profile} entered Flat fallback during an opening update`);
if (requireStage3 && afterOpeningStage3.structuralBuilds !== steadyStage3.structuralBuilds)
throw new Error(`${profile} rebuilt Stage 3 structure for an opening update`);
const overlayInteraction = stage3Dense ? await duration(async () => {
const overlay = card.renderRoot.querySelector(requireStage3
? '[data-hp-iso-overlay-kind="device"][data-hp-iso-floor][data-hp-iso-visual]'
: '[data-hp="device"]');
if (!overlay) throw new Error(`${profile} dense fixture rendered no interactive device`);
overlay.dispatchEvent(new PointerEvent('pointerover', {
bubbles: true, composed: true, pointerId: 885, pointerType: 'mouse', isPrimary: true,
}));
overlay.focus?.();
await card.updateComplete;
await frame();
overlay.dispatchEvent(new PointerEvent('pointerout', {
bubbles: true, composed: true, pointerId: 885, pointerType: 'mouse', isPrimary: true,
}));
overlay.blur?.();
await card.updateComplete;
}) : null;
const afterInteractionStage3 = stage3Dense ? stage3Snapshot(card) : null;
if (afterInteractionStage3)
assertStage3Snapshot(afterInteractionStage3, 'hover/focus update');
if (stage3Dense && requiresIsometric && afterInteractionStage3?.effectiveProjection !== 'iso')
throw new Error(`${profile} entered Flat fallback during a hover/focus update`);
if (requireStage3
&& afterInteractionStage3.structuralBuilds !== steadyStage3.structuralBuilds) {
throw new Error(`${profile} rebuilt Stage 3 structure for a hover/focus update`);
}
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 () => {
@@ -318,12 +979,14 @@ try {
)) throw new Error(`plan-snap structural contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
if (requiresWallFace) {
const oldPath = card._path;
const oldDraftId = card._activeDraftId;
const oldCms = card._draftSegmentCms;
const oldChainId = card._activeWallChainId;
const oldPartitionIds = card._activeWallChainPartitionIds;
const oldCms = card._wallChainSegmentCms;
const beforePath = [[10, 10]];
card._path = [[10, 10], [20, 10]];
card._activeDraftId = 'perf-face-draft';
card._draftSegmentCms = [15];
card._activeWallChainId = 'perf-face-chain';
card._activeWallChainPartitionIds = ['perf-face-segment'];
card._wallChainSegmentCms = [15];
const acceptedStarted = performance.now();
card._offerWallFaces(beforePath);
planSnapDiagnostics.wallFaceAcceptedClickMs = performance.now() - acceptedStarted;
@@ -331,8 +994,9 @@ try {
card._wallFaceBatch = null;
card._roomDialog = false;
card._path = oldPath;
card._activeDraftId = oldDraftId;
card._draftSegmentCms = oldCms;
card._activeWallChainId = oldChainId;
card._activeWallChainPartitionIds = oldPartitionIds;
card._wallChainSegmentCms = oldCms;
if (planSnapDiagnostics.wallFaceAcceptedClickMs > 1000
|| planSnapDiagnostics.wallFaceCacheEntries < 1
|| planSnapDiagnostics.wallFaceCacheEntries > 4) {
@@ -423,16 +1087,29 @@ try {
const cacheGrowth = Object.fromEntries(
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
);
const finalProjection = typeof card._effectiveProjection === 'function'
? card._effectiveProjection() : null;
if (requiresIsometric && finalProjection !== 'iso')
throw new Error(`${profile} ended the sample in Flat fallback`);
const finalStage3 = stage3Dense ? stage3Snapshot(card) : null;
if (finalStage3) assertStage3Snapshot(finalStage3, 'final warmed state');
const result = {
sample,
modelReadyMs,
firstStableRenderMs,
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
...(openingUpdate ? { openingUpdateMs: openingUpdate.ms } : {}),
...(overlayInteraction ? { overlayInteractionMs: overlayInteraction.ms } : {}),
...(planSnapPointer ? {
planSnapPointerMs: planSnapPointer.ms,
planSnapDiagnostics,
} : {}),
...(interactionSeries ? {
interactionSeriesMs: interactionSeries.ms,
...interactionTimings,
interactionDiagnostics,
} : {}),
spaceSwitchMs: spaceSwitch.ms,
stateUpdateMs: stateUpdate.ms,
resizePreviewMs: resizePreview.ms,
@@ -442,7 +1119,10 @@ try {
longTasks: {
load: loadLongTaskResult,
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
...(openingUpdate ? { openingUpdate: openingUpdate.longTasks } : {}),
...(overlayInteraction ? { overlayInteraction: overlayInteraction.longTasks } : {}),
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
...(interactionSeries ? interactionLongTasks || {} : {}),
spaceSwitch: spaceSwitch.longTasks,
stateUpdate: stateUpdate.longTasks,
resizePreview: resizePreview.longTasks,
@@ -455,6 +1135,26 @@ try {
heapGrowthBytes: heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore,
preciseGc: typeof globalThis.gc === 'function',
renderedDevices: card._devices?.length ?? 0,
effectiveProjection: finalProjection,
...(isometric ? {
isoStructuralBuilds: {
supported: steadyIsoStructuralBuilds != null && afterStateIsoStructuralBuilds != null,
initial: initialIsoStructuralBuilds,
beforeHaUpdate: steadyIsoStructuralBuilds,
afterHaUpdate: afterStateIsoStructuralBuilds,
haUpdateDelta: steadyIsoStructuralBuilds == null || afterStateIsoStructuralBuilds == null
? null : afterStateIsoStructuralBuilds - steadyIsoStructuralBuilds,
},
} : {}),
...(finalStage3 ? {
isoStageRevision: finalStage3.isoStageRevision,
stage3Diagnostics: {
...finalStage3,
steadyUpdateStructuralBuildDelta: requireStage3
? afterInteractionStage3.structuralBuilds - steadyStage3.structuralBuilds
: null,
},
} : {}),
};
card.remove();
await frame();
@@ -462,6 +1162,8 @@ try {
}, {
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
interaction, requiresInteraction, stage3Dense, requireStage3,
requiresIsoStructuralBuildCounter, profile,
});
if (measuredSample >= 0) rows.push(row);
}
@@ -474,10 +1176,15 @@ const metricNames = [
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
];
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
if (stage3Dense) metricNames.splice(3, 0, 'openingUpdateMs', 'overlayInteractionMs');
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,
sourceSha,
generatedAt: new Date().toISOString(),
buildFingerprint,
runtime: {
@@ -489,11 +1196,16 @@ const report = {
deviceScaleFactor: 1,
reducedMotion: true,
},
fixture: LARGE_HOUSE_COUNTS,
fixture: fixtureCounts,
samples,
warmups,
summary: summarizeTimings(rows, metricNames),
longTasks: summarizeLongTasks(rows),
effectiveProjection: [...new Set(rows.map((row) => row.effectiveProjection))],
...(stage3Dense ? {
isoStageRevision: [...new Set(rows.map((row) => row.isoStageRevision))],
stage3Required: requireStage3,
} : {}),
rows,
note: `Compare with a base-SHA report captured by the same runner and evaluate the ${profile} budget.`,
};
+113
View File
@@ -0,0 +1,113 @@
/** #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,
runWallDrawFinishProfile,
} 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 runFinish = async (remoteVariant) => {
await resetWallDrawClickHarness(page, remoteVariant);
// Warm the distinct finalizer path, then measure it from the same fixture.
await runWallDrawFinishProfile(page);
await resetWallDrawClickHarness(page, remoteVariant);
return runWallDrawFinishProfile(page);
};
const baseFinish = await runFinish(false);
const remoteFinish = await runFinish(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.savedPartitions === 8;
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.metrics.wallGenericFallbacks === 0
&& base.result.terminal.fullSpacePhysicalChecks === 0
&& base.result.terminal.localPhysicalChecks === 0
&& base.result.terminal.configWrites === 0 && base.result.terminal.history === 0
&& base.result.terminalPartitionCount === base.fixture.savedPartitions + 7
&& base.result.activeCleared;
const finishStructural = baseFinish.terminal.fullSpacePhysicalChecks === 0
&& baseFinish.terminal.localPhysicalChecks === 1
&& baseFinish.terminal.configWrites === 1
&& baseFinish.terminal.history === 0
&& baseFinish.terminal.wallFinishBarriers === 1
&& baseFinish.terminal.wallGenericFallbacks === 0
&& baseFinish.surviving.length === 1
&& baseFinish.terminalPartitionCount === base.fixture.savedPartitions + 1
&& baseFinish.optimizeChanged === false
&& baseFinish.activeCleared
&& remoteFinish.terminal.fullSpacePhysicalChecks === 0
&& remoteFinish.terminal.localPhysicalChecks === 1
&& remoteFinish.terminal.configWrites === 1
&& remoteFinish.terminal.wallFinishBarriers === 1
&& remoteFinish.surviving.length === 1
&& remoteFinish.optimizeChanged === false;
const budgets = {
medianMs: 150,
maxMs: 250,
remoteMedianMs: medianMs * 1.5 + 20,
finishMs: 250,
remoteFinishMs: baseFinish.finishMs * 1.5 + 20,
};
const timing = medianMs <= budgets.medianMs && maxMs <= budgets.maxMs
&& remoteMedianMs <= budgets.remoteMedianMs
&& baseFinish.finishMs <= budgets.finishMs
&& remoteFinish.finishMs <= budgets.remoteFinishMs;
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,
finishMs: Math.round(baseFinish.finishMs * 10) / 10,
remoteFinishMs: Math.round(remoteFinish.finishMs * 10) / 10,
},
budgets: Object.fromEntries(Object.entries(budgets)
.map(([key, value]) => [key, Math.round(value * 10) / 10])),
counters: base.result.metrics,
finishCounters: baseFinish.terminal,
structural: structural && finishStructural,
timingPass: timing,
pass: structural && finishStructural && 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);
+9
View File
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs';
import { relative, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
import { assertBundleManifest, verifyBundleTree } from '../scripts/bundle-tree.mjs';
const fingerprintForTree = async (root) => {
const modulePath = resolve(root, 'scripts/source-fingerprint.mjs');
@@ -25,6 +26,14 @@ const verifyManifestTree = (root, expected) => {
|| !Array.isArray(manifest.files)) {
throw new Error('demo bundle manifest is stale or malformed; run npm run bundle:sync');
}
// Comparative performance runs intentionally build older trees. Require the
// two-entry contract exactly when that target contains the panel source; a
// pre-#486 baseline remains measurable under its own fingerprint contract.
if (existsSync(resolve(root, 'src/houseplan-panel.ts'))) {
assertBundleManifest(manifest, manifestPath);
verifyBundleTree(assetRoot);
return;
}
for (const file of manifest.files) {
const path = resolve(assetRoot, String(file?.path || ''));
const rel = relative(assetRoot, path);
+11 -1
View File
@@ -132,6 +132,12 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
card._infoCard = device;
}
if (current.dialog === 'pdf') {
if (!(await card._pdfRuntimeLoader.ensure()))
throw new Error('documentation PDF runtime did not load');
card._pdfDialog = true;
}
card.requestUpdate();
await card.updateComplete;
await frame();
@@ -154,7 +160,11 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
}
await settleCamera();
return { dialog: !!card.renderRoot.querySelector('hp-dialog') };
// Most dialogs render hp-dialog directly into the card, while the lazy PDF
// surface owns that shell inside hp-pdf-dialog's shadow root. The host is
// the stable public capture boundary; reaching through its shadow root here
// would couple documentation capture to the dialog implementation.
return { dialog: !!card.renderRoot.querySelector('hp-dialog, hp-pdf-dialog') };
}, scenario);
mkdirSync(OUTPUT, { recursive: true });
+7 -1
View File
@@ -4,7 +4,7 @@
* `scripts/docs-accept.mjs` (#246). Импортировать его из `capture.mjs` нельзя —
* тот скрипт при импорте поднимает браузер и снимает картинки.
*/
export const DOC_SCREENSHOT_VERSION = 1;
export const DOC_SCREENSHOT_VERSION = 2;
export const DOC_SCREENSHOTS = Object.freeze([
{
id: 'view-desktop', file: '01-view-desktop.png', fixture: 'visual',
@@ -72,4 +72,10 @@ export const DOC_SCREENSHOTS = Object.freeze([
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 900 }, capture: 'page', expectDialog: true,
},
{
id: 'pdf-export', file: '10-pdf-export.png', fixture: 'visual',
space: 'golden-geometry', mode: 'view', dialog: 'pdf',
title: 'House Plan — synthetic home', language: 'en', theme: 'light',
viewport: { width: 900, height: 760 }, capture: 'page', expectDialog: true,
},
]);
+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 savedPartitions = Array.from({ length: 4 }, (_, index) => {
const points = [
point(8 + index * 28, 112), point(20 + index * 28, 112),
point(20 + index * 28, 124),
];
return points.slice(1).map((b, edge) => ({
id: `saved-partition-${index}-${edge}`, a: points[edge], b, cm: 15,
}));
}).flat();
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),
partitions: savedPartitions, wall_columns: [], openings: [], open_spans: [],
};
return {
model_version: 10,
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: [],
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: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

+84 -67
View File
@@ -1,14 +1,21 @@
{
"schema": 1,
"matrixVersion": 54,
"acceptedAt": "2026-09-04T13:10:56.492Z",
"sourceFingerprint": "7acb1b9d3bb384bc1a0c70fb01347386c157ae88b12b00ccd876b7658fc84a1c",
"matrixVersion": 61,
"acceptedAt": "2026-09-08T07:21:40.179Z",
"sourceFingerprint": "890c1fb3c26652d17297e069612868f7bdad06b80839ef1dfda080e53d2b19c1",
"chromium": "151.0.7922.34",
"platform": "win32",
"witnesses": {
"count": 127,
"count": 138,
"floor": 10
},
"scenarios": {
"panel-wide-view-light-en": "5d81a6c410c4289f82b994e7b6c9af729b56ceea59bf5823e35f638f54b5bc30",
"panel-wide-plan-editor-dark-en": "9404d7e3aa84b4b40a8cfeed27eea8831e55e9e6236abbb7cb092c709ca0dfcd",
"panel-narrow-read-only-empty-light-en": "99b8f1af82b1ad13f237125aab8f7e7da0563c138e9da2ddf06e54c2d56138c4",
"version-mismatch-desktop-dark-en": "ad08f293c728913549b0a71a1a514a60e3030d885acfb0c9951f1d9a1125a1d3",
"version-mismatch-touch-light-ru": "bf511c2e7e8714e30a12126c0c0b7f7ea7f5885e8c8ee0c475b364f6510c8702",
"version-mismatch-kiosk-attempted-dark-de": "d3d0863fe814d20f5b081de6ac14f8bbf7d559be019808e62cd6dcdc7abcaa70",
"hidden-wall-diagnostics-plan-light": "4113474796ccb1b7b8c876fcb463e97eb77bb1ec96129195a0dafdcc7ccca334",
"hidden-wall-diagnostics-plan-dark": "40c48868f0251aec3b1a14c0eef77a78cc7965420fb9b41f46f8190d1fdfd11a",
"coincident-partition-before-dark": "daacbc763c81ad037b305fc34572729c063ec542d031821660b8d3d096a35d19",
@@ -20,36 +27,44 @@
"split-corner-wall-thin-dark": "9a509f0fa7ee8503c54a508c313b47e10f8044f0a9931791d1cab83467dc3f90",
"split-corner-wall-thick-dark": "51ac5e7594386e306f5ebc94e50d6c6d428fd6a4f26a447b28e1cbdd429afda7",
"split-zero-divider-taper-dark": "eb43a7ba81ebd8b1f7c7f76306bb6205ea77a5cef3c21739d40ca540f0f7a1bf",
"isometric-geometry-view-dark": "0d8427299c77e1e3bb86f37f53128a6640b6666e0b682e5f96085c4f07a657ec",
"isometric-geometry-view-light": "a3e592448ad3ad4ff65266f1fd6f31259c01a9baf47392f6b8be9811d74f8dce",
"isometric-live-layers-dark": "90fa1c0b64a12626e481c5f34e515c53b3e06cd657f5976bb8a72127aec3e1e9",
"isometric-no-borders-dark": "a0abf9fdc31092980de3bcbf8598844b6e3d152363694e9bd070d45af73c26d9",
"isometric-touch-kiosk-dark": "d3ae363165c4f0aba65bcbac9bd6bdca79f29aca03bba997f3abec1860eb1e55",
"isometric-large-warm-remount-dark": "299401bd4b499876d40678593308b42ac8aa4d3328113f27ad5940fc3621184a",
"isometric-geometry-view-dark": "52b01bf23b31353900ea9174fdb4b9d27a586f8370fb9b00c4db6b9bbe7f73f2",
"isometric-geometry-view-light": "6ee98e56782feaabf784eea7d927ea35bdde054d8605484f4a5e92ea93f7c82a",
"isometric-live-layers-dark": "0d123ab0285fe3ad8487fb5263c3d8074fe2d2490091868ed648009039c04a3b",
"isometric-no-borders-dark": "7cd9e7d9797c920e7f3564a24b1fd6143b4da94a342af3b34d5a1cd8d584f11b",
"isometric-touch-kiosk-dark": "a6515349431d53688415e42a7711e8adf052eacf5e63a74f892fed60002c6d11",
"isometric-large-warm-remount-dark": "91f24287e8c3148fd6470ab4abc4f856f2863c2ae7372cd90a3770ddee48f209",
"isometric-stage3-overlays-light": "efb39c86776c507b25a8f1955f7371086cdca92e0df0ecb4c4127577fb925ba9",
"isometric-stage3-overlays-dark": "af2af4520240ebec87d84727fccc249c5a008b0ce4403bb9c2d37aa317d8a3e8",
"isometric-stage3-openings-dark": "a387ad33e2d238cd5f5088f9de2be8797f8b724d40599ef21eaae4ad0ff27b33",
"isometric-stage3-forced-colors-dark": "36bd5a28a336b2be2d33640dc57d8eb3992198c812c6cfb5c20bf7daa6a611f2",
"isometric-stage3-no-filter-dark": "277e79c005736ed81135f70517902ccea4d4300ee886e0cf05eb027e7b46d687",
"geometry-view-dark-fit": "1ea111a2f68d71c2d2269ee65ac70b586b7d8a3a76f550bb1caf8d154bf42b26",
"geometry-view-light-fit": "80bbb618619731dcb9dbd5e872785f32f1e52ff71ce989a2154c5f1218812eda",
"pdf-export-geometry-light": "2e7d0d027f4fc63e8005412e59b08da0114217cbd441bd3195f4ae909d19ff86",
"pdf-export-polish-light": "a8f9698513de9730d58fed90cd4c7092fdc8abc49378ee3ad1e58ce0fd607c7b",
"pdf-export-stepped-dimensions-light": "d4dffc0b2bab76405a5c4d504e18b86392abaf50ba63940666d165497947b21d",
"vacuum-trail-smoothing-dark": "b33ddbfaed72f51e601576bbf87664a08578fbce5c22c69a11d83cc2644ff312",
"room-label-parity-view-dark": "0b4dd478e9b5e0f7acbf46c135309c8b1ed984b8c21f9c7ca0be9a25c10779b7",
"room-label-parity-plan-dark": "9112819b3af20153022dbc79940961c2776d70eaeb61716dad62df3c4c518665",
"room-label-parity-view-light": "a8a9915d2900a1347e85baa3df2ebde602904a9e35bbcc143fea7b1067c171f0",
"room-label-parity-plan-light": "354e6c78c6daf94305b9b0c9b4efa5caea89a327d3a81f634f1c3f5c6674524c",
"room-label-parity-view-dark": "eea967d733bea8c08242d9265e572dc13ee90894e0df71a4ca93cee7631f7fef",
"room-label-parity-plan-dark": "dfc804fa1aa5bc0222759463195bae0751513e0e428c8eae0bbc9d49fbc5a1b9",
"room-label-parity-view-light": "bb15fe24a803b1a3e07b778ccdda7c187bf8b85cd0d2f0c85e65bd77029d500e",
"room-label-parity-plan-light": "8ca4ce563f87d15c70b680ea61fcbf19ba0881123f9bb4bf76e6ea6f4e58c76d",
"washer-active-cycle-dark": "0f38b85f2f912059a6380a0642a4d0314ea5c6fa6acb9568e1c4abbf4a7731a2",
"washer-idle-cycle-dark": "08d5f10dc14b548e6a149c8d9c93f9b635854604cebd681644f30a1ca0b4ee4d",
"day-cycle-dawn-dark": "cee9dea7017ee192dc69771822e53583df973dceecd46279c93079ba6bc730c5",
"day-cycle-day-dark": "96fb83defa72f78d9e58413505f9250fae9cc766e21bdecb4b23374e04f15d43",
"day-cycle-dusk-dark": "eca235de3abf21776b160d1b79cde3309952f0030bc2031a0ada7bdf2847d2be",
"day-cycle-night-dark": "feec7492db1187b84e59cb627de9dd8d6d412f1dfc2643c3eab5cafe8f198f7c",
"geometry-plan-editor-dark": "0fc3d491560d87e9c9b0285729c50bd20bc85c959b487b40c4d47f8c9378e8f4",
"safe-resize-handles-clamp-light": "1a5adb25fd12180bc7b9781924abb4ffad637579ec1eba9e1a76481523080960",
"safe-resize-handles-clamp-dark": "bd0035cacc4d8c16c789754507a00f673b75cfecd1569635e673683e5b9f0247",
"space-tab-drop-before-light": "d174ac6b543672ebd649e7c9363c3bb79b22b417f7d899c08f755e273632f702",
"space-tab-drop-after-dark": "eb8d6bae0e32392234bd4462652e2a8f7831d7017bf5e8d8106c9d6f203b3eaf",
"plan-snap-endpoint-light": "dd4fc2254ae457e9ae5628b1e97ca0228699cdedf9b57f5358b2280dbebd6d6e",
"plan-snap-line-gaps-dark": "b8457468ef5d4b8b38b62c4a52950364d231aedb7549fcbcbd4ee6f9444569e1",
"wall-junctions-plan-preview-light": "3151196d0015c56a46b17a599513ced13dc5558c03106e98fb7d6dda5fad8de2",
"wall-junctions-plan-t-dark": "6d16a52e7cf362622778e5b5692b6f3ae4ec04c790e5117144fb0ca50f6c70b2",
"geometry-plan-editor-dark": "6fa29c51ac37449e53ff76afe17760a86325e160f53040c737619aae1095599d",
"safe-resize-handles-clamp-light": "98db89eb1ec29aa6297ac0bc10653c8efce16175833091d0e76dd033ad9f4a8e",
"safe-resize-handles-clamp-dark": "581f85ca93197ba796e72daa67402eebb7c64f13d4722b5d33943577837c8980",
"space-tab-drop-before-light": "2678c840a33ade95f8a038213a696132612cb9bae8290e467ae9c4ac2ee265e3",
"space-tab-drop-after-dark": "f4f3ab1bb986426f281753a101b4055fae1f5f1ed0c04c8d41071dfa260978f1",
"plan-snap-endpoint-light": "40f761e1a7a12e5113ec6e736c6d7176908d8a1585281c2c2f438bc1fee36e1d",
"plan-snap-line-gaps-dark": "e4e5e15a98e35ac26a5b0fe7ef674341fb55d8445587ccc25a81603e1ef92774",
"wall-junctions-plan-preview-light": "f6f450768912f37b3d91730cc38f43b399eab781e585bdbe6c9bb29458b56f4c",
"wall-junctions-plan-t-dark": "6c3748e931407079e3aad3ade77c46069ef75444c27405549d1610670a33ad95",
"wall-junctions-view-dark": "ad23e5fb0ca9ba23f58ee4839cec489e7d05a28ef193d424b451a4020ef8ce0b",
"junction-patch-resilience-plan-dark": "80d032f5962dce27cd3577972e3d651d41080dae22c114fde8aec4c4b605f24e",
"junction-patch-resilience-plan-dark": "8ad87deb245097cd090f301d259710323cf4ea577cd8edaf16607d6ad8a3d29d",
"junction-patch-resilience-view-dark": "4ba710fd6859f958ad6c576a01d0b240a3b1723fe9df54f8cabd971414a943a6",
"wall-union-isolation-view-light": "426ef2ada0e69febd73ab305aa99b600a95e69db68dc30dae4c5288ee1151604",
"wall-union-isolation-view-dark": "215ed9d8cba31c5de4cb9d0e2b347bf0c96c8d1bba4d35feca57d6f66fc25322",
@@ -57,27 +72,27 @@
"orthogonal-strip-cell-5-view-dark": "05cdd744d09025719eda4c5e68cf3ebc5d7d201afd3028da91262d0f4874c126",
"orthogonal-strip-cell-1-view-dark": "9c0f91b11d47f1c5df4476774ae6028db21dc15cc6fb81d9f80009e0fa86bffe",
"wall-key-roundtrip-view-dark": "cc6f755c03b1a586684627fc1dc27eb1aa28d2673a6c79bcb687da94a6bf9b3b",
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
"opening-placement-door-thick-wall-dark": "d986d82fe68bec4b0b0556870d3ec7de280a00acb42cf0165a3d64f91f994bed",
"opening-placement-passage-thick-wall-dark": "f53908dbdf8cf6681c97317398e8a04cd98ebacdbc81571c2d15d38b2599e049",
"opening-placement-passage-thick-wall-light": "f7bdf033bbc511b34269044c450f8c93a3493e27bbbcf758050b7798197efd80",
"isometric-wall-junctions-dark": "af692b2c01293c59cc74e5731309e6612c913ca1c8384572fd1cb67a19d0f902",
"opening-placement-door-thick-wall-dark": "9d587eff0c2561ef5f84e9280cd812fa6e9af7cf6ff00c922d275796774ba513",
"opening-placement-passage-thick-wall-dark": "6ed48b39c0e11fbf4b0df7d3e4dad8078e5b17694f1a780936a864bebd0c1c57",
"opening-placement-passage-thick-wall-light": "3d4e3a5d9b67f2450fab9ccc0f5dc91f4485080582377de904f16ea1d62fbafa",
"opening-symbol-room-wall-light": "c487c11f12b2bba1473559a82af3320b7995fa9c8d1b379274b942e431930beb",
"opening-symbol-diagonal-partition-dark": "77101350b15845207644b6df86a3bc5f48b4dc873b9cfd7443c0d23507412de3",
"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",
"geometry-decor-editor-dark": "4023e36ff4521011153983538e4a43ad80efd92bf07d523c333982bbd2f80ad6",
"isometric-opening-symbol-parity-dark": "e2ead596818f6182bd2478dc3823f31312d361080c99afbb9c7f7d1862cf781c",
"geometry-devices-editor-dark": "e273c4fe49619a5856a9212ff6261803640e5afa79b841edfccf5a4056e0264d",
"device-inbox-desktop-en-light": "a73761da0594ba2138f098d971d74566f9915f59a563fd55295add241145ba36",
"device-inbox-desktop-ru-dark": "5a9eb32ffb7f408c6bf8adca9d76b0d01d7b0b514f8b33a01e500f9230541f1a",
"device-inbox-narrow-ru-dark": "65c46b7311f850f305147d75a24ddeded67c9e8b18ea362c88b9964c50a5b727",
"geometry-decor-editor-dark": "2f8a3742166c10c0a5aa1239c9b6b7d908e7e578dc43229274b17d015c47503c",
"tray-wide-selection-en": "5a535b457b8b274ff25085ee992aebd95d756415afe1321d0aadee70610d5801",
"tray-wide-tool-ru": "56c09bb397b76b7e5c6f4d7eaf7ab59e5d96faa409e553c45c6ce7605e9a9418",
"tray-medium-group-en": "b38abb69d1fac7414fc24d6cda6b5698eb861f8af94054605902e8529fe1e993",
"tray-medium-selection-ru": "b53f9513b167a33cffed2177603ae5b562264a83d5544d938c2af42ec8a551a5",
"tray-wide-tool-ru": "99abefe9c5b0f21356e87b46fc6d6d615cf9b799614c14bf8fffa0261fd9059a",
"tray-medium-group-en": "afdb651c687cabda48e62d426b9ef4926a23caa86904fe6fa2c163930070b2bd",
"tray-medium-selection-ru": "5d78f5d818514ab80a4fb2949274edffd372e0d2e41bc3ccf39a22fd7c993f6b",
"tray-narrow-palette-en": "ae23f1b1384bec23ec272156cb2b7d3fe4088addaefa2f575bc1daa0480569a1",
"furniture-categories-light": "02ce65879aca968d05c7400e32d3f38c73a53f3bc036ee306fdf851ed84ed88c",
"furniture-variants-dark": "f52db569690a67e84701aeb79cc8967c9ee117b543fd9bf3d6fb8c3fee7a660b",
"furniture-variants-light": "4a78f688423f0f0946f50ec1a92fe9a3de5bfa288c7e153425c1beadf5c5f839",
"furniture-categories-light": "1aae9b6166146b06c6571730761daed5690ea50769e6dc618a66c19339f5d834",
"furniture-variants-dark": "aa15801cc56ab488e9563e05d15694db48294dd4f3d7e5bc238b4f79ec8d9b7b",
"furniture-variants-light": "286269d08ddcc086747d0703a59cd03bac29b11b69839364c01cb8492a00602d",
"furniture-placement-preview-light": "f983eda1bf42e27d17afb78eada8b96b192a7e91bebb0c70e6766994000aabe3",
"furniture-plan-art-dark": "e83a8c40add2bf33af304f86ad29fc77d8d47bd7228f17c152e962f3431e0c50",
"furniture-transform-light": "3e37e03457bac87c9d7eaf6bbeb0f4054fb6e6a66a7da5953714446a92ccf296",
@@ -133,33 +148,35 @@
"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",
"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": "ba147ee0a21d0ad0986fbdf933b4b36b8c8ae7dee005efb8befe7fc59a9dc28d",
"settings-help-zoom-200-en-light": "9f5e2ae5ac30c99c52870cebd5114096b413a6e4a5ee060b87f3a0f327065ee6",
"settings-help-zoom-200-ru-dark": "07b3a635617d9b592c7b51e3a34bd1f2b88447814b60d694746ba85bd178520d",
"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",
"device-dialog-desktop-en": "f3a3a45dad2cc22e692366114a4f87272b39ba261c1509e8a501dbfb82b6584e",
"device-dialog-mobile-ru": "7ac796286ab4e65275838b4095ea3f16491311f99fd98cad3bbb475c6061e31c",
"device-dialog-desktop-de": "9d39d3e73a3f36171e8cc7c930bcb43bfbfd788e31db0f584962300a2b00ca93",
"room-temperature-dialog-desktop-en": "b496e84e960f7b42eb38094ebaadeff8da146ce43c2f21405ddebf59b8561465",
"room-temperature-dialog-mobile-ru": "2c3bd451394e00c539aca68a1a27979d3c3fb6f5cc0e6fb189545b884458c59e",
"german-view-mobile-light": "dc252c1a0ced7c636dfbd5cc3113abe546d568b9c62e4812326ab604dfd4e5cd",
"toggle-entity-dialog-desktop-en": "3ea2bb828b554b6fb544b77fdef2af947e45af632769f4deab03c6adf195fa68",
"toggle-entity-dialog-mobile-ru": "2d433f3f1fab9e9d4c3d413c302ac0d3b44a2d46ec9ecbf098c3063da6f1df04",
"device-help-popover-light-ru": "1905d843c715b5d1f1dbfe06ca5cbcb8f0fea27f26301bfc1b826227334d10b2",
"decor-color-popover-mobile-ru": "dbe9b01e848d3f2776ac74c6bad0134c978b04c07c37b674ca7c823aff5fcd33",
"decor-color-popover-desktop-en": "034cf59419f2e940e5fd05be10f441714291a90a2043a7cfcd6e1cda249a1292",
"general-color-popover-desktop-en": "16c7b7c206970a6f36c5c70cf493d3e04ea6509a89c65751c83842096c1550d6",
"settings-help-zoom-200-en-light": "a6f49b4d0a171dc5f74d409c6e7df0e1c57bc52086b3a66dd794efde1236eef8",
"settings-help-zoom-200-ru-dark": "90e9052fa339ada6a8c05b1732a5786bee9b61266d1a9305f316c4fc35c03df2",
"support-desktop-empty-light-en": "87d6db3b1d5a5d58034ddba0612c59bc1d3ce42ad43e812a6c28ce42a38ff499",
"support-desktop-preview-dark-en": "4ab4a46a6f06064df60f3bb7ba62229398061c9407380050269d7523b502a2d4",
"support-phone-validation-light-ru": "37059c99431d6890d1f1a66444c7e853e51387655cd1ee6474063299281f2b1f",
"support-phone-success-dark-en": "8471706c17e14d199e6d8ca329d4cfe15cb7910f9d0aec28bfd839f49e002612",
"support-relay-error-light-en": "ea6cfd6385561562eea3300b802e5036ac63dfb21bad7e9b7bb57e4dbb5e605e",
"support-tablet-preview-dark-ru": "549310cc299c346f5495abbd386e5e56b011c812d815cbbe39871165cf9d7981",
"device-ripple-color-popover-mobile-ru": "93d04a498e16ac7e98bd420bef566645c2215488138568c5b150110380aa782e",
"space-room-color-popover-desktop-ru": "c4cb215714ce98728c2e7b9d3f083055650b639302578874d28067c741ba0cf8",
"backup-full-preview-desktop-en": "c8fbd39e63e6ff3f8158ae36ae4ef299ad7635bf41dc490547cd6ad56aeffdfc",
"backup-plan-only-export-desktop-en": "cf00fa1784b7a4c4aa3153802ca6a79afbe988ae1b70a9c5ba1983aba0863b4f",
"backup-space-preview-mobile-ru": "4575ba8783647518abbbe9903fd915ec0d3caceaa08a70fa70cf8bf8e7b08356",
"optimize-preflight-dialog-dark-en": "fa16f7caa6ab75db6a76ada7d21450cee488900a7dc70505867bed7c6f5325d5",
"optimize-preflight-dialog-light-ru": "14ff81985b7564e23f9d1124c2be44a31b017186691745a6092fe918e79328e1",
"optimize-orphan-references-dark-en": "d6a59ae1f7accef832ac6458c618ab4719d110c0154a4d607454fc7dab34d8a1",
"optimize-orphan-references-light-ru": "88455ebb34a052abcc75cd4454f1256cebb11ee0a8b880b8507235c332f8ae14",
"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: 181 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: 181 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: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

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