Compare commits

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

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

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

Что сделано:

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

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

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

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

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

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

Issue: #455
User-Visible: no
2026-09-04 19:26:35 +03:00
claude[bot] 9e56842bc1 docs: review document for #449
Проверка (CI) / Классификация изменённых файлов (push) Successful in 37s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 36s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 57s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Issue: #449
User-Visible: no
2026-09-04 16:23:07 +00:00
Sergey Matyunin 1d8edf4a01 docs: refresh screenshot fingerprint for double fit
Issue: #449
User-Visible: no
2026-09-04 19:09:47 +03:00
Sergey Matyunin aa51ed2bf5 fix: complete double background fit delivery
Issue: #449
User-Visible: no
2026-09-04 19:06:29 +03:00
claude[bot] a158d75712 docs: review document for #449
Issue: #449
User-Visible: no
2026-09-04 16:01:44 +00:00
Sergey Matyunin 05ef318133 feat: fit all on double background tap
Issue: #449
User-Visible: yes
2026-09-04 18:33:03 +03:00
claude[bot]andSergey Matyunin b39f99b3a4 docs: review document for #449
Issue: #449
User-Visible: no
2026-09-04 18:18:14 +03:00
CodexandSergey Matyunin 85a064430b docs: name the three editors as the guide does
User-Visible: no
Issue: #449
2026-09-04 18:18:14 +03:00
claude[bot]andSergey Matyunin 070c276e26 docs: review document for #449
Issue: #449
User-Visible: no
2026-09-04 18:18:14 +03:00
CodexandSergey Matyunin 1a7488d695 docs: state the touch editor status in the double fit-all spec
User-Visible: no
Issue: #449
2026-09-04 18:18:14 +03:00
claude[bot]andSergey Matyunin 1ce62613ed docs: review document for #449
Issue: #449
User-Visible: no
2026-09-04 18:18:14 +03:00
Sergey Matyunin b8a31e90ca docs: specify double gesture fit all
Issue: #449
User-Visible: no
2026-09-04 18:18:13 +03:00
claude[bot] 5da19f1793 docs: review document for #450
Issue: #450
User-Visible: no
2026-09-04 15:06:10 +00:00
Sergey Matyuninandclaude[bot] a8ccb2b90f docs: refresh screenshot source fingerprint
Issue: #450
User-Visible: no
2026-09-04 14:58:04 +00:00
Sergey Matyuninandclaude[bot] 8935a736cb fix: accept Zigbee2MQTT raw network maps
Issue: #450
User-Visible: yes
2026-09-04 14:58:04 +00:00
Codex 411a0a20a9 docs: call the fit-all button by its real name in the Russian guide
Проверка (CI) / Классификация изменённых файлов (push) Successful in 23s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 42s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 49s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 19s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 17s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 7m1s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 9m36s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
User-Visible: no
Issue: #452
2026-09-04 17:54:42 +03:00
claude[bot] 89647c10bd docs: review document for #448
Issue: #448
User-Visible: no
2026-09-04 14:00:07 +00:00
Sergey Matyunin ed4ac972b3 docs: close alpha review findings
Issue: #448
User-Visible: no
2026-09-04 16:56:50 +03:00
claude[bot] ee9b28ed98 docs: review document for #448
Issue: #448
User-Visible: no
2026-09-04 13:55:34 +00:00
Sergey Matyunin 658f284b44 docs: refresh alpha screenshot source fingerprint
Issue: #448
User-Visible: no
2026-09-04 16:33:56 +03:00
Sergey Matyunin 169fed01f0 feat: replace Labs expiry with persistent alpha switch
Issue: #448
User-Visible: yes
2026-09-04 16:31:06 +03:00
claude[bot]andSergey Matyunin 7230e4248a docs: review document for #448
Issue: #448
User-Visible: no
2026-09-04 16:20:35 +03:00
Sergey Matyunin 0ec0785dfd docs: clarify alpha touch contract
Issue: #448
User-Visible: no
2026-09-04 16:20:34 +03:00
claude[bot]andSergey Matyunin e846833ed6 docs: review document for #448
Issue: #448
User-Visible: no
2026-09-04 16:20:34 +03:00
Sergey Matyunin ed0fdd4859 docs: specify persistent alpha switch
Issue: #448
User-Visible: no
2026-09-04 16:20:34 +03:00
Sergey Matyunin 112ec0eb66 test: accept Zigbee settings golden frames
Issue: #54
User-Visible: no
Release: v1.72.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33875831578
2026-09-04 16:11:16 +03:00
Sergey Matyunin 775e55f0ed build: prepare v1.72.0-beta.1 candidate
Issue: #54
Issue: #152
Issue: #447
User-Visible: yes
2026-09-04 16:01:33 +03:00
claude[bot] 3f9ef807d8 docs: review document for #54
Issue: #54
User-Visible: no
2026-09-04 12:39:19 +00:00
Sergey Matyunin f0ed526a59 docs: refresh screenshot source fingerprint
Issue: #54
User-Visible: no
2026-09-04 15:30:04 +03:00
Sergey Matyunin 4203a01b04 fix: reject malformed Zigbee topology replies
Issue: #54
User-Visible: no
2026-09-04 15:26:06 +03:00
claude[bot] aab5c3ca95 docs: review document for #54
Issue: #54
User-Visible: no
2026-09-04 12:20:34 +00:00
Sergey Matyunin a2867df9a2 docs: refresh screenshot source fingerprint
Issue: #54
User-Visible: no
2026-09-04 14:48:27 +03:00
Sergey Matyunin 2202b20730 refactor: type Zigbee topology boundaries
Issue: #54
User-Visible: no
2026-09-04 14:45:39 +03:00
Sergey Matyunin 85702afc0c feat: add contextual Zigbee topology hover
Issue: #54
User-Visible: yes
2026-09-04 14:40:09 +03:00
claude[bot] e5587aed7c docs: review document for #54
Issue: #54
User-Visible: no
2026-09-04 11:11:49 +00:00
Sergey Matyunin aed0b5b65a docs: address zigbee spec review
Issue: #54
User-Visible: no
2026-09-04 14:06:18 +03:00
claude[bot] c53702cdfa docs: review document for #54
Issue: #54
User-Visible: no
2026-09-04 11:05:11 +00:00
Sergey Matyunin a6a3df9310 docs: specify contextual zigbee links
Issue: #54
User-Visible: no
2026-09-04 13:53:07 +03:00
claude[bot] ec9824f227 docs: review document for #152
Проверка (CI) / Классификация изменённых файлов (push) Successful in 29s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 49s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 50s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Issue: #152
User-Visible: no
2026-09-04 08:41:12 +00:00
Sergey Matyunin 260767e2f8 docs: accept reviewed room fit fixes provenance
Issue: #152
User-Visible: no
2026-09-04 11:26:36 +03:00
Sergey Matyunin 3a1f2b2d86 fix: address room fit code review
Issue: #152
User-Visible: no
2026-09-04 11:20:41 +03:00
claude[bot] 6eea4d1c7a docs: review document for #152
Issue: #152
User-Visible: no
2026-09-04 08:16:21 +00:00
Sergey Matyunin a54addb694 docs: accept final room fit screenshot provenance
Issue: #152
User-Visible: no
2026-09-04 10:43:27 +03:00
Sergey Matyunin 6a7ef48ae8 fix: preserve editor pointer cancellation
Issue: #152
User-Visible: no
2026-09-04 10:37:42 +03:00
Sergey Matyunin 6190234c51 docs: accept room fit screenshot provenance
Issue: #152
User-Visible: no
2026-09-04 10:32:58 +03:00
Sergey Matyunin 39ba4e2b88 test: exercise shared room action guard
Issue: #152
User-Visible: no
2026-09-04 10:26:50 +03:00
Sergey Matyunin 15cfa96553 test: fix room fit ownership witness
Issue: #152
User-Visible: no
2026-09-04 10:25:32 +03:00
Sergey Matyunin 4aad9facdf feat: fit rooms from view
Issue: #152
User-Visible: yes
2026-09-04 10:24:25 +03:00
claude[bot] 81026539d3 docs: review document for #152
Issue: #152
User-Visible: no
2026-09-04 06:53:57 +00:00
Sergey Matyunin f672de674f docs: revise room click fit specification
Проверка (CI) / Классификация изменённых файлов (push) Successful in 49s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 49s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 5m3s
Issue: #152
User-Visible: no
2026-09-04 09:37:44 +03:00
claude[bot]andSergey Matyunin 68992d5582 docs: spec review document for #152
Issue: #152
User-Visible: no
2026-09-04 09:29:35 +03:00
Sergey Matyunin f29c4c5b79 docs: specify room click fit
Issue: #152
User-Visible: no
2026-09-04 09:29:35 +03:00
claude[bot] d5bfa0e3e1 docs: review document for #447
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m5s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 22s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 25s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 42s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 16s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 7m7s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 8m25s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #447
User-Visible: no
2026-09-04 06:21:59 +00:00
Sergey Matyunin f62dd6b632 docs: accept screenshots for #447
Issue: #447
User-Visible: no
2026-09-04 09:09:20 +03:00
Sergey Matyunin 6b07b70d7c fix: preserve exterior furniture side and nudge decor
Issue: #447
User-Visible: yes
2026-09-04 08:58:48 +03:00
claude[bot] 62ecb8566e docs: review document for #447
Issue: #447
User-Visible: no
2026-09-04 05:48:58 +00:00
Sergey Matyunin 818ad2d2bb docs: distinguish render and stored decor nudge steps
Issue: #447
User-Visible: no
2026-09-04 08:40:44 +03:00
claude[bot] 6c668d4bfe docs: review document for #447
Issue: #447
User-Visible: no
2026-09-04 05:39:54 +00:00
Sergey Matyunin a1fbea07d2 docs: specify exterior furniture snap and keyboard nudge
Issue: #447
User-Visible: no
2026-09-04 08:28:20 +03:00
Sergey Matyunin effb36295d fix(gates): audit beta.2 golden provenance
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 2m0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 1m47s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 1m52s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 1m45s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 1m47s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 1m49s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 21s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 36s
Проверка (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) Successful in 4m56s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 21m0s
The immutable beta.2 candidate changed one version-bearing golden frame without the two baseline trailers. Its complete Linux Validate successor proved the exact baseline tree; record that one SHA as the only historical exception while preserving every other provenance check.

Issue: #426
Release: v1.71.0
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33760450815
User-Visible: no
2026-09-04 03:38:26 +03:00
Sergey Matyunin 7c4cb187fe Release v1.71.0
Issue: #51
Release: v1.71.0
User-Visible: no
2026-09-04 03:16:27 +03:00
216 changed files with 35380 additions and 18230 deletions
+8
View File
@@ -37,6 +37,7 @@ jobs:
- large-house
- isometric
- plan-snap
- interaction
- blend
- overlay
- space-default
@@ -190,6 +191,10 @@ jobs:
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
;;
interaction)
npm run benchmark:large-house-interaction -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/interaction-baseline.json
npm run benchmark:large-house-interaction -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/interaction-candidate.json
;;
blend)
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
@@ -235,6 +240,9 @@ jobs:
plan-snap)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
;;
interaction)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-interaction.json --baseline=../artifacts/performance/interaction-baseline.json --candidate=../artifacts/performance/interaction-candidate.json --output=../artifacts/performance/interaction-comparison.json
;;
blend)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
;;
+80 -11
View File
@@ -43,9 +43,19 @@ jobs:
spent: ${{ steps.decide.outputs.spent }}
limit: ${{ steps.decide.outputs.limit }}
steps:
# Мелкий checkout: guard остаётся лёгким, но ему нужен
# scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что
# inline-shell не покрывается тестами (#454). Node на раннере
# предустановлен, setup-node не нужен.
- uses: actions/checkout@v7
with:
fetch-depth: 1
ref: dev
persist-credentials: false
- id: decide
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
REPO: ${{ github.repository }}
LABEL: ${{ github.event.label.name }}
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
@@ -87,19 +97,78 @@ jobs:
#
# Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал
# цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по
# имени документа в теле комментария; документа нет — вердикт не
# посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы
# работу досрочно: из двух ошибок выбрана обратимая.
# имени документа — раньше по подстроке маркера в теле комментария,
# теперь по имени документа ЭТОЙ задачи, `<MARKER>-<NUM>`: голая
# подстрока протекала на прозе. #454 поймала это на себе — разбор
# чужих задач в комментарии содержал `CODE-REVIEW`, и первый же
# код-ревью получил заход r3.
#
# Счёт по комментариям остаётся ровно тем же, но он БОЛЬШЕ НЕ
# ЕДИНСТВЕННЫЙ (#454). Маркер этапа попадает в тело комментария,
# только если ревьюер сам назвал имя файла, — то есть прежний счёт
# зависел от формулировки. На #449 первый спек-вердикт файла не
# назвал, заход r2 получил номер r1, и документ второго раунда лёг
# ПОВЕРХ документа первого. Оценка «недосчёт обратим» была неверна
# ровно здесь: номер захода входит в имя файла, и повтор номера — не
# лишний заход, а потеря артефакта.
#
# Поэтому рядом встаёт второй источник — опубликованные документы:
# их имена несёт сам конвейер, и подделать их прозой нельзя. Берётся
# МАКСИМУМ двух источников: недосчёт возможен только при отказе
# обоих, перерасчёт невозможен по построению.
attempt=1; spent=0; spent_list=""
if [ -n "$stage" ]; then
comments=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json comments)
of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))]"
# Блокирующим считается вердикт, у которого в строке вердикта стоит
# «жёлтый» или «красный». Регистр и окружение слова не важны.
blocking="$of_stage | map(select(.body | test(\"Вердикт:[^\\n]*(жёлт|красн)\"; \"i\")))"
attempt=$(( $(printf '%s' "$comments" | jq -r "$of_stage | length") + 1 ))
spent=$(printf '%s' "$comments" | jq -r "$blocking | length")
spent_list=$(printf '%s' "$comments" | jq -r "$blocking | map(\"- \" + .url) | join(\"\\n\")")
comments=$(mktemp)
gh issue view "$NUM" --repo "$REPO" --json comments > "$comments"
# Ветка задачи — та же, что выберет шаг ревью: свежая по коммиту.
# Её нет у задач, размеченных до появления конвейера; тогда счёт по
# файлам даёт ноль и работает страховка по комментариям.
branch=""; newest=""
for ref in $(gh api "repos/$REPO/git/matching-refs/heads/issue/$NUM-" \
--jq '.[].ref' 2>/dev/null | sed 's|^refs/heads/||'); do
date=$(gh api "repos/$REPO/commits/$ref" --jq '.commit.committer.date' 2>/dev/null || true)
if [ -n "$date" ] && { [ -z "$newest" ] || [ "$date" \> "$newest" ]; }; then
newest="$date"; branch="$ref"
fi
done
target="${branch:-dev}"
names=$(mktemp); docs=$(mktemp -d)
if ! gh api "repos/$REPO/contents/docs/reviews?ref=$target" --jq '.[].name' \
> "$names" 2>/dev/null; then
: > "$names"
echo "::warning::список docs/reviews на $target не получен — счёт по файлам отключён"
fi
# Каталог перечисляется одним ответом до 1000 записей; за этой
# границей ответ молча обрежется, и счёт по файлам занизится.
if [ "$(grep -c . "$names")" -ge 1000 ]; then
echo "::warning::в docs/reviews не меньше 1000 файлов — листинг contents обрезается, счёт по файлам ненадёжен"
fi
# Тела нужны только своим документам этапа: их единицы.
for name in $(grep -E "^${marker}-${NUM}-r[0-9]+\\.md$" "$names" || true); do
gh api "repos/$REPO/contents/docs/reviews/$name?ref=$target" \
-H 'Accept: application/vnd.github.raw' > "$docs/$name" 2>/dev/null || rm -f "$docs/$name"
done
list=$(mktemp)
counters=$(node scripts/review-doc-guard.mjs --counters \
--marker="$marker" --num="$NUM" --names="$names" --docs="$docs" \
--comments="$comments" --spent-list="$list")
spent_list=$(cat "$list")
# Пустой ответ означает, что скрипт не отработал. Тогда остаются
# значения по умолчанию (заход 1, циклов 0): guard обязан
# продолжить работу, а не встать.
new_attempt=$(printf '%s\n' "$counters" | sed -n 's/^attempt=//p')
new_spent=$(printf '%s\n' "$counters" | sed -n 's/^spent=//p')
new_blocking=$(printf '%s\n' "$counters" | sed -n 's/^blocking=//p')
case "$new_attempt" in ''|*[!0-9]*) echo "::warning::счётчик раундов не дал числа — работают значения по умолчанию" ;; *) attempt="$new_attempt" ;; esac
case "$new_spent" in ''|*[!0-9]*) : ;; *) spent="$new_spent" ;; esac
# Перечень учтённого обязан сходиться с числом: если цикл виден
# только документом, ссылка на комментарий его не объяснит.
if [ -n "$new_blocking" ]; then
spent_list="$spent_list
- документы: $new_blocking"
fi
echo "ветка материала: ${branch:-нет, читался dev}"
fi
# Отказ обязан быть виден в issue, а не только в логе прогона.
+32 -15
View File
@@ -344,9 +344,27 @@ whole review round that a local run would have caught immediately.
The full smoke set, `golden` and `performance_smoke` still belong to the
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
the beta gate is CI at the exact SHA.
**Verifying and capturing are different things (#455).** `golden:verify` is
advisory and legal anywhere, Windows included: it reports differences and
accepts nothing. **Capturing** frames is refused outside Linux
before the browser even starts — `golden:capture` through
`demo/golden/policy.mjs`, documentation screenshots through
`npm run docs:capture` (use that script, not a bare `node
demo/docs/capture.mjs`: the gate sits one step earlier because editing the
capture script invalidates the committed screenshot index). The refusal prints
the WSL command. The reason is not policy but physics: Windows
rasterizes text through DirectWrite with different subpixel and DPI behaviour,
so no frame ever matches an accepted baseline byte for byte, no environment
witness can exist, and acceptance would refuse anyway (#401 accepts any
environment that proves itself with byte-identical undeclared frames — Linux is
simply the only one we have). The deliberate override is
`HP_ALLOW_FOREIGN_CAPTURE="reason"`; the reason travels into the output and the
manifest. Baselines are still accepted only via
`npm run golden:accept -- --reviewed` on a complete artefact, and the accepted
index records the platform next to the Chromium build.
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
@@ -399,21 +417,20 @@ Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It
reproduces against the pristine committed bundle, so treat it as
pre-existing/pixel-precision, not a regression you introduced.
## Labs flags
## Alpha experiments
`src/labs.ts` is the single registry and resolver for hidden presentation
experiments. Activate a live flag through `?hp-labs=<id>` or the shared hash
grammar, remove it with `-<id>`, and use `off` to clear the set. Do not add a
YAML/config switch for a Labs-only experiment. A new entry needs a unique
lowercase id, issue, numeric-core `since`, numeric-core `expires`, summary and
unit/browser coverage. Invalid or duplicate registry entries fail closed.
experiments. One browser-local switch controls the complete set known to the
installed build: `hp_alpha=1` in query or the shared hash grammar enables and
persists it, while `hp_alpha=0` disables and persists it. Do not add per-feature
URL/storage keys or a YAML/config switch. The legacy `hp-labs` grammar and
`houseplan_card_labs_v1` storage are ignored and never migrated.
Expiry is exclusive and ignores prerelease suffixes: an entry expiring at
`1.65.0` is unavailable in `1.65.0-beta.1`. Before that cycle, either remove the
experiment or graduate it through its own reviewed issue; never extend expiry as
an incidental change. Labs may alter presentation only and must not gate data,
migrations, stores, HA actions or network calls. Current renderer details are in
`docs/ISOMETRIC.md`.
A new registry entry needs a unique lowercase id, issue, summary and
unit/browser coverage; it does not get `since` or `expires`. Invalid or duplicate
entries fail closed. Alpha capabilities may alter presentation only and must not
gate data, migrations, stores, HA actions or network calls. Current renderer
details are in `docs/ISOMETRIC.md`.
Demo harness render quirk: the fake `hass` in `demo.html` is set once, so opening the
page directly in a browser renders the floor plan but **device icons only appear
+1 -1
View File
@@ -51,7 +51,7 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
ASSETS_DIR = "houseplan/assets"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.71.0-beta.4"
VERSION = "1.72.0-beta.1"
DECOR_ASSETS_API_VERSION = 1
# #43: the support transport is deliberately not configurable. A user supplied
@@ -1,125 +1,182 @@
{
"schema": 1,
"fingerprint": "9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15",
"fingerprint": "3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-n2PxGqZY.js",
"houseplan-assets/houseplan-card-CeQyO4TX.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 292752,
"initialViewGzipBytes": 296719,
"lazyFiles": [
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
"houseplan-assets/de-BP_2FY3A.js",
"houseplan-assets/editor-BoI8ovCT.js",
"houseplan-assets/fr-DOlNp4ji.js",
"houseplan-assets/houseplan-editor-runtime-De35hz5H.js",
"houseplan-assets/houseplan-onboarding-runtime-dO-9we_v.js"
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
"houseplan-assets/de-B9ewWWX2.js",
"houseplan-assets/editor-CDcQqv3k.js",
"houseplan-assets/fr-4rsWX0CK.js",
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
"houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
"houseplan-assets/live-interaction-runtime-ClI0mgY3.js",
"houseplan-assets/zigbee-topology-Q4h3gz_F.js",
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
],
"lazyGzipBytes": 223409,
"lazyGzipBytes": 240651,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
"houseplan-assets/editor-BoI8ovCT.js",
"houseplan-assets/houseplan-editor-runtime-De35hz5H.js"
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
"houseplan-assets/editor-CDcQqv3k.js",
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
],
"lazyEditorGzipBytes": 167369,
"lazyEditorGzipBytes": 178324,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
"houseplan-assets/houseplan-onboarding-runtime-dO-9we_v.js"
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js"
],
"lazyOnboardingGzipBytes": 14244,
"lazyOnboardingGzipBytes": 14239,
"lazyLocaleFiles": [
"houseplan-assets/de-BP_2FY3A.js",
"houseplan-assets/fr-DOlNp4ji.js"
"houseplan-assets/de-B9ewWWX2.js",
"houseplan-assets/fr-4rsWX0CK.js"
],
"lazyLocaleGzipBytes": 49080,
"lazyLocaleGzipBytes": 49104,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DMGa8wc7.js",
"sha256": "6226595cb5753e94dbba8a99943f5d7133eb19cfb43d2c7af574b2cd1c995c7a",
"rawBytes": 21418,
"gzipBytes": 7284,
"path": "houseplan-assets/backdrop-pick-Br9hp4fM.js",
"sha256": "473ca1094a2f837fffc71026ba5f9c5787a6a1e18db7b37328f586bf7bdeb73a",
"rawBytes": 21417,
"gzipBytes": 7280,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-n2PxGqZY.js"
"houseplan-assets/houseplan-card-CeQyO4TX.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-BP_2FY3A.js",
"sha256": "0925d353b13fb9ed03f06fa910fb44f9f7460f1edf8efaf1299c91117d223697",
"rawBytes": 82140,
"gzipBytes": 24809,
"path": "houseplan-assets/de-B9ewWWX2.js",
"sha256": "3c291653c70e131993810e78b7ace06a10758b9fbdfa54636f890b36aa8cfc1a",
"rawBytes": 82182,
"gzipBytes": 24822,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-BoI8ovCT.js",
"sha256": "4c882564e63a08f64b1b34ad231bd85d897e12cd2973a1ffcbcfe0d091ceff02",
"rawBytes": 3826,
"gzipBytes": 1585,
"path": "houseplan-assets/editor-CDcQqv3k.js",
"sha256": "76a014106e36d05f727ecc2bfb654708df0d5d3db54d498bb58254eb9ebec15b",
"rawBytes": 3831,
"gzipBytes": 1583,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-n2PxGqZY.js"
"houseplan-assets/houseplan-card-CeQyO4TX.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-DOlNp4ji.js",
"sha256": "d54f2c24b83c924eba22439817b54d7793af00d28fd4f16e18e0cf35e8737ea3",
"rawBytes": 84267,
"gzipBytes": 24271,
"path": "houseplan-assets/fr-4rsWX0CK.js",
"sha256": "b0508cefcba06761e14436fd8ab8bef916c0bdcb70a71e1d338ba7722057d6f1",
"rawBytes": 84312,
"gzipBytes": 24282,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-n2PxGqZY.js",
"sha256": "f2e5ff91866347e77fe2d924294e119651f41641356d1f761188f175500c2af1",
"rawBytes": 1039630,
"gzipBytes": 291954,
"path": "houseplan-assets/houseplan-card-CeQyO4TX.js",
"sha256": "24597a66c6c0b7ddb3859a5bc2e8b58e08ef57d4612d0cf9222895122edfe92b",
"rawBytes": 1053904,
"gzipBytes": 295923,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-BP_2FY3A.js",
"houseplan-assets/editor-BoI8ovCT.js",
"houseplan-assets/fr-DOlNp4ji.js",
"houseplan-assets/houseplan-editor-runtime-De35hz5H.js",
"houseplan-assets/houseplan-onboarding-runtime-dO-9we_v.js"
"houseplan-assets/de-B9ewWWX2.js",
"houseplan-assets/editor-CDcQqv3k.js",
"houseplan-assets/fr-4rsWX0CK.js",
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
"houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
"houseplan-assets/live-interaction-runtime-ClI0mgY3.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-De35hz5H.js",
"sha256": "9f44dafa722abba6a47073110496e99d178ee661f38bed2e189280f629980f73",
"rawBytes": 601529,
"gzipBytes": 158500,
"path": "houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
"sha256": "fe6417012721db8c1124e8d1a1b9f037db6edd8edb1ccf328bbc1c9697186cbd",
"rawBytes": 622491,
"gzipBytes": 164615,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
"houseplan-assets/houseplan-card-n2PxGqZY.js"
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
"houseplan-assets/houseplan-card-CeQyO4TX.js",
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
],
"dynamicImports": [
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
"sha256": "69cf99a15ce74f74a309241f88f2cf0eade072507f2ec463560956367549d5d2",
"rawBytes": 28088,
"gzipBytes": 6959,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
"houseplan-assets/houseplan-card-CeQyO4TX.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-dO-9we_v.js",
"sha256": "429df2912042da7959c13847d96b6a957a5e097c2899b82e82bb0f9820e86067",
"rawBytes": 28088,
"gzipBytes": 6960,
"path": "houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
"sha256": "e64d8d0dae12904eba4aa3f87fb7f046962091eb7284016e0442c838be3cdfb2",
"rawBytes": 6006,
"gzipBytes": 2376,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
"houseplan-assets/houseplan-card-n2PxGqZY.js"
"houseplan-assets/houseplan-card-CeQyO4TX.js",
"houseplan-assets/zigbee-topology-Q4h3gz_F.js",
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/live-interaction-runtime-ClI0mgY3.js",
"sha256": "05f378c2eef76f35ea98d05821b4680cae1bd599e410d27fbe69ad5ad76eb0d6",
"rawBytes": 5196,
"gzipBytes": 2127,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CeQyO4TX.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-Q4h3gz_F.js",
"sha256": "c6f7dc8aad18071bd7766901c382201b0252725fae2c77993483030b3f73ca67",
"rawBytes": 12382,
"gzipBytes": 4846,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CeQyO4TX.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-runtime-CVolox2I.js",
"sha256": "e4dcb60eb07d78c23782ac134e498fff6b348f4f35a2a7820a13582ceb1887fa",
"rawBytes": 3941,
"gzipBytes": 1761,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CeQyO4TX.js",
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "8c663d28d60b83468ee17e7b8e6891d9579e96e1c8c89cd5ca2ecd23c89e84a3",
"sha256": "03b15da3629659eba3779885ccf8f0206455ba95378dd1fa246c8ea4bb372a8e",
"rawBytes": 1183,
"gzipBytes": 798,
"gzipBytes": 796,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-n2PxGqZY.js"
"houseplan-assets/houseplan-card-CeQyO4TX.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-n2PxGqZY.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
@@ -0,0 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{v as e,l as o,z as t,dt as s,A as a,du as i,dv as l,dw as n,E as r,c}from"./houseplan-card-CeQyO4TX.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15";import{l as t,aQ as s,A as e,t as o,c as a,cd as l,ce as i,cf as h,cg as c,aN as n,ch as r,cm as p,ci as d,cj as _,ck as u,cl as g,dc as m,aM as b,aO as $,dd as v}from"./houseplan-card-n2PxGqZY.js";import{i as f,c as y,e as w,r as D,a as k,b as S,d as C,t as M}from"./backdrop-pick-DMGa8wc7.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{l as t,a$ as s,A as e,z as o,c as a,cj as l,ck as i,cl as h,cm as c,aY as n,cn as r,cs as p,co as d,cp as _,cq as u,cr as g,dj as m,aX as b,aZ as $,dk as v}from"./houseplan-card-CeQyO4TX.js";import{i as f,c as y,e as w,r as D,a as k,b as S,d as C,t as M}from"./backdrop-pick-Br9hp4fM.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,p="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:r(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:p,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=p(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:_,tempMax:d,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){const s=this.host._spaceDialog,e=s?.saved?.find(s=>s.name===t);if(!s||!e||e.used_by.length||e.url===s.planUrl)return;const o=await this.host._confirmDanger({key:"delete-plan",kind:"destructive",title:this.host._t("confirm.delete_plan_title"),message:this.host._t("confirm.delete_plan_body"),objectName:t,confirmLabel:this.host._t("btn.delete"),cancelLabel:this.host._t("btn.cancel")}),a=this.host._spaceDialog,l=a?.saved?.find(s=>s.name===t);if(o&&a&&l&&l.url===e.url&&l.modified===e.modified&&!l.used_by.length&&l.url!==a.planUrl)try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
${s.map(s=>{return a`
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
@@ -0,0 +1,36 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{v as e,A as t,dx as i,w as s,c as o,l as r,u as n}from"./houseplan-card-CeQyO4TX.js";import{a,r as h,t as d}from"./zigbee-topology-Q4h3gz_F.js";import{zigbeeTopologyRuntimeSnapshot as p,subscribeZigbeeTopology as c}from"./zigbee-topology-runtime-CVolox2I.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,n=e&&e.runtime===this._runtime&&e.devices===this.devices&&e.registry===this.registry?e.mapped:a(this._runtime.topologies,this.devices,this.registry);e&&n===e.mapped||(this._mappedMemo={runtime:this._runtime,devices:this.devices,registry:this.registry,mapped:n});const p=h(n,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=n`
: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
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{n as t,b as n}from"./zigbee-topology-Q4h3gz_F.js";import{I as e}from"./houseplan-card-CeQyO4TX.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__="9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15";try{await import("./houseplan-assets/houseplan-card-n2PxGqZY.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__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";try{await import("./houseplan-assets/houseplan-card-CeQyO4TX.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)}
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.71.0-beta.4"
"version": "1.72.0-beta.1"
}
+449 -11
View File
@@ -15,13 +15,15 @@ const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const targetRoot = resolve(valueArg('target-root') ?? '.');
const profile = valueArg('profile') ?? 'large-house-v1';
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1', 'large-house-interaction-v1'].includes(profile))
throw new Error(`unknown large-house profile: ${profile}`);
const isometric = profile === 'large-house-isometric-v1';
const planSnap = profile === 'large-house-plan-snap-v1';
const interaction = profile === 'large-house-interaction-v1';
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
const requiresInteraction = interaction && existsSync(resolve(targetRoot, 'src/live-viewport.ts'));
const fixture = makeLargeHouseFixture();
if (planSnap) {
for (const [floor, space] of fixture.config.spaces.entries()) {
@@ -71,7 +73,7 @@ try {
const measuredSample = iteration - warmups;
const row = await page.evaluate(async ({
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
requiresWallFace,
requiresWallFace, interaction, requiresInteraction,
}) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
@@ -135,11 +137,11 @@ try {
window.__card?.remove?.();
localStorage.clear();
if (isometric) {
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(['iso']));
localStorage.setItem('houseplan_card_alpha_v1', '1');
localStorage.setItem('houseplan_card_view_v1', JSON.stringify(Object.fromEntries(
fixture.config.spaces.map((space) => [space.id, 'iso']),
)));
history.replaceState(null, '', '?hp-labs=iso');
history.replaceState(null, '', '?hp_alpha=1');
} else history.replaceState(null, '', location.pathname);
const host = document.getElementById('host');
const card = document.createElement('houseplan-card');
@@ -151,10 +153,10 @@ try {
subscribeEvents: async () => () => undefined,
subscribeMessage: async () => () => undefined,
};
const hassFor = (states) => ({
const hassBase = {
language: 'en', locale: { language: 'en' },
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas, states,
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas,
floors: {
one: { floor_id: 'one', name: 'One', level: 0 },
two: { floor_id: 'two', name: 'Two', level: 1 },
@@ -178,17 +180,19 @@ try {
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
});
};
const hassFor = (states) => ({ ...hassBase, states });
const loadLongTasks = startLongTaskWindow();
const loadStarted = performance.now();
host.replaceChildren(card);
if (requiresIsometric) {
if (requiresIsometric && window.__hpAlpha !== true) {
if (typeof card._onLabsSnapshot !== 'function')
throw new Error('large-house-isometric-v1 candidate has no Labs fixture hook');
// The product flag expires at 1.65.0. Performance keeps exercising
// the dormant renderer without changing the public registry contract.
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
// Comparison bundles before #448 do not understand hp_alpha. Preserve
// the cross-version benchmark only for that base; current candidates
// must activate through the real URL/storage contract above.
card._onLabsSnapshot({ alpha: true, active: Object.freeze(['iso']), space: '' });
}
card.hass = hassFor(fixture.states);
// launch() warms its own bootstrap card, but each measured sample creates
@@ -206,9 +210,33 @@ try {
await frame();
const modelReadyMs = Number((performance.now() - loadStarted).toFixed(2));
await until(() => card._booting === false);
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
await frame();
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
const loadLongTaskResult = await loadLongTasks.stop();
let fullRenderCount = 0;
let diagnosticsScanCount = 0;
let physicalPreflightCount = 0;
let physicalPreflightMs = 0;
const fullRenderReasons = [];
if (interaction) {
const renderBody = card._renderBody.bind(card);
card._renderBody = (...args) => { fullRenderCount++; return renderBody(...args); };
const bindingStatus = card._bindingStatus.bind(card);
card._bindingStatus = (...args) => { diagnosticsScanCount++; return bindingStatus(...args); };
const checkSpacePhysicalGeometry = card._checkSpacePhysicalGeometry.bind(card);
card._checkSpacePhysicalGeometry = (...args) => {
physicalPreflightCount++;
const started = performance.now();
try { return checkSpacePhysicalGeometry(...args); }
finally { physicalPreflightMs += performance.now() - started; }
};
const willUpdate = card.willUpdate.bind(card);
card.willUpdate = (changed) => {
fullRenderReasons.push([...changed.keys()].map(String));
return willUpdate(changed);
};
}
const viewToggle = isometric ? await duration(async () => {
if (typeof card._setProjection === 'function') {
card._setProjection('flat');
@@ -236,6 +264,406 @@ try {
await card.updateComplete;
});
let interactionDiagnostics = null;
let interactionTimings = null;
let interactionLongTasks = null;
const interactionSeries = interaction ? await duration(async () => {
const deltas = {};
const timings = {};
interactionLongTasks = {};
const diagnosticsBefore = diagnosticsScanCount;
const reasonsBefore = fullRenderReasons.length;
const beforeIrrelevant = fullRenderCount;
const intakeBefore = card._hassSequence;
const irrelevantStarted = performance.now();
let currentStates = nextStates;
for (let index = 0; index < 30; index++) {
currentStates = {
...currentStates,
'sensor.performance_unrelated': {
entity_id: 'sensor.performance_unrelated', state: `${sample}:${index}`,
},
};
card.hass = hassFor(currentStates);
}
await card.updateComplete;
await frame();
timings.irrelevantHaTicksMs = performance.now() - irrelevantStarted;
deltas.irrelevantFullRenders = fullRenderCount - beforeIrrelevant;
deltas.irrelevantHassIntakes = card._hassSequence - intakeBefore;
const relevantBefore = fullRenderCount;
const relevantStarted = performance.now();
currentStates = {
...currentStates,
[firstEntity]: {
...currentStates[firstEntity],
state: currentStates[firstEntity].state === 'on' ? 'off' : 'on',
},
};
card.hass = hassFor(currentStates);
await card.updateComplete;
await frame();
timings.relevantHaTickMs = performance.now() - relevantStarted;
deltas.relevantOutsideGestureFullRenders = fullRenderCount - relevantBefore;
const stage = card.renderRoot.querySelector('.stage');
const device = card.renderRoot.querySelector('[data-hp="device"]');
const room = card.renderRoot.querySelector('[data-hp="room"]');
const heavy = card.renderRoot.querySelector('.wallbodies');
if (!stage || !device || !room || !heavy)
throw new Error('interaction profile has no stage/device/room/heavy scene');
const rect = stage.getBoundingClientRect();
const hoverBefore = fullRenderCount;
const hoverLongTasks = startLongTaskWindow();
const hoverStarted = performance.now();
device.dispatchEvent(new PointerEvent('pointerover', {
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
for (let index = 0; index < 40; index++) {
device.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 100 + index, clientY: rect.top + 100 + index / 2,
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
if ((index + 1) % 20 === 0) {
await card.updateComplete;
await frame();
}
}
const liveTip = card.renderRoot.querySelector('[data-hp-live-tip]');
deltas.tooltipVisible = !!liveTip && !liveTip.hidden && !!liveTip.textContent?.trim();
device.dispatchEvent(new PointerEvent('pointerleave', {
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
room.dispatchEvent(new PointerEvent('pointerenter', {
clientX: rect.left + 300, clientY: rect.top + 300,
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
for (let index = 0; index < 40; index++) {
room.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 300 + index, clientY: rect.top + 300 + index / 2,
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
if ((index + 1) % 20 === 0) { await card.updateComplete; await frame(); }
}
room.dispatchEvent(new PointerEvent('pointerleave', {
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
for (let index = 0; index < 40; index++) {
stage.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 20 + index, clientY: rect.top + 20,
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
if ((index + 1) % 20 === 0) { await card.updateComplete; await frame(); }
}
await card.updateComplete;
timings.hoverSeriesMs = performance.now() - hoverStarted;
deltas.hoverFullRenders = fullRenderCount - hoverBefore;
deltas.hoverTargets = ['device', 'room', 'miss'];
interactionLongTasks.hoverSeries = await hoverLongTasks.stop();
const cameraSvg = card.renderRoot.querySelector('[data-hp-live-viewbox="camera"], [data-hp-live-viewbox="floor"], .zoomwrap > svg');
const initialViewBox = cameraSvg?.getAttribute('viewBox') || '';
const panLongTasks = startLongTaskWindow();
const panStarted = performance.now();
const panMoveRenders = [];
const panTerminalRenders = [];
let relevantDuringGestureFullRenders = 0;
let relevantDuringGestureIntakes = 0;
for (let dragIndex = 0; dragIndex < 4; dragIndex++) {
const pointerId = 992 + dragIndex;
stage.dispatchEvent(new PointerEvent('pointerdown', {
clientX: rect.left + 200, clientY: rect.top + 200, button: 0, buttons: 1,
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
}));
await card.updateComplete;
const panMoveBefore = fullRenderCount;
for (let index = 0; index < 20; index++) {
stage.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 205 + index * 3, clientY: rect.top + 202 + index,
button: 0, buttons: 1, bubbles: true, composed: true,
pointerId, pointerType: 'mouse', isPrimary: true,
}));
if (dragIndex === 0 && index === 10) {
const deferredBefore = fullRenderCount;
const deferredIntakeBefore = card._hassSequence;
for (let tick = 0; tick < 3; tick++) {
currentStates = {
...currentStates,
[firstEntity]: { ...currentStates[firstEntity], state: `gesture-${tick}` },
};
card.hass = hassFor(currentStates);
}
await card.updateComplete;
relevantDuringGestureFullRenders += fullRenderCount - deferredBefore;
relevantDuringGestureIntakes += card._hassSequence - deferredIntakeBefore;
}
if ((index + 1) % 10 === 0) {
await card.updateComplete;
await frame();
}
}
const beforeTerminal = fullRenderCount;
panMoveRenders.push(beforeTerminal - panMoveBefore);
stage.dispatchEvent(new PointerEvent('pointerup', {
clientX: rect.left + 265, clientY: rect.top + 222, button: 0, buttons: 0,
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
}));
await card.updateComplete;
await frame();
panTerminalRenders.push(fullRenderCount - beforeTerminal);
}
timings.panSeriesMs = performance.now() - panStarted;
interactionLongTasks.panSeries = await panLongTasks.stop();
const movedViewBox = cameraSvg?.getAttribute('viewBox') || '';
const currentView = card._viewOr(card._baseVb());
const currentDevice = card._devices.find((item) => item.id === device.dataset.id);
const position = currentDevice ? card._pos(currentDevice) : null;
const scene = position ? card._scenePoint([position.x, position.y]) : null;
const deviceRect = device.getBoundingClientRect();
const expectedX = scene ? rect.left + ((scene[0] - currentView.x) / currentView.w) * rect.width : 0;
const expectedY = scene ? rect.top + ((scene[1] - currentView.y) / currentView.h) * rect.height : 0;
const overlayErrorPx = scene ? Math.hypot(
deviceRect.left + deviceRect.width / 2 - expectedX,
deviceRect.top + deviceRect.height / 2 - expectedY,
) : Infinity;
const cameraLongTasks = startLongTaskWindow();
const cameraStarted = performance.now();
const cameraBefore = fullRenderCount;
const pinchPointerA = 1001, pinchPointerB = 1002;
stage.dispatchEvent(new PointerEvent('pointerdown', {
clientX: rect.left + 360, clientY: rect.top + 300, button: 0, buttons: 1,
bubbles: true, composed: true, pointerId: pinchPointerA,
pointerType: 'touch', isPrimary: true,
}));
stage.dispatchEvent(new PointerEvent('pointerdown', {
clientX: rect.left + 460, clientY: rect.top + 300, button: 0, buttons: 1,
bubbles: true, composed: true, pointerId: pinchPointerB,
pointerType: 'touch', isPrimary: false,
}));
await card.updateComplete;
const pinchMoveBefore = fullRenderCount;
for (let index = 0; index < 20; index++) {
stage.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 360 - index, clientY: rect.top + 300,
button: 0, buttons: 1, bubbles: true, composed: true,
pointerId: pinchPointerA, pointerType: 'touch', isPrimary: true,
}));
stage.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 460 + index, clientY: rect.top + 300,
button: 0, buttons: 1, bubbles: true, composed: true,
pointerId: pinchPointerB, pointerType: 'touch', isPrimary: false,
}));
if ((index + 1) % 5 === 0) await frame();
}
const pinchTerminalBefore = fullRenderCount;
for (const pointerId of [pinchPointerA, pinchPointerB]) {
stage.dispatchEvent(new PointerEvent('pointerup', {
clientX: rect.left + 410, clientY: rect.top + 300,
button: 0, buttons: 0, bubbles: true, composed: true,
pointerId, pointerType: 'touch', isPrimary: pointerId === pinchPointerA,
}));
}
await card.updateComplete;
await frame();
const pinchTerminalRenders = fullRenderCount - pinchTerminalBefore;
for (let index = 0; index < 4; index++) {
stage.dispatchEvent(new WheelEvent('wheel', {
deltaY: index % 2 ? 120 : -120,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
bubbles: true, cancelable: true,
}));
}
await until(() => !card._cameraTransition.active);
await card.updateComplete;
await frame();
timings.cameraSeriesMs = performance.now() - cameraStarted;
interactionLongTasks.cameraSeries = await cameraLongTasks.stop();
deltas.cameraMoveFullRenders = pinchTerminalBefore - pinchMoveBefore;
deltas.cameraTerminalFullRenders = pinchTerminalRenders;
deltas.cameraFullRenders = fullRenderCount - cameraBefore;
const editorLongTaskWindows = [];
const editorConfigBefore = JSON.stringify(card._serverCfg);
const editorCallsBefore = wsCalls;
const editorPaintBefore = card._liveEditorPaintCount || 0;
let editorElapsed = 0;
const editorMoveRenders = [];
const editorTerminalRenders = [];
card._setMode('plan', false);
card._tool = 'draw';
card._path = [[100, 100]];
card.requestUpdate();
await card.updateComplete;
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
for (let index = 0; index < 5; index++) await frame();
let editorStage = card.renderRoot.querySelector('.stage');
let editorRect = editorStage.getBoundingClientRect();
let editorView = card._viewOr(card._baseVb());
const fromScene = (x, y) => ({
clientX: editorRect.left + ((x - editorView.x) / editorView.w) * editorRect.width,
clientY: editorRect.top + ((y - editorView.y) / editorView.h) * editorRect.height,
});
let editorPartLongTasks = startLongTaskWindow();
let partStarted = performance.now();
let editorMoveBefore = fullRenderCount;
for (let index = 0; index < 40; index++) {
editorStage.dispatchEvent(new PointerEvent('pointermove', {
...fromScene(110 + index * 2, 110 + index),
bubbles: true, composed: true, pointerId: 1101,
pointerType: 'mouse', isPrimary: true,
}));
if ((index + 1) % 10 === 0) await frame();
}
let editorTerminalBefore = fullRenderCount;
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
card._cursorPt = null;
card._path = [];
card.requestUpdate();
await card.updateComplete;
await frame();
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
editorElapsed += performance.now() - partStarted;
editorLongTaskWindows.push(await editorPartLongTasks.stop());
card._tool = 'resize';
card.requestUpdate();
await card.updateComplete;
const resizeRoom = card._rszRooms()[0];
const resizeEvent = {
pointerId: 1102, stopPropagation: () => undefined,
preventDefault: () => undefined, target: null,
};
card._rszEdgeDown(resizeEvent, resizeRoom.id, 1);
const resizePlan = card._resize?.plan || card._rszDrag?.plan;
if (!resizePlan) throw new Error('interaction resize plan was not created');
const resizePreflightBefore = physicalPreflightCount;
const resizePreflightMsBefore = physicalPreflightMs;
editorStage = card.renderRoot.querySelector('.stage');
editorRect = editorStage.getBoundingClientRect();
editorView = card._viewOr(card._baseVb());
// A browser delivers pointerdown and pointermove as separate tasks.
// Keep the synthetic harness from attributing both handlers to one
// impossible long task while still timing every move-side preflight.
await new Promise((done) => setTimeout(done, 0));
editorPartLongTasks = startLongTaskWindow();
partStarted = performance.now();
editorMoveBefore = fullRenderCount;
for (let index = 0; index < 40; index++) {
const target = [
resizePlan.a[0] + resizePlan.n[0] * card._gridPitch * (1 + index / 40),
resizePlan.a[1] + resizePlan.n[1] * card._gridPitch * (1 + index / 40),
];
card._rszMove({
...resizeEvent,
clientX: editorRect.left + ((target[0] - editorView.x) / editorView.w) * editorRect.width,
clientY: editorRect.top + ((target[1] - editorView.y) / editorView.h) * editorRect.height,
});
if ((index + 1) % 10 === 0) await frame();
}
editorTerminalBefore = fullRenderCount;
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
deltas.resizeLivePreflightChecks = physicalPreflightCount - resizePreflightBefore;
deltas.resizeLivePreflightMs = physicalPreflightMs - resizePreflightMsBefore;
card._rszCancelDrag();
await card.updateComplete;
await frame();
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
editorElapsed += performance.now() - partStarted;
editorLongTaskWindows.push(await editorPartLongTasks.stop());
card._setMode('decor', false);
card._decorTool = 'select';
const decorShape = card._decorList.find((shape) =>
shape.kind === 'furniture' || shape.kind === 'rect' || shape.kind === 'ellipse');
if (!decorShape) throw new Error('interaction profile has no transformable decor');
card._decorSel = decorShape.id;
card.requestUpdate();
await card.updateComplete;
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
for (let index = 0; index < 5; index++) await frame();
card._dtMeasure();
await card.updateComplete;
editorStage = card.renderRoot.querySelector('.stage');
editorRect = editorStage.getBoundingClientRect();
editorView = card._viewOr(card._baseVb());
const box = card._decorBoxOf(decorShape);
const cornerX = box.x + box.w, cornerY = box.y + box.h;
const decorPointer = (x, y) => ({
pointerId: 1103, pointerType: 'mouse', shiftKey: false,
clientX: editorRect.left + ((x - editorView.x) / editorView.w) * editorRect.width,
clientY: editorRect.top + ((y - editorView.y) / editorView.h) * editorRect.height,
stopPropagation: () => undefined, preventDefault: () => undefined,
currentTarget: null, target: null,
});
card._dtStart(decorPointer(cornerX, cornerY), 'scale', [1, 1]);
await card.updateComplete;
await frame();
editorPartLongTasks = startLongTaskWindow();
partStarted = performance.now();
editorMoveBefore = fullRenderCount;
for (let index = 0; index < 40; index++) {
card._dtMove(decorPointer(cornerX + index, cornerY + index / 2));
if ((index + 1) % 10 === 0) await frame();
}
editorTerminalBefore = fullRenderCount;
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
card._cancelDecorGesture();
await card.updateComplete;
await frame();
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
editorElapsed += performance.now() - partStarted;
editorLongTaskWindows.push(await editorPartLongTasks.stop());
timings.editorSeriesMs = editorElapsed;
interactionLongTasks.editorSeries = {
supported: editorLongTaskWindows.every((window) => window.supported),
count: editorLongTaskWindows.reduce((sum, window) => sum + window.count, 0),
maxMs: Math.max(0, ...editorLongTaskWindows.map((window) => window.maxMs)),
totalMs: editorLongTaskWindows.reduce((sum, window) => sum + window.totalMs, 0),
};
deltas.editorMoveFullRenders = editorMoveRenders;
deltas.editorTerminalFullRenders = editorTerminalRenders;
deltas.editorLivePaints = (card._liveEditorPaintCount || 0) - editorPaintBefore;
deltas.editorConfigStable = JSON.stringify(card._serverCfg) === editorConfigBefore;
deltas.editorWsWrites = wsCalls - editorCallsBefore;
deltas.editorLongTaskParts = editorLongTaskWindows;
card._setMode('view', false);
await card.updateComplete;
await frame();
deltas.panMoveFullRenders = panMoveRenders;
deltas.terminalFullRenders = panTerminalRenders;
deltas.relevantDuringGestureFullRenders = relevantDuringGestureFullRenders;
deltas.relevantDuringGestureIntakes = relevantDuringGestureIntakes;
deltas.viewBoxMoved = !!initialViewBox && initialViewBox !== movedViewBox;
deltas.overlayErrorPx = Number(overlayErrorPx.toFixed(2));
deltas.heavyNodeStable = card.renderRoot.querySelector('.wallbodies') === heavy;
deltas.diagnosticsScans = diagnosticsScanCount - diagnosticsBefore;
deltas.fullRenderReasons = fullRenderReasons.slice(reasonsBefore);
interactionTimings = timings;
interactionDiagnostics = { supported: requiresInteraction, ...deltas };
if (requiresInteraction && (
deltas.irrelevantFullRenders !== 0 || deltas.irrelevantHassIntakes !== 30
|| deltas.relevantOutsideGestureFullRenders !== 1 || deltas.hoverFullRenders !== 0
|| deltas.hoverTargets.join(',') !== 'device,room,miss'
|| deltas.panMoveFullRenders.some((count) => count !== 0)
|| deltas.terminalFullRenders.some((count) => count !== 1)
|| deltas.relevantDuringGestureFullRenders !== 0 || !deltas.tooltipVisible
|| deltas.relevantDuringGestureIntakes !== 3
|| deltas.cameraMoveFullRenders !== 0 || deltas.cameraTerminalFullRenders !== 1
|| deltas.editorMoveFullRenders.some((count) => count !== 0)
|| deltas.editorTerminalFullRenders.some((count) => count > 1)
|| deltas.resizeLivePreflightChecks < 1
|| deltas.editorLivePaints < 3 || !deltas.editorConfigStable || deltas.editorWsWrites !== 0
|| !deltas.viewBoxMoved || deltas.overlayErrorPx > 1 || !deltas.heavyNodeStable
|| deltas.diagnosticsScans !== 0
)) throw new Error(`interaction structural contract failed: ${JSON.stringify(interactionDiagnostics)}`);
}) : null;
let planSnapDiagnostics = null;
const planSnapPointer = planSnap ? await duration(async () => {
card._setMode('plan');
@@ -432,6 +860,11 @@ try {
planSnapPointerMs: planSnapPointer.ms,
planSnapDiagnostics,
} : {}),
...(interactionSeries ? {
interactionSeriesMs: interactionSeries.ms,
...interactionTimings,
interactionDiagnostics,
} : {}),
spaceSwitchMs: spaceSwitch.ms,
stateUpdateMs: stateUpdate.ms,
resizePreviewMs: resizePreview.ms,
@@ -442,6 +875,7 @@ try {
load: loadLongTaskResult,
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
...(interactionSeries ? interactionLongTasks || {} : {}),
spaceSwitch: spaceSwitch.longTasks,
stateUpdate: stateUpdate.longTasks,
resizePreview: resizePreview.longTasks,
@@ -461,6 +895,7 @@ try {
}, {
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
interaction, requiresInteraction,
});
if (measuredSample >= 0) rows.push(row);
}
@@ -474,6 +909,9 @@ const metricNames = [
];
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
if (interaction) metricNames.splice(2, 0,
'interactionSeriesMs', 'hoverSeriesMs', 'panSeriesMs', 'cameraSeriesMs',
'editorSeriesMs', 'irrelevantHaTicksMs', 'relevantHaTickMs');
const report = {
schema: 2,
profile,
+47
View File
@@ -0,0 +1,47 @@
/** #54: bounded provider normalization and incident-only hover performance. */
import { performance } from 'node:perf_hooks';
import { mapTopologies, normalizeZhaTopology, resolveMappedTopologyHover } from '../test-build/zigbee-topology.js';
const NODES = 500;
const SAMPLES = 9;
const BUDGETS = { normalizeMs: 80, mapMs: 160, firstHoverMs: 180, repeatedHoverMs: 120 };
const hex = (index) => index.toString(16).padStart(16, '0');
const rows = Array.from({ length: NODES }, (_, index) => ({
ieee: hex(index + 1), device_reg_id: `d${index}`, device_type: index ? 'Router' : 'Coordinator',
neighbors: [1, 2, 3, 4, 5, 6].flatMap((offset) => {
const other = index + offset;
return other < NODES ? [{ ieee: hex(other + 1), lqi: (index * 17 + offset * 13) % 256 }] : [];
}),
}));
const registry = {
revision: 1, authoritative: true, access: 'full', lastSuccess: Date.now(), entities: {},
devices: Object.fromEntries(rows.map((_, index) => [`d${index}`, { id: `d${index}` }])),
};
const active = { kind: 'active', enabledEntityIds: [], allEntityIds: [] };
const devices = rows.map((_, index) => ({
id: `m${index}`, name: '', model: '', area: '', space: index % 5 ? 'main' : 'remote',
icon: '', entities: [], bindingKind: 'device', bindingRef: `d${index}`, bindingStatus: active,
}));
const measured = (fn) => {
const samples = [];
for (let i = 0; i < SAMPLES; i++) {
const started = performance.now(); fn(); samples.push(performance.now() - started);
}
samples.sort((a, b) => a - b);
return samples[Math.floor(samples.length * .9)];
};
let topology; let mapped;
const normalizeMs = measured(() => { topology = normalizeZhaTopology(rows); });
const mapMs = measured(() => { mapped = mapTopologies([topology], devices, registry); });
const firstHoverMs = measured(() => resolveMappedTopologyHover(mapped, 'main', 'm1'));
const repeatedHoverMs = measured(() => {
for (let i = 1; i <= 20; i++) resolveMappedTopologyHover(mapped, 'main', `m${i}`);
});
const result = { nodes: topology.nodes.length, links: topology.links.length,
normalizeMs, mapMs, firstHoverMs, repeatedHoverMs };
console.log(JSON.stringify(result, null, 2));
for (const [name, budget] of Object.entries(BUDGETS)) {
if (result[name] > budget) throw new Error(`${name} ${result[name].toFixed(2)} ms > ${budget} ms`);
}
+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 }
+6 -6
View File
@@ -1,11 +1,11 @@
{
"schema": 1,
"matrixVersion": 54,
"acceptedAt": "2026-09-03T06:25:02.855Z",
"sourceFingerprint": "b69bdfe3df225cf1b96fea0159a03a620e38cb94ec6106f9ee3b0e57cdefd183",
"acceptedAt": "2026-09-04T13:10:56.492Z",
"sourceFingerprint": "7acb1b9d3bb384bc1a0c70fb01347386c157ae88b12b00ccd876b7658fc84a1c",
"chromium": "151.0.7922.34",
"witnesses": {
"count": 132,
"count": 127,
"floor": 10
},
"scenarios": {
@@ -142,9 +142,9 @@
"device-help-popover-light-ru": "c2146f13d03841c395b0772f1a21de358c62a8703d3d02e7d026fac8ae29955e",
"decor-color-popover-mobile-ru": "c65318327c6e4631c769419e791a749d667b4dfc36f2e3fc46d6fac53180cf1b",
"decor-color-popover-desktop-en": "60de64b1bc072327cb88d2e876732d50d5cb7441c850f52b7af6f4ed5c6de22b",
"general-color-popover-desktop-en": "5b47ab921f889c9ad41cab195e6efeed283f014d37f5377714b54ef30282ebf6",
"settings-help-zoom-200-en-light": "bb1192a52bab5ebc6984a5d799a32d05f9ce374ce6b493f5f618b639eedce8d5",
"settings-help-zoom-200-ru-dark": "13e7fdd19017c38a5659394e622ff54ac8a426f9cb6e85b3248864767aa9f42b",
"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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

+10 -13
View File
@@ -760,10 +760,9 @@ export async function prepareGoldenScenario(page, scenario) {
window.__goldenEditor?.remove?.();
window.__card?.remove?.();
localStorage.clear();
history.replaceState(null, '', scenario.labs?.length
? `?hp-labs=${encodeURIComponent(scenario.labs.join(','))}` : location.pathname);
if (scenario.labs?.length) {
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(scenario.labs));
history.replaceState(null, '', scenario.alpha ? '?hp_alpha=1' : location.pathname);
if (scenario.alpha) {
localStorage.setItem('houseplan_card_alpha_v1', '1');
}
if (scenario.projection && scenario.space) {
localStorage.setItem('houseplan_card_view_v1', JSON.stringify({
@@ -818,21 +817,16 @@ export async function prepareGoldenScenario(page, scenario) {
const card = document.createElement('houseplan-card');
card.setConfig(cardConfig);
host.replaceChildren(card);
if (scenario.testOnlyLabsSnapshot) {
if (!scenario.labs?.length || typeof card._onLabsSnapshot !== 'function') {
throw new Error(`invalid test-only Labs contract: ${scenario.id}`);
}
// connectedCallback has already received the real (expired) registry
// snapshot. Inject only the renderer fixture before hass/model boot so
// fit and warm-remount follow the former live-Labs lifecycle exactly.
card._onLabsSnapshot({ active: Object.freeze([...scenario.labs]), space: '' });
}
card.hass = hassFor();
await until(() => card._loadOk && card._model?.length === fixture.config.spaces.length);
await card.updateComplete;
const expectedDevices = Object.keys(fixture.devices || {}).length;
if (expectedDevices) await until(() => card._devices?.length >= expectedDevices);
await until(() => card._booting === false);
if (scenario.alpha
&& (window.__hpAlpha !== true || JSON.stringify(window.__hpLabs) !== '["iso"]')) {
throw new Error(`alpha contract did not activate isometric capability: ${scenario.id}`);
}
// Golden scenarios intentionally call internal editor commands directly.
// Preload the lazy runtime for that legacy harness contract; cold-View
// loading and retry semantics are covered by smoke_lazy_editor_chunk.
@@ -1066,6 +1060,9 @@ export async function prepareGoldenScenario(page, scenario) {
card._setProjection('iso');
await card.updateComplete;
await frame();
if (card._effectiveProjection?.() !== 'iso') {
throw new Error(`isometric golden rendered Flat: ${scenario.id}`);
}
await until(() => card._renderProjection === 'iso');
}
if (Number.isFinite(scenario.zoom)) {
+8 -13
View File
@@ -6,11 +6,6 @@ export const GOLDEN_MATRIX_VERSION = 54;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
// The product flag expires at 1.65.0 by contract. Golden keeps the dormant
// renderer covered through an explicit card-only snapshot; this never changes
// URL/storage/registry behaviour and must fail if the rendered view stays flat.
const expiredIsoFixture = { testOnlyLabsSnapshot: true };
const openingRoomContract = {
kind: 'room', wallCm: 30, surface: 'flat',
openings: [
@@ -200,23 +195,23 @@ export const GOLDEN_SCENARIOS = Object.freeze([
cornerSplitWall: 'zero-taper', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
// Stage 2 material/floor-edge plus door, window, gate and nested-room coverage.
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
alpha: true, projection: 'iso',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
alpha: true, projection: 'iso',
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-live-layers-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
alpha: true, projection: 'iso',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-no-borders-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, showBorders: false, fillMode: 'custom',
alpha: true, projection: 'iso', showBorders: false, fillMode: 'custom',
customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-touch-kiosk-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, kiosk: true,
alpha: true, projection: 'iso', kiosk: true,
theme: 'dark', viewport: { width: 390, height: 760 }, ...stage },
{ id: 'isometric-large-warm-remount-dark', fixture: 'large', space: 'perf-floor-2', mode: 'view',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, warmRemount: true,
alpha: true, projection: 'iso', warmRemount: true,
theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
{ id: 'geometry-view-dark-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
@@ -348,7 +343,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-wall-junctions-dark', fixture: 'visual',
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
alpha: true, projection: 'iso',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'opening-placement-door-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
// The shared centre edge is a long 25 cm physical wall. It can contain the
@@ -375,7 +370,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
mode: 'view', openingSymbolContract: openingFlipContract,
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-opening-symbol-parity-dark', fixture: 'visual', space: 'golden-opening-symbols',
mode: 'view', labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
mode: 'view', alpha: true, projection: 'iso',
openingSymbolContract: openingIsoContract,
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
+28 -1
View File
@@ -1,12 +1,39 @@
import {
captureEnvironment, foreignCaptureAllowance, foreignCaptureRefusal,
} from '../../scripts/capture-environment.mjs';
// The name must NOT end with `manifest.json`: the HACS submission check globs
// `*manifest.json` over the whole clone of the default branch and refuses a
// repository with more than one match (test/repo-hygiene.test.mjs).
export const GOLDEN_BASELINE_MANIFEST = 'baselines-index.json';
export const assertGoldenInvocation = (mode, scenarioFilter = '') => {
/**
* Проверка вызова — и заодно проверка среды съёмки (#455).
*
* Почему здесь, а не в `run.mjs`: `run.mjs` входит в корпус
* `sourceFingerprint`, и его правка объявляет устаревшими закоммиченный бандл,
* манифест скриншотов документации и индекс эталонов. Этот файл из корпуса
* исключён именно как «предикаты, вызываемые до и после съёмки», и уже
* вызывается первой строкой `run.mjs` — гейт встаёт без единой правки
* фингерпринтуемого файла.
*
* Платформа — ПАРАМЕТР, а не `process.platform` внутри: иначе юнит стал бы
* зелёным на Linux и красным на машине владельца, то есть тестом про хост, а
* не про правило.
*/
export const assertGoldenInvocation = (mode, scenarioFilter = '', options = {}) => {
if (!['capture', 'verify'].includes(mode)) throw new Error(`unknown golden mode: ${mode}`);
if (mode === 'verify' && scenarioFilter)
throw new Error('golden verify must run the complete matrix; use capture for a diagnostic --scenario run');
// Диагностический прогон verify в чужой среде законен: он ничего не принимает.
if (mode !== 'capture') return;
const platform = options.platform ?? captureEnvironment().platform;
const allowance = options.allowance ?? foreignCaptureAllowance();
const { refusal, allowance: accepted } = foreignCaptureRefusal({
platform, kind: 'golden', stage: 'capture', allowance,
});
if (refusal) throw new Error(refusal);
if (accepted) console.log(`Чужая среда съёмки разрешена осознанно: ${accepted}`);
};
/** A reviewed golden matrix is exact: neither an orphan PNG nor a stale hash
+26 -5
View File
@@ -4,11 +4,13 @@
fixture. The fixture has 60 rooms, 200 devices, 100 openings, 60 partitions,
40 columns and 500 decor objects on three floors.
Issue #89 adds `large-house-isometric-v1` through the same runner. Because the
product flag expires at 1.65.0, the runner injects an explicit test-only Labs
snapshot, selects iso per fixture space, measures the View toggle and records
the capped `isoGeometry` cache. A comparison SHA predating #89 remains flat
while reporting the same profile. The dedicated
Issue #89 adds `large-house-isometric-v1` through the same runner. The runner
enables the current candidate through the real `hp_alpha=1` URL/storage
contract, selects iso per fixture space, measures the View toggle and records
the capped `isoGeometry` cache. A compatibility-only snapshot keeps comparison
bundles from before #448 measurable without restoring a legacy user-facing
activation path; a comparison SHA predating #89 remains flat while reporting
the same profile. The dedicated
`budgets-large-house-isometric.json` applies the reviewed 20% relative allowance
plus absolute noise/ceiling checks. Only the exact-SHA Linux workflow is gate
evidence; a local report is diagnostic.
@@ -23,6 +25,24 @@ websocket traffic change. Its dedicated budget retains every original timing,
heap and cache ceiling and adds the measured pointer series plus a one-entry
snap-geometry cache cap. Exact-SHA Linux output is the only gate evidence.
Issue #451 adds `large-house-interaction-v1`. It dispatches 120 real
device/room/free-background hover moves, four 20-move pans, pinch/wheel camera input, three editor
drag series and 30 unrelated HA snapshots. It also injects relevant snapshots
inside and outside a gesture and counts heavy `_renderBody()` passes plus full
marker-binding diagnostic scans. Current bundles fail inside the runner unless
hover, editor moves and unrelated ticks perform zero full renders, a relevant
tick is deferred during pan, each terminal event reconciles at most one full
frame, the heavy wall DOM remains identical, and the projected HTML device
layer stays within one CSS pixel of its SVG scene position. Older comparison
bundles still produce timing baselines; structural assertions become mandatory
only when the target source contains the live viewport implementation. Its
dedicated budget preserves every existing large-house ceiling and adds the
individual interaction timing gates.
Structural assertions are evaluated independently of timing budgets: a fast
run still fails when an interaction performs an unexpected full render or
changes the heavy scene DOM.
The runner records seven measured samples after one discarded warm-up. With
this intentionally small CI sample, the nearest-rank `p95` is the observed
maximum; reports keep the conventional field name but should be read as a
@@ -124,6 +144,7 @@ Build and copy a fresh demo bundle first, then run:
```bash
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
npm run benchmark:large-house-plan-snap -- --samples=7 --warmups=1 --output=artifacts/performance/plan-snap-local.json
npm run benchmark:large-house-interaction -- --samples=7 --warmups=1 --output=artifacts/performance/interaction-local.json
```
A local report is diagnostic only; it cannot replace the CI comparison.
@@ -0,0 +1,139 @@
{
"schema": 1,
"profile": "large-house-interaction-v1",
"minimumSamples": 7,
"timings": {
"modelReadyMs": {
"stat": "median",
"maxRegressionRatio": 0.3,
"noiseAllowanceMs": 200,
"hardMaxMs": 2500
},
"firstStableRenderMs": {
"stat": "median",
"maxRegressionRatio": 0.3,
"noiseAllowanceMs": 250,
"hardMaxMs": 3000
},
"interactionSeriesMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 150,
"hardMaxMs": 3000
},
"hoverSeriesMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 60,
"hardMaxMs": 500
},
"panSeriesMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 60,
"hardMaxMs": 500
},
"cameraSeriesMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 60,
"hardMaxMs": 500
},
"editorSeriesMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 100,
"hardMaxMs": 750
},
"irrelevantHaTicksMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 40,
"hardMaxMs": 250
},
"relevantHaTickMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 40,
"hardMaxMs": 250
},
"spaceSwitchMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 75,
"hardMaxMs": 1500
},
"stateUpdateMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 75,
"hardMaxMs": 1000
},
"resizePreviewMs": {
"stat": "median",
"maxRegressionRatio": 0.4,
"noiseAllowanceMs": 150,
"hardMaxMs": 2000
},
"panZoomMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 60,
"hardMaxMs": 500
},
"settingsDialogMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 100,
"hardMaxMs": 1000
},
"switchCycleMs": {
"stat": "median",
"maxRegressionRatio": 0.35,
"noiseAllowanceMs": 250,
"hardMaxMs": 7000
}
},
"longTasks": {
"maxSingleMs": 3000,
"maxSingleRegressionRatio": 0.3,
"maxSingleNoiseAllowanceMs": 250,
"maxCountP95": 30,
"maxCountRegressionRatio": 0.35,
"countNoiseAllowance": 3,
"maxTotalP95Ms": 12000,
"maxTotalRegressionRatio": 0.3,
"noiseAllowanceMs": 150
},
"longTaskWindows": {
"hoverSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
"panSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
"cameraSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
"editorSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 }
},
"heap": {
"required": true,
"hardMaxGrowthBytes": 67108864,
"maxRegressionRatio": 0.75,
"noiseAllowanceBytes": 16777216
},
"cacheEntries": {
"cleanFloor": 100,
"glowClip": 200,
"wallUnion": 1,
"openingTunnel": 1,
"openingWallIndex": 1,
"planSnapGeometry": 1,
"wallFaceGraph": 4
},
"cacheGrowth": {
"cleanFloor": 0,
"glowClip": 0,
"wallUnion": 0,
"openingTunnel": 0,
"openingWallIndex": 0,
"planSnapGeometry": 0,
"wallFaceGraph": 0
},
"renderedDevices": 200
}
+27 -1
View File
@@ -17,22 +17,41 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
label: 'large-house-v1',
methods: Object.freeze([
'_baseVb',
'_bindingStatus',
'_cancelDecorGesture',
'_checkSpacePhysicalGeometry',
'_decorBoxOf',
'_dtMeasure',
'_dtMove',
'_dtStart',
'_openSettingsDialog',
'_pickSpace',
'_pos',
'_renderBody',
'_rszCancelDrag',
'_rszEdgeDown',
'_rszMove',
'_rszRooms',
'_setMode',
'_scenePoint',
'_viewOr',
]),
fields: Object.freeze([
'_booting',
'_bootSoft',
'_cameraTransition',
...CACHE_FIELDS,
'_continuity',
'_cursorPt',
'_decorList',
'_decorSel',
'_decorTool',
'_devices',
'_gridPitch',
'_hassSequence',
'_loadOk',
'_model',
'_modeTransitionBusy',
'_path',
'_serverCfg',
'_settingsDialog',
@@ -42,7 +61,7 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
// checks these members only when the target source tree supports Stage 1.
optionalFields: Object.freeze([
'_activeDraftId', '_draftSegmentCms', '_isoGeometryCache', '_offerWallFaces',
'_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
'_liveEditorPaintCount', '_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
'_wallFaceBatch', '_wallFaceGraphCache',
]),
// #380: v1.68.1 owns the same resize session directly on the card; newer
@@ -54,12 +73,19 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
]),
fieldTypes: Object.freeze({
_booting: 'boolean',
_bootSoft: 'boolean',
_cameraTransition: 'object',
_cleanFloorCache: 'map',
_devices: 'array',
_continuity: 'object',
_decorList: 'array',
_decorTool: 'string',
_draftSegmentCms: 'array',
_glowClipCache: 'map',
_gridPitch: 'number',
_hassSequence: 'number',
_loadOk: 'boolean',
_liveEditorPaintCount: 'number',
_model: 'array',
_onLabsSnapshot: 'function',
_offerWallFaces: 'function',
+14
View File
@@ -143,6 +143,20 @@ export const evaluatePerformanceBudget = ({ candidate, baseline, budgets, absolu
},
));
for (const [windowName, budget] of Object.entries(budgets.longTaskWindows ?? {})) {
const windows = candidate.rows.map((row) => row.longTasks?.[windowName]);
const available = windows.every((window) => window?.supported === true);
checks.push({
id: `longTask.${windowName}.available`, actual: available ? 1 : 0, limit: 1, pass: available,
});
const maxSingle = available ? Math.max(...windows.map((window) => window.maxMs)) : NaN;
const countP95 = available ? percentile(windows.map((window) => window.count), 0.95) : NaN;
const totalP95 = available ? percentile(windows.map((window) => window.totalMs), 0.95) : NaN;
checks.push(makeCheck(`longTask.${windowName}.maxSingleMs`, maxSingle, budget.maxSingleMs));
checks.push(makeCheck(`longTask.${windowName}.countP95`, countP95, budget.maxCountP95));
checks.push(makeCheck(`longTask.${windowName}.totalP95Ms`, totalP95, budget.maxTotalP95Ms));
}
const candidateHeap = candidate.rows
.map((row) => row.heapGrowthBytes)
.filter(finite)
+1
View File
@@ -325,6 +325,7 @@ const drawn = await page.evaluate(async () => {
},
options: { minDim: 1, eps: 0.01 }, rooms: resizeRooms, openings: [],
snapshotIdentity: snapshot, before: JSON.parse(snapshot), wallUnionBefore: null,
epochBefore: c._cfgEpoch,
});
c._resize.move({
pointerId: 912, point: [687.5, 250], step: 0.5, snap: (point) => point,
+130 -4
View File
@@ -10,6 +10,11 @@ const res = await page.evaluate(async () => {
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const settleLive = async () => {
await Promise.resolve();
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
await c.updateComplete;
};
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
// 1) третья вкладка есть
const tabs = [...sr().querySelectorAll('.modetab')];
@@ -272,10 +277,10 @@ const res = await page.evaluate(async () => {
const vw = () => c._viewOr(c._baseVb());
c._decorTool = 'line';
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 4, g * 20], pid: 3 };
await c.updateComplete;
await settleLive();
out.noBadgeBeforeTheDrag = !label(); // нулевая длина — молчим
c._decorDraft = { ...c._decorDraft, b: [g * 16, g * 20] }; // 12 клеток вправо
await c.updateComplete;
await settleLive();
out.lineBadgeShown = !!label();
// 12 клеток × 5 см = 0.60 m, угол 0° — ровно то, что пишет плашка стены
out.lineBadgeText = txt();
@@ -287,12 +292,12 @@ const res = await page.evaluate(async () => {
out.badgeGreenOnAxis = !!label() && label().classList.contains('on45');
// косой отрезок 3-4-5: длина 5 клеток, угол 53.1° — не кратен 45°
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 7, g * 24], pid: 3 };
await c.updateComplete;
await settleLive();
out.obliqueBadge = txt() === fmt(g * 5) + ' · 53.1°';
out.obliqueNotGreen = !!label() && !label().classList.contains('on45');
// прямоугольник показывает габарит и чистую площадь
c._decorDraft = { kind: 'rect', a: [g * 4, g * 20], b: [g * 12, g * 26], pid: 3 };
await c.updateComplete;
await settleLive();
out.rectBadgeHasSizeAndArea = txt().startsWith(fmt(g * 8) + ' × ' + fmt(g * 6) + ' · ')
&& /(?:m²|ft²)$/.test(txt());
// отпустили — плашки нет
@@ -493,6 +498,127 @@ const res = await page.evaluate(async () => {
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
c._decorTool = 'select'; await c.updateComplete;
out.decorRestored = c._decorList.length === decorBefore.length;
// #447: Arrow is a separate rigid transaction, not a synthetic pointer
// move. It preserves off-grid phase, bypasses both magnets and keeps one
// history/save boundary per browser keydown.
const keyboardOriginal = JSON.parse(JSON.stringify(c._decorList));
const keyboardShapes = [
{ id: 'key-rect', kind: 'rect', x: 0.1013, y: 0.2017, w: 0.08, h: 0.05,
angle: 17, color: '#123456', fill: true },
{ id: 'key-furniture', kind: 'furniture', x: 0.3013, y: 0.4017, w: 0.08, h: 0.05,
symbol: 'chair', angle: 90, flip_h: true },
{ id: 'key-image', kind: 'image', x: 0.5013, y: 0.6017, w: 0.08, h: 0.05,
asset_id: 'smoke-missing-asset', opacity: 0.6 },
];
c._curSpaceCfg.decor = keyboardShapes;
c._decorTool = 'select'; c._decorSel = 'key-furniture';
c._decorDraft = null; c._decorMove = null; c._dtDrag = null; c._bdDrag = null;
c.requestUpdate(); await c.updateComplete;
c._geometryHistory.clear();
c._saveConfigDebounced.cancel();
const realDebouncedSave = c._saveConfigDebounced;
let keyboardSaves = 0, decorSnapCalls = 0, furnitureMoveCalls = 0;
const countedSave = () => { keyboardSaves += 1; };
countedSave.pending = () => false; countedSave.cancel = () => {}; countedSave.flush = () => {};
c._saveConfigDebounced = countedSave;
const realDecorSnap = c._decorSnap;
const realFurnitureMove = c._editorRuntime._furnMoveUpdate;
c._decorSnap = (...args) => { decorSnapCalls += 1; return realDecorSnap.apply(c, args); };
c._editorRuntime._furnMoveUpdate = (...args) => {
furnitureMoveCalls += 1;
return realFurnitureMove.apply(c._editorRuntime, args);
};
const arrow = (key, target = window, init = {}) => {
const event = new KeyboardEvent('keydown', {
key, bubbles: true, composed: true, cancelable: true, ...init,
});
return target.dispatchEvent(event) === false;
};
const keyed = (id) => c._decorList.find((shape) => shape.id === id);
const keyStep = g / 1000;
const furnitureBeforeKey = JSON.parse(JSON.stringify(keyed('key-furniture')));
const epochBeforeKey = c._cfgEpoch;
const firstArrowPrevented = arrow('ArrowRight');
const furnitureAfterRight = JSON.parse(JSON.stringify(keyed('key-furniture')));
out.arrowMovesFurnitureOneCellWithoutMagnet = firstArrowPrevented
&& Math.abs(furnitureAfterRight.x - furnitureBeforeKey.x - keyStep) < 1e-12
&& furnitureAfterRight.y === furnitureBeforeKey.y
&& furnitureAfterRight.angle === furnitureBeforeKey.angle
&& furnitureAfterRight.flip_h === furnitureBeforeKey.flip_h
&& decorSnapCalls === 0 && furnitureMoveCalls === 0;
out.arrowIsOneHistoryAndSaveTransaction = c._geometryHistory.size === 1
&& c._geometryHistory.undoName === c._t('history.decor_move')
&& keyboardSaves === 1 && c._cfgEpoch === epochBeforeKey + 1
&& c._decorSel === 'key-furniture';
const shiftArrowPrevented = arrow('ArrowDown', window, { shiftKey: true });
const furnitureAfterDown = JSON.parse(JSON.stringify(keyed('key-furniture')));
out.shiftArrowKeepsTheSameOneCellStep = shiftArrowPrevented
&& Math.abs(furnitureAfterDown.y - furnitureAfterRight.y - keyStep) < 1e-12
&& c._geometryHistory.size === 2 && keyboardSaves === 2;
arrow('z', window, { ctrlKey: true });
const furnitureAfterUndo = JSON.parse(JSON.stringify(keyed('key-furniture')));
arrow('y', window, { ctrlKey: true });
const furnitureAfterRedo = JSON.parse(JSON.stringify(keyed('key-furniture')));
out.arrowUndoRedoIsExactlyOneStep = Math.abs(furnitureAfterUndo.y - furnitureAfterRight.y) < 1e-8
&& Math.abs(furnitureAfterRedo.y - furnitureAfterDown.y) < 1e-8
&& Math.abs(furnitureAfterRedo.x - furnitureAfterRight.x) < 1e-8
&& c._decorSel === 'key-furniture';
c._decorSel = 'key-image';
const imageX = keyed('key-image').x;
arrow('ArrowLeft');
c._decorSel = 'key-rect';
const rectY = keyed('key-rect').y;
arrow('ArrowUp');
out.imageAndOrdinaryDecorUseTheSameArrowPath = Math.abs(keyed('key-image').x - imageX + keyStep) < 1e-12
&& Math.abs(keyed('key-rect').y - rectY + keyStep) < 1e-12
&& decorSnapCalls === 0 && furnitureMoveCalls === 0;
const guardX = () => keyed('key-rect').x;
const guardStart = guardX();
c.requestUpdate(); await c.updateComplete;
const controls = ['input', 'textarea', 'select'].map((tag) => document.createElement(tag));
const editable = document.createElement('div'); editable.contentEditable = 'true';
controls.push(editable);
const fieldResults = [];
for (const control of controls) {
document.body.append(control); control.focus();
fieldResults.push(!arrow('ArrowRight', control) && guardX() === guardStart);
control.remove();
}
out.arrowLeavesFormControlsAlone = fieldResults.every(Boolean);
const toolbarControl = sr().querySelector('.editor-secondary button');
toolbarControl?.focus();
const toolbarGuarded = !!toolbarControl && !arrow('ArrowRight', toolbarControl) && guardX() === guardStart;
toolbarControl?.blur();
c._decorEraseConfirm = { id: 'key-rect', kind: 'rect' };
const dialogGuarded = !arrow('ArrowRight') && guardX() === guardStart;
c._decorEraseConfirm = null;
c._decorMove = { id: 'key-rect', start: [0, 0], orig: JSON.parse(JSON.stringify(keyed('key-rect'))),
pid: 447, moved: false, before: c._geometrySnapshot() };
const gestureGuarded = !arrow('ArrowRight') && guardX() === guardStart;
c._decorMove = null;
out.arrowRespectsToolbarDialogAndGestureGuards = toolbarGuarded && dialogGuarded && gestureGuarded;
const modifierResults = [
{ ctrlKey: true }, { metaKey: true }, { altKey: true },
].map((init) => !arrow('ArrowRight', window, init) && guardX() === guardStart);
out.modifiedArrowsRemainBrowserCommands = modifierResults.every(Boolean);
c._curSpaceCfg.decor = [{ id: 'key-limit', kind: 'rect', x: 4999.9, y: 0, w: 0.1, h: 0.1 }];
c._decorSel = 'key-limit'; c._geometryHistory.clear();
const savesBeforeNoop = keyboardSaves, epochBeforeNoop = c._cfgEpoch;
const noopPrevented = arrow('ArrowRight');
out.boundaryNoopIsConsumedButNotSaved = noopPrevented
&& c._curSpaceCfg.decor[0].x === 4999.9
&& c._geometryHistory.size === 0 && keyboardSaves === savesBeforeNoop
&& c._cfgEpoch === epochBeforeNoop;
c._decorSnap = realDecorSnap;
c._editorRuntime._furnMoveUpdate = realFurnitureMove;
c._saveConfigDebounced = realDebouncedSave;
c._curSpaceCfg.decor = keyboardOriginal;
c._decorSel = null; c._geometryHistory.clear(); c._cfgEpoch++;
c.requestUpdate(); await c.updateComplete;
// Switching modes during a live move cancels the transaction and restores
// its start instead of leaving mutated geometry with a forgotten pointer.
const liveShape = c._decorList[0];
+9 -3
View File
@@ -27,9 +27,15 @@ const res = await page.evaluate(async () => {
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const settleLive = async () => {
await Promise.resolve();
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
await c.updateComplete;
};
// null-safe: на сборке ДО этих правок у надписи нет ни data-id, ни tspan —
// смок должен показать список провалов, а не упасть с исключением
const el = (id) => sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
const el = (id) => sr().querySelector(`[data-hp-live-editor] .decorlayer text.dtext[data-id="${id}"]`)
|| sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
const attr = (id, name) => { const e = el(id); return e ? e.getAttribute(name) : null; };
const tspans = (id) => { const e = el(id); return e ? [...e.querySelectorAll('tspan')] : []; };
const stageEl = () => sr().querySelector('.stage');
@@ -101,7 +107,7 @@ const res = await page.evaluate(async () => {
out.noFrameWithoutSelection = !sr().querySelector('.dtframe');
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const frame = () => sr().querySelector('.dtframe');
const frame = () => sr().querySelector('[data-hp-live-editor] .dtframe') || sr().querySelector('.dtframe');
out.frameOnSelection = !!frame();
out.fourCornersAndRotate = !!frame()
&& frame().querySelectorAll('.dthandle').length === 5
@@ -147,7 +153,7 @@ const res = await page.evaluate(async () => {
ev('pointerdown', corner, ax + 40, ay2);
out.dragStarted = !!c._dtDrag && c._dtDrag.kind === 'scale';
ev('pointermove', stageEl(), ax + 80, ay2);
await c.updateComplete;
await settleLive();
const scaled = c._decorList.find((x) => x.id === 'dtm');
out.cornerStoresPhysicalSize = scaled.scale === undefined && scaled.size === undefined
&& Math.abs((scaled.size_cm || 0) - mediumSizeCm0 * 2) < 0.01;
+1
View File
@@ -131,6 +131,7 @@ const out = await page.evaluate(async () => {
beginDeviceDrag(dev3, r0, 13);
c._pointerMove(mk(2.5 * NORM_W + halfStep - r0.x, 2.5 * NORM_W - r0.y,
400, 400, { shiftKey: true, pointerId: 13 }), dev3);
await Promise.resolve();
o.shiftStillSnapsToTheNodes = onGrid(c._layout[dev3.id].x);
c._cancelDeviceDrag();
+67 -26
View File
@@ -23,8 +23,14 @@ const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const settleLive = async () => {
await Promise.resolve();
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
await c.updateComplete;
};
const stageEl = () => sr().querySelector('.stage');
const el = (id) => (id ? sr().querySelector(`.decorlayer [data-id="${id}"]`) : null);
const el = (id) => (id ? sr().querySelector(`[data-hp-live-editor] .decorlayer [data-id="${id}"]`)
|| sr().querySelector(`.decorlayer [data-id="${id}"]`) : null);
const attr = (id, n) => { const e = el(id); return e ? e.getAttribute(n) : null; };
const toScreen = (x, y) => {
const r = stageEl().getBoundingClientRect();
@@ -46,6 +52,7 @@ const res = await page.evaluate(async () => {
const cmToUnits = (cm) => (cm / CELL) * PITCH;
const near = (a, b, tol) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= tol;
const pe = (id) => { const e = el(id); return e ? getComputedStyle(e).pointerEvents : null; };
const box = () => c._decorBoxOf(sofaNow());
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
c._curSpaceCfg.decor = [];
@@ -148,7 +155,7 @@ const res = await page.evaluate(async () => {
redo: c._geometryHistory.redoName,
};
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
await c.updateComplete;
await settleLive();
const firstPreview = c._editorRuntime?._furniturePreviewPlacement()
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
const firstTransform = ghost()?.getAttribute('transform');
@@ -176,7 +183,7 @@ const res = await page.evaluate(async () => {
stageEl().dispatchEvent(new PointerEvent('pointerleave', {
bubbles: true, composed: true, pointerId: 11, pointerType: 'mouse',
}));
await c.updateComplete;
await settleLive();
out.pointerLeaveClearsPreview = !ghost() && c._furnPreviewInput === null;
// Unknown/stale symbols fail dark: neither an invisible ghost nor a decor
@@ -184,7 +191,7 @@ const res = await page.evaluate(async () => {
c._furnPalette = { symbol: 'future_unknown_symbol', w: 180, h: 90 };
await c.updateComplete;
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
await c.updateComplete;
await settleLive();
const beforeUnknown = c._decorList.length;
ev('pointerdown', stageEl(), 300, 300, { pointerType: 'mouse' });
await c.updateComplete;
@@ -196,7 +203,7 @@ const res = await page.evaluate(async () => {
// ================= 4. размещение в реальном размере ======================
// середина комнаты r1 (40..550 × 140..580) — до стен дальше порога магнита
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
await c.updateComplete;
await settleLive();
const committedPreview = c._editorRuntime?._furniturePreviewPlacement()
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
const committedPreviewStroke = Number(ghost()?.getAttribute('stroke-width'));
@@ -266,7 +273,7 @@ const res = await page.evaluate(async () => {
category('bed')?.click(); await c.updateComplete;
pick('bed_double'); await c.updateComplete;
ev('pointermove', stageEl(), 300, 140 + wallHalf, { pointerType: 'mouse' });
await c.updateComplete;
await settleLive();
const thickWallPreview = c._editorRuntime?._furniturePreviewPlacement()
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
ev('pointerdown', stageEl(), 300, 140 + wallHalf);
@@ -282,6 +289,39 @@ const res = await page.evaluate(async () => {
&& ['x', 'y', 'w', 'h'].every((key) => near(bed[key], thickWallPreview[key], 1e-12))
&& (bed.angle || 0) === thickWallPreview.angle;
// #447: the same finite outer atom also has its exterior physical face.
// Raw intent above the wall must never be pulled through to the room side;
// preview, commit and exact-axis drag all share that decision.
await openFurniture();
c._furnPalette = { symbol: 'chair', w: 50, h: 50 };
c.requestUpdate(); await c.updateComplete;
const exteriorIntentY = 140 - wallHalf;
ev('pointermove', stageEl(), 300, exteriorIntentY, { pointerType: 'mouse' });
await settleLive();
const exteriorPreview = c._editorRuntime?._furniturePreviewPlacement()
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
const exteriorDepth = cmToUnits(50);
const exteriorCentreY = 140 - wallHalf - exteriorDepth / 2;
out.exteriorPreviewStaysOutside = !!exteriorPreview
&& near((exteriorPreview.y + exteriorPreview.h / 2) * 1000, exteriorCentreY, 1e-6)
&& Math.abs(Math.abs(exteriorPreview.angle) - 180) < 1e-6;
ev('pointerdown', stageEl(), 300, exteriorIntentY);
await c.updateComplete;
const exteriorChair = c._decorList.find((shape) => shape.symbol === 'chair');
const exteriorChairId = exteriorChair?.id ?? null;
out.exteriorCommitMatchesPreview = !!exteriorChair && !!exteriorPreview
&& ['x', 'y', 'w', 'h'].every((key) => near(exteriorChair[key], exteriorPreview[key], 1e-12))
&& (exteriorChair.angle || 0) === exteriorPreview.angle;
ev('pointerdown', el(exteriorChairId), 300, exteriorCentreY);
ev('pointermove', stageEl(), 300, 140);
await settleLive();
const exteriorAxisDrag = c._decorList.find((shape) => shape.id === exteriorChairId);
out.exteriorExactAxisDragPreservesSide = !!exteriorAxisDrag
&& near((exteriorAxisDrag.y + exteriorAxisDrag.h / 2) * 1000, exteriorCentreY, 1e-6)
&& Math.abs(Math.abs(exteriorAxisDrag.angle || 0) - 180) < 1e-6;
ev('pointerup', stageEl(), 300, 140);
await c.updateComplete;
// The regular decor snap resolves this pointer to the shared wall axis.
// Surface-side selection must still use the raw point on the r2 side.
await openFurniture();
@@ -289,10 +329,10 @@ const res = await page.evaluate(async () => {
pick('chair'); await c.updateComplete;
const sharedIntentX = 550 + wallHalf / 2;
ev('pointermove', stageEl(), sharedIntentX, 300, { pointerType: 'mouse' });
await c.updateComplete;
await settleLive();
ev('pointerdown', stageEl(), sharedIntentX, 300);
await c.updateComplete;
const sharedChair = c._decorList.find((s) => s.symbol === 'chair');
const sharedChair = c._decorList.find((s) => s.symbol === 'chair' && s.id !== exteriorChairId);
const chairDepth = (sharedChair?.h ?? NaN) * 1000;
const rightSurfaceCentre = 550 + wallHalf + chairDepth / 2;
out.sharedWallUsesRawPointerSide = !!sharedChair
@@ -304,7 +344,7 @@ const res = await page.evaluate(async () => {
const sharedChairY = sharedChair ? (sharedChair.y + sharedChair.h / 2) * 1000 : NaN;
ev('pointerdown', el(sharedChairId), rightSurfaceCentre, sharedChairY);
ev('pointermove', stageEl(), 550, sharedChairY);
await c.updateComplete;
await settleLive();
const axisDraggedChair = c._decorList.find((s) => s.id === sharedChairId);
out.exactAxisDragPreservesWallSide = !!axisDraggedChair
&& near((axisDraggedChair.x + axisDraggedChair.w / 2) * 1000, rightSurfaceCentre, 1e-6);
@@ -352,14 +392,14 @@ const res = await page.evaluate(async () => {
ev('pointerdown', el(sofaId), 300, 300);
out.dragStarted = !!sofaId && c._decorMove?.id === sofaId;
ev('pointermove', stageEl(), 540, 300);
await c.updateComplete;
await settleLive();
const pulled = sofaNow();
out.dragMagnetTurnsItToTheWall = Math.abs(Math.abs(pulled.angle || 0) - 90) < 1e-6;
out.dragMagnetPressesTheBack =
near((pulled.x + pulled.w / 2) * 1000,
550 - wallHalf - cmToUnits(90) / 2, 1e-6);
ev('pointermove', stageEl(), 300, 300, { shiftKey: true });
await c.updateComplete;
await settleLive();
const shiftDragGridIndex = (sofaNow().x * 1000) / PITCH;
out.shiftDragRemainsGridBound = near(shiftDragGridIndex, Math.round(shiftDragGridIndex), 1e-6);
ev('pointerup', stageEl(), 300, 300, { shiftKey: true });
@@ -377,7 +417,7 @@ const res = await page.evaluate(async () => {
c._cfgEpoch++;
c._decorSel = sofaId; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const frame = () => sr().querySelector('.dtframe');
const frame = () => sr().querySelector('[data-hp-live-editor] .dtframe') || sr().querySelector('.dtframe');
out.frameOnSelection = !!frame();
out.furnitureFrameHasFourSideHandles = !!frame()
&& frame().classList.contains('dtfurnitureframe')
@@ -391,9 +431,10 @@ const res = await page.evaluate(async () => {
const rOf = (sel) => { const e = frame()?.querySelector(sel); return e ? +e.getAttribute('r') : NaN; };
out.handleSizeIsTheTaskOneSize = Math.abs(rOf('.dtknob') * 4 - rOf('.dthandle.dtrot'))
/ Math.max(rOf('.dthandle.dtrot'), 1e-9) < 0.05;
out.frameBoxIsTheShapeBox = !!c._dtBox
&& near(c._dtBox.w, sofaNow().w * 1000, 1e-6)
&& near(c._dtBox.h, sofaNow().h * 1000, 1e-6);
const frameBox = frame()?.querySelector('.dtbox');
out.frameBoxIsTheShapeBox = !!frameBox
&& near(+frameBox.getAttribute('width'), sofaNow().w * 1000, 1e-6)
&& near(+frameBox.getAttribute('height'), sofaNow().h * 1000, 1e-6);
const visiblePath = el(sofaId);
const hitPath = sr().querySelector(`.dfurniturehit[data-id="${sofaId}"]`);
@@ -406,7 +447,7 @@ const res = await page.evaluate(async () => {
// Shift: independent, continuous width/depth + live plates.
const before = { w: sofaNow().w, h: sofaNow().h };
const b = c._dtBox || { x: 0, y: 0, w: 0, h: 0 };
const b = box() || { x: 0, y: 0, w: 0, h: 0 };
const handles = [...(frame()?.querySelectorAll('.dthandle') || [])];
// #400: угловая ручка ищется по РОЛИ, а не по индексу в DOM. Индекс держался
// на порядке отрисовки, а порядок — это решение о приоритете хита (углы
@@ -418,7 +459,7 @@ const res = await page.evaluate(async () => {
ev('pointerdown', se, b.x + b.w, b.y + b.h);
out.cornerDragStarted = c._dtDrag?.kind === 'scale' && !!c._dtDrag?.orig;
ev('pointermove', stageEl(), b.x + b.w + 100, b.y + b.h + 20, { shiftKey: true });
await c.updateComplete;
await settleLive();
const grown = sofaNow();
out.cornerGrowsTheWidth = grown.w > before.w && near(grown.w * 1000, b.w + 100, 1e-6);
out.cornerGrowsTheDepthINDEPENDENTLY = grown.h > before.h
@@ -433,7 +474,7 @@ const res = await page.evaluate(async () => {
&& plates.some((p) => /m|м|′/.test(p.textContent));
// Without Shift the original ratio is preserved, still without grid snap.
ev('pointermove', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
await c.updateComplete;
await settleLive();
const proportional = sofaNow();
const wCells = (proportional.w * 1000) / PITCH;
out.defaultResizePreservesRatio = near(proportional.w / proportional.h, before.w / before.h, 1e-5);
@@ -445,12 +486,12 @@ const res = await page.evaluate(async () => {
// A middle handle changes only one axis and keeps the opposite edge fixed.
const edgeBefore = { ...sofaNow() };
const eb = c._dtBox;
const eb = box();
const rightEdge = [...frame().querySelectorAll('.dthandle.dtedge')].find((handle) =>
near(+handle.getAttribute('cx'), eb.x + eb.w, 1e-6));
ev('pointerdown', rightEdge, eb.x + eb.w, eb.y + eb.h / 2);
ev('pointermove', stageEl(), eb.x + eb.w + 13.7, eb.y + eb.h / 2);
await c.updateComplete;
await settleLive();
const edgeAfter = sofaNow();
out.edgeHandleChangesOneAxisContinuously = near(edgeAfter.h, edgeBefore.h, 1e-9)
&& near(edgeAfter.w * 1000, edgeBefore.w * 1000 + 13.7, 1e-6)
@@ -461,13 +502,13 @@ const res = await page.evaluate(async () => {
// Crossing the fixed corner toggles only the crossed axis. pointercancel
// must restore the exact pre-gesture object and create no persisted flip.
const crossBefore = JSON.parse(JSON.stringify(sofaNow()));
const cb = c._dtBox;
const cb = box();
const crossHandles = [...frame().querySelectorAll('.dthandle')]
.filter((el) => !el.classList.contains('dtrot') && !el.classList.contains('dtedge'));
const crossSe = crossHandles[2]; // #400: по роли, не по позиции в DOM
ev('pointerdown', crossSe, cb.x + cb.w, cb.y + cb.h);
ev('pointermove', stageEl(), cb.x - 25, cb.y + cb.h + 10, { shiftKey: true });
await c.updateComplete;
await settleLive();
const crossed = sofaNow();
out.crossingTogglesOnlyHorizontalFlip = crossed.flip_h === true && !crossed.flip_v
&& crossed.w > 0 && crossed.h > 0 && near(crossed.x * 1000 + crossed.w * 1000, cb.x, 1e-6);
@@ -484,17 +525,17 @@ const res = await page.evaluate(async () => {
ev('pointerdown', frame()?.querySelector('.dthandle.dtrot'), cx + 100, cy);
out.rotateStarted = c._dtDrag?.kind === 'rotate';
ev('pointermove', stageEl(), cx + 100, cy + 100); // ровно 45°
await c.updateComplete;
await settleLive();
out.rotates45 = near(sofaNow().angle || 0, 45, 1e-6);
ev('pointermove', stageEl(), cx + 100, cy + 2); // ~1.1° → липнет к 0
await c.updateComplete;
await settleLive();
const fine = sofaNow().angle;
out.rotationIsFreeWithoutShift = fine > 0.5 && fine < 5;
ev('pointermove', stageEl(), cx + 100, cy + 2, { shiftKey: true });
await c.updateComplete;
await settleLive();
out.shiftSnapsRotationTo45 = (sofaNow().angle || 0) === 0;
ev('pointermove', stageEl(), cx + 100, cy + 100);
await c.updateComplete;
await settleLive();
ev('pointerup', stageEl(), cx + 100, cy + 100);
await c.updateComplete;
out.rotationIsRendered = /rotate\(/.test(attr(sofaId, 'transform') || '');
+5 -1
View File
@@ -101,7 +101,11 @@ await page.evaluate(async () => {
// raster pair and prove the precision ratio separately below.
runtime._gridLevels = mode === 'view' ? originalGridLevels : () => null;
card._gridLevels = () => runtime._gridLevels();
card._labs = { ...card._labs, active: projection === 'iso' ? ['iso'] : [] };
card._labs = {
...card._labs,
alpha: projection === 'iso',
active: projection === 'iso' ? ['iso'] : [],
};
card._viewPreference = { ...card._viewPreference, f1: projection };
card._isoFallback.clear();
card._isoGeometryCache.clear();
+2 -2
View File
@@ -271,9 +271,9 @@ const farIcon = await page.evaluate(async () => {
const o = {};
c._resetZoom(); await window.__waitForCamera(c);
const at1 = size();
c._applyView(4); await c.updateComplete;
c._applyView(4); await new Promise((resolve) => requestAnimationFrame(resolve));
const at4 = size();
c._applyView(1 / 3); await c.updateComplete;
c._applyView(1 / 3); await new Promise((resolve) => requestAnimationFrame(resolve));
const atOut = size();
c._resetZoom(); await window.__waitForCamera(c);
o.iconGrowsWithZoomIn = at1 > 4 && Math.abs(at4 / at1 - 4) < 0.05;
+14 -10
View File
@@ -11,18 +11,15 @@ const out = await page.evaluate(async () => {
return [rect.left + rect.width / 2, rect.top + rect.height / 2];
};
const enable = async () => {
history.replaceState(null, '', '#space=f1&hp-labs=iso');
history.replaceState(null, '', '#space=f1&hp_alpha=1');
dispatchEvent(new HashChangeEvent('hashchange'));
if (typeof card._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
const active = Object.freeze(['iso']);
// The product flag expires at 1.65.0. Keep this renderer/lifecycle smoke
// explicit without extending the public Labs registry contract.
card._onLabsSnapshot({ active, space: '' });
window.__hpLabs = active;
await card.updateComplete;
await frame();
};
const result = {};
result.cleanProfileStartsOff = window.__hpAlpha === false
&& JSON.stringify(window.__hpLabs) === '[]'
&& !root().querySelector('[data-hp="projection-toggle"]');
const configSpace = card._serverCfg.spaces.find((space) => space.id === 'f1');
configSpace.settings = {
...(configSpace.settings || {}), show_borders: true, show_names: true,
@@ -66,8 +63,9 @@ const out = await page.evaluate(async () => {
result.isoGateFlipReversesTurn = centredGate?.leaves[0]?.turnDeg
=== -flippedGate?.leaves[0]?.turnDeg;
const toggle = root().querySelector('[data-hp="projection-toggle"]');
result.labsSnapshotFrozen = Object.isFrozen(window.__hpLabs)
result.alphaSnapshotFrozen = window.__hpAlpha === true && Object.isFrozen(window.__hpLabs)
&& JSON.stringify(window.__hpLabs) === '["iso"]';
result.alphaStored = localStorage.getItem('houseplan_card_alpha_v1') === '1';
result.toggleShown = !!toggle;
result.toggleMinHitTarget = toggle && toggle.getBoundingClientRect().width >= 44
&& toggle.getBoundingClientRect().height >= 44;
@@ -144,14 +142,20 @@ const out = await page.evaluate(async () => {
card.requestUpdate();
await card.updateComplete;
history.replaceState(null, '', '#space=f1&hp-labs=-iso');
history.replaceState(null, '', '#space=f1&hp_alpha=0');
dispatchEvent(new HashChangeEvent('hashchange'));
await card.updateComplete;
await frame();
result.removalIsImmediateFlat = JSON.stringify(window.__hpLabs) === '[]'
result.removalIsImmediateFlat = window.__hpAlpha === false
&& localStorage.getItem('houseplan_card_alpha_v1') === '0'
&& JSON.stringify(window.__hpLabs) === '[]'
&& !root().querySelector('[data-hp="projection-toggle"]')
&& !root().querySelector('[data-hp="iso-walls"]')
&& !root().querySelector('[id^="hp-iso-"]');
await enable();
result.reenableRestoresPreference = window.__hpAlpha === true
&& root().querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true'
&& !!root().querySelector('[data-hp="iso-walls"]');
return result;
});
+5 -10
View File
@@ -9,14 +9,8 @@ const out = await page.evaluate(async () => {
const root = (card) => card.renderRoot;
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
history.replaceState(null, '', '?hp-labs=iso#space=f1');
history.replaceState(null, '', '?hp_alpha=1#space=f1');
dispatchEvent(new HashChangeEvent('hashchange'));
if (typeof original._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
const testOnlyIso = Object.freeze(['iso']);
// The product registry remains expired; this direct snapshot exercises the
// dormant Stage 2 renderer and is removed by the real -iso path below.
original._onLabsSnapshot({ active: testOnlyIso, space: '' });
window.__hpLabs = testOnlyIso;
await original.updateComplete;
const configSpace = original._serverCfg.spaces.find((space) => space.id === 'f1');
configSpace.settings = {
@@ -301,7 +295,6 @@ const out = await page.evaluate(async () => {
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', kiosk });
host.replaceChildren(card);
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
card.hass = window.__mkHass();
const started = performance.now();
while ((!card._loadOk || card._booting) && performance.now() - started < 9000) await wait(30);
@@ -333,11 +326,13 @@ out.orientationResizeKeepsIso = await page.evaluate(async () => {
});
out.kioskEmergencyOffIsFlat = await page.evaluate(async () => {
const card = document.querySelector('houseplan-card');
history.replaceState(null, '', '?hp-labs=-iso#space=f1');
history.replaceState(null, '', '?hp_alpha=0#space=f1');
dispatchEvent(new HashChangeEvent('hashchange'));
await card.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
return !card.renderRoot.querySelector('[data-hp="iso-walls"]')
return window.__hpAlpha === false
&& localStorage.getItem('houseplan_card_alpha_v1') === '0'
&& !card.renderRoot.querySelector('[data-hp="iso-walls"]')
&& !card.renderRoot.querySelector('[data-hp="projection-toggle"]');
});
+2 -1
View File
@@ -210,7 +210,8 @@ const result = await page.evaluate(async (spaceFixture) => {
staticCard.remove();
const labs = Object.freeze(['iso']);
card._onLabsSnapshot({ active: labs, space: '' });
card._onLabsSnapshot({ alpha: true, active: labs, space: '' });
window.__hpAlpha = true;
window.__hpLabs = labs;
card._setProjection('iso');
await update(false);
+8 -7
View File
@@ -22,21 +22,22 @@ const res = await page.evaluate(async () => {
// 3) свайп при 1:1 переключает пространство (и точки показываются)
const s0 = c._space;
const stage = sr().querySelector('.stage');
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, clientX: 450, clientY: 305 }));
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0, clientX: 450, clientY: 305 }));
await c.updateComplete;
out.swipeSwitches = c._space !== s0;
out.dotsShown = !!sr().querySelector('.kioskdots');
// 4) при зуме свайп не переключает
c._zoom = 2; const s1 = c._space;
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 2, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 2, clientX: 450, clientY: 305 }));
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 2, pointerType: 'touch', isPrimary: true, button: 0, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 2, pointerType: 'touch', isPrimary: true, button: 0, clientX: 450, clientY: 305 }));
await c.updateComplete;
out.noSwipeZoomed = c._space === s1;
// 5) двойной тап сбрасывает зум
c._lastTap = Date.now() - 100;
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 3, clientX: 500, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 3, clientX: 501, clientY: 300 }));
for (const pointerId of [3, 4]) {
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId, pointerType: 'touch', isPrimary: true, button: 0, clientX: 500, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId, pointerType: 'touch', isPrimary: true, button: 0, clientX: 501, clientY: 300 }));
}
const resetStarted = performance.now();
while (c._cameraTransition?.active) {
if (performance.now() - resetStarted > 1000) throw new Error('double-tap camera transition did not settle');
+6 -4
View File
@@ -30,7 +30,8 @@ const out = await page.evaluate(async () => {
const sr = k.shadowRoot || k.renderRoot;
const stage = sr.querySelector('.stage');
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
bubbles: true, composed: true, cancelable: true, pointerId: id,
pointerType: 'touch', isPrimary: true, button: 0, clientX: x, clientY: y,
}));
o.kioskHasSeveralSpaces = k._model.length > 1;
@@ -108,9 +109,10 @@ const out = await page.evaluate(async () => {
await home();
k._applyView(2);
await k.updateComplete;
k._lastTap = Date.now() - 100;
fire('pointerdown', 58, 500, 300);
fire('pointerup', 58, 501, 300);
for (const id of [58, 60]) {
fire('pointerdown', id, 500, 300);
fire('pointerup', id, 501, 300);
}
const resetStarted = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (k._cameraTransition.active && performance.now() - resetStarted < 1000);
+2 -1
View File
@@ -201,7 +201,8 @@ const result = await page.evaluate(async (source) => {
staticCard.remove();
const labs = Object.freeze(['iso']);
card._onLabsSnapshot({ active: labs, space: '' });
card._onLabsSnapshot({ alpha: true, active: labs, space: '' });
window.__hpAlpha = true;
window.__hpLabs = labs;
card._setProjection('iso');
await update(false);
+2 -1
View File
@@ -211,7 +211,8 @@ const result = await page.evaluate(async (source) => {
staticCard.remove();
const labs = Object.freeze(['iso']);
card._onLabsSnapshot({ active: labs, space: '' });
card._onLabsSnapshot({ alpha: true, active: labs, space: '' });
window.__hpAlpha = true;
window.__hpLabs = labs;
card._setProjection('iso');
await update(false);
+5 -2
View File
@@ -42,7 +42,10 @@ await page.evaluate(() => {
return c.updateComplete && true;
});
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
await page.waitForTimeout(220); // editor chrome transition changes stage coordinates
await page.waitForFunction(() => {
const c = window.__card;
return !!c._editorRuntime && !c._modeTransitionBusy && !c._cameraTransition?.active;
});
await settle();
const screenPt = (x, y) => page.evaluate(([x, y]) => {
@@ -288,7 +291,7 @@ const previews = () => page.evaluate(() =>
// the click places it AT THE MAGNET and clears every hint
{
const [hx, hy] = await screenPt(293.7, 141);
const [hx, hy] = await screenPt(295, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
await page.mouse.click(hx, hy);
+2
View File
@@ -125,6 +125,7 @@ Object.assign(out, await page.evaluate(async () => {
fire(h, 'pointerdown', 21, r.left + r.width / 2, r.top + r.height / 2);
o.resizeHandleTakesThePointer = c._resize?.dragging === true && c._panStart === null;
fire(h, 'pointermove', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
await Promise.resolve();
const v1 = { ...c._viewOr(c._baseVb()) };
o.resizeHandleDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
o.resizeHandleResizes = c._resize?.dragging === true && c._resize.moved === true;
@@ -143,6 +144,7 @@ Object.assign(out, await page.evaluate(async () => {
fire(dev, 'pointerdown', 22, r.left + r.width / 2, r.top + r.height / 2);
o.deviceTakesThePointer = !!c._deviceDrag && c._panStart === null;
fire(dev, 'pointermove', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
await Promise.resolve();
c._stagePointerMove({ pointerId: 22, clientX: r.left + 60, clientY: r.top + 50 });
const v1 = { ...c._viewOr(c._baseVb()) };
o.deviceDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
+1
View File
@@ -81,6 +81,7 @@ const out = await page.evaluate(async () => {
card._opPointerMove(new PointerEvent('pointermove', {
pointerId: 186, clientX: 20, clientY: 0, bubbles: true,
}), saved);
await Promise.resolve();
runtime._svgPoint = originalSvgPoint;
card._opPointerUp(new PointerEvent('pointerup', { pointerId: 186, bubbles: true }), saved);
await update();
+150
View File
@@ -0,0 +1,150 @@
// #451: production-bundle guard for the boundary between HA intake, full Lit
// renders and RAF-owned interaction layers. The large benchmark owns timings;
// this fast smoke runs on every selected-smoke pass and owns wiring semantics.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const result = await page.evaluate(async () => {
const card = window.__card;
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 5000) => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error('render invalidation smoke timed out');
await new Promise((done) => setTimeout(done, 10));
}
};
const out = {};
card._setMode('view', false);
await card.updateComplete;
if ('_bootSoft' in card) await until(() => card._bootSoft === false);
await frame();
let renders = 0;
let intakes = 0;
let scans = 0;
const originalRenderBody = card._renderBody.bind(card);
const originalObserve = card._renderLife.observe.bind(card._renderLife);
const originalBindingStatus = card._bindingStatus.bind(card);
card._renderBody = (...args) => { renders++; return originalRenderBody(...args); };
card._renderLife.observe = (...args) => { intakes++; return originalObserve(...args); };
card._bindingStatus = (...args) => { scans++; return originalBindingStatus(...args); };
const dependency = [...(card._visibleDeviceSnapshot?.entityIds || [])]
.find((id) => card.hass.states?.[id]);
const originalHass = card.hass;
const stableConfig = JSON.stringify(card._serverCfg);
const stableLayout = JSON.stringify(card._layout);
const withState = (id, state) => ({
...card.hass,
states: { ...card.hass.states, [id]: { ...card.hass.states[id], state } },
});
const irrelevantBefore = renders;
const intakeBefore = intakes;
for (let index = 0; index < 5; index++) {
card.hass = {
...card.hass,
states: {
...card.hass.states,
[`sensor.hp_451_irrelevant_${index}`]: { entity_id: `sensor.hp_451_irrelevant_${index}`, state: String(index), attributes: {} },
},
};
}
await card.updateComplete;
await frame();
out.irrelevantHaHasNoFullRender = renders - irrelevantBefore === 0;
out.irrelevantHaStillIntakes = intakes - intakeBefore === 5;
const relevantBefore = renders;
if (dependency) card.hass = withState(dependency, '451-outside');
await card.updateComplete;
await frame();
out.relevantHaRendersOnce = !!dependency && renders - relevantBefore === 1;
scans = 0;
const heavy = card.renderRoot.querySelector('.wallbodies');
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const hoverBefore = renders;
const device = card.renderRoot.querySelector('[data-hp="device"]');
if (device) {
device.dispatchEvent(new PointerEvent('pointerover', {
bubbles: true, composed: true, pointerId: 450, pointerType: 'mouse', isPrimary: true,
clientX: rect.left + 220, clientY: rect.top + 220,
}));
device.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, composed: true, pointerId: 450, pointerType: 'mouse', isPrimary: true,
clientX: rect.left + 225, clientY: rect.top + 225,
}));
}
await frame();
const liveTip = card.renderRoot.querySelector('[data-hp-live-tip]');
out.hoverUsesLightweightPaint = !!device && renders === hoverBefore
&& !!liveTip && !liveTip.hidden;
device?.dispatchEvent(new PointerEvent('pointerleave', {
bubbles: false, composed: true, pointerId: 450, pointerType: 'mouse', isPrimary: true,
}));
await frame();
const pointer = (type, x, y, buttons) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, pointerId: 451, pointerType: 'mouse', isPrimary: true,
button: 0, buttons, clientX: rect.left + x, clientY: rect.top + y,
}));
pointer('pointerdown', 300, 300, 1);
await card.updateComplete;
const panBefore = renders;
const panIntakeBefore = intakes;
for (let index = 0; index < 3; index++) {
pointer('pointermove', 320 + index * 20, 315 + index * 10, 1);
if (dependency) card.hass = withState(dependency, `451-drag-${index}`);
}
await card.updateComplete;
await frame();
out.panAndDeferredHaSkipFullRender = renders === panBefore;
out.deferredHaTicksStillIntake = intakes - panIntakeBefore === 3;
const panTerminalBefore = renders;
pointer('pointerup', 360, 335, 0);
await card.updateComplete;
await frame();
out.panTerminalRendersOnce = renders - panTerminalBefore === 1;
out.deferredHaIsLastWins = !!dependency
&& card._renderPlanHass.states?.[dependency]?.state === '451-drag-2';
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();
await frame();
const editorBefore = renders;
const editorPaintBefore = card._liveEditorPaintCount || 0;
for (let index = 0; index < 20; index++) {
stage.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, composed: true, pointerId: 452, pointerType: 'mouse', isPrimary: true,
clientX: rect.left + 110 + index, clientY: rect.top + 120 + index,
}));
}
await frame();
out.editorMovesSkipFullRender = renders === editorBefore;
out.editorMovesUseRafPaint = (card._liveEditorPaintCount || 0) > editorPaintBefore;
card._cursorPt = null;
card._path = [];
const editorTerminalBefore = renders;
card.requestUpdate();
await card.updateComplete;
await frame();
out.editorTerminalHasAtMostOneRender = renders - editorTerminalBefore <= 1;
out.heavySceneIdentityStable = card.renderRoot.querySelector('.wallbodies') === heavy;
out.pointerWindowsSkipDiagnosticScans = scans === 0;
out.interactionsDoNotWriteConfig = JSON.stringify(card._serverCfg) === stableConfig;
out.interactionsDoNotWriteLayout = JSON.stringify(card._layout) === stableLayout;
card.hass = originalHass;
return out;
});
checkAll(result);
await finish(browser, result);
+5 -2
View File
@@ -114,10 +114,13 @@ const sharedX = () => page.evaluate(() => {
))).map((point) => point[0]);
});
const domHasSharedX = (x) => page.evaluate((wanted) =>
[...window.__card.renderRoot.querySelectorAll('.rszhandle[aria-disabled="false"]')]
[...(window.__card.renderRoot.querySelector('[data-hp-live-editor] .hp-live-resize')
|| window.__card.renderRoot).querySelectorAll('.rszhandle[aria-disabled="false"]')]
.some((entry) => Math.abs(Number(entry.getAttribute('cx')) - wanted) < 1), x);
const sharedHandleX = () => page.evaluate(() => {
const candidates = [...window.__card.renderRoot.querySelectorAll(
const activeRoot = window.__card.renderRoot.querySelector('[data-hp-live-editor] .hp-live-resize')
|| window.__card.renderRoot;
const candidates = [...activeRoot.querySelectorAll(
'.rszhandle[aria-disabled="false"]',
)].filter((entry) => Math.abs(Number(entry.getAttribute('cy')) - 529.166667) < 2);
return candidates
+294
View File
@@ -0,0 +1,294 @@
// #152: the browser-painted room owns a clean View tap and the shared camera
// fits its exact rendered geometry. This smoke is part of the pre-beta matrix;
// the implementation cycle itself remains typecheck + unit + build.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 800 }, 1);
const out = await page.evaluate(async () => {
const c = window.__card;
const root = c.shadowRoot || c.renderRoot;
const result = {};
const config = {
spaces: [{
id: 'fit-floor', title: 'Fit floor', view_box: [0, 0, 1, 1],
settings: { show_borders: true, show_names: true },
partitions: [{ id: 'fit-wall', a: [0.1, 0.1], b: [0.9, 0.1], cm: 15 }],
rooms: [
{ id: 'outer', name: 'Outer', area: 'fit_outer',
poly: [[0.05, 0.05], [0.95, 0.05], [0.95, 0.95], [0.05, 0.95]] },
{ id: 'room-a', name: 'Office', area: 'fit_office',
poly: [[0.55, 0.15], [0.85, 0.15], [0.85, 0.45], [0.72, 0.45], [0.72, 0.35], [0.55, 0.35]] },
],
}],
markers: [], settings: { filter_seeded: true },
};
const server = (await c.hass.callWS({ type: 'houseplan/config/get' })).config;
for (const key of Object.keys(server)) delete server[key];
Object.assign(server, JSON.parse(JSON.stringify(config)));
c._serverCfg = server;
c._space = 'fit-floor';
c._modelCache = null;
c._frame = null;
c._cfgEpoch++;
c._mode = 'view';
c._zoom = 1;
c._view = null;
c.requestUpdate();
await c.updateComplete;
const waitCamera = async () => {
const started = performance.now();
while (c._cameraTransition?.active) {
if (performance.now() - started > 1200) throw new Error('room camera did not settle');
await new Promise((resolve) => requestAnimationFrame(resolve));
}
await c.updateComplete;
};
const pointer = (target, type, id, x = 500, y = 300, extra = {}) =>
target.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id,
pointerType: 'mouse', isPrimary: true, button: 0,
buttons: type === 'pointerdown' || type === 'pointermove' ? 1 : 0,
clientX: x, clientY: y, ...extra,
}));
const roomNode = () => root.querySelector('[data-hp="room"][data-id="room-a"]');
const room = () => c._spaceModel().rooms.find((item) => item.id === 'room-a');
const margins = () => {
const bounds = c._roomFitBounds(room(), c._spaceModel());
const view = c._view;
return {
left: (bounds.x - view.x) / view.w,
right: (view.x + view.w - bounds.x - bounds.w) / view.w,
top: (bounds.y - view.y) / view.h,
bottom: (view.y + view.h - bounds.y - bounds.h) / view.h,
};
};
localStorage.setItem('houseplan_card_zoom_v1', JSON.stringify({ 'fit-floor': 1 }));
pointer(roomNode(), 'pointerdown', 15201);
pointer(roomNode(), 'pointerup', 15201);
const syncFrames = [];
for (let frame = 0; frame < 3 && c._cameraTransition?.active; frame++) {
await new Promise((resolve) => requestAnimationFrame(resolve));
await c.updateComplete;
const activeView = c._view;
const activeLabel = root.querySelector('.roomlabel[data-id="room-a"]');
const activeSvg = root.querySelector('.plan-svg') || root.querySelector('.zoomwrap > svg');
const position = c._scenePoint([c._labelPos(room(), 'fit-floor').x,
c._labelPos(room(), 'fit-floor').y]);
const expectedLeft = ((position[0] - activeView.x) / activeView.w) * 100;
const expectedTop = ((position[1] - activeView.y) / activeView.h) * 100;
const actualView = activeSvg?.getAttribute('viewBox')?.trim().split(/\s+/).map(Number) || [];
const labelRect = activeLabel?.getBoundingClientRect();
const stageRect = root.querySelector('.stage')?.getBoundingClientRect();
syncFrames.push(actualView.length === 4
&& actualView.every((value, index) => Math.abs(value
- [activeView.x, activeView.y, activeView.w, activeView.h][index]) < 1e-6)
&& !!labelRect && !!stageRect
&& Math.abs(labelRect.left + labelRect.width / 2
- (stageRect.left + stageRect.width * expectedLeft / 100)) < 1
&& Math.abs(labelRect.top + labelRect.height / 2
- (stageRect.top + stageRect.height * expectedTop / 100)) < 1);
}
result.svgAndHtmlShareTweenFrames = syncFrames.length > 0 && syncFrames.every(Boolean);
await waitCamera();
const flatMargins = margins();
result.cleanRoomTapFits = Object.values(flatMargins).every((value) => value >= 0.099);
result.oneFlatAxisUsesTenPercent = Math.min(...Object.values(flatMargins)) <= 0.102;
result.roomIntentIsSessionOnly = c._roomFocus?.roomId === 'room-a'
&& JSON.parse(localStorage.getItem('houseplan_card_zoom_v1'))['fit-floor'] === 1;
const settled = JSON.stringify(c._view);
pointer(roomNode(), 'pointerdown', 15202);
pointer(roomNode(), 'pointerup', 15202);
await waitCamera();
result.repeatedFitIsNoOp = !c._cameraTransition.active && JSON.stringify(c._view) === settled;
const label = root.querySelector('.roomlabel[data-id="room-a"]');
result.labelIsKeyboardAction = label?.getAttribute('role') === 'button'
&& label?.getAttribute('tabindex') === '0'
&& /Office/.test(label?.getAttribute('aria-label') || '');
c._resetZoom(); await waitCamera();
label.focus();
label.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }));
await waitCamera();
result.keyboardUsesSameCommand = c._roomFocus?.roomId === 'room-a'
&& (root.activeElement === label || document.activeElement === label);
const editorLabelsArePassive = [];
for (const mode of ['devices', 'decor']) {
c._mode = mode;
c.requestUpdate();
await c.updateComplete;
const editorLabel = root.querySelector('.roomlabel[data-id="room-a"]');
editorLabelsArePassive.push(editorLabel
&& !editorLabel.hasAttribute('role')
&& !editorLabel.hasAttribute('tabindex')
&& !editorLabel.hasAttribute('aria-label'));
}
result.editorsDoNotExposeRoomAction = editorLabelsArePassive.every(Boolean);
c._mode = 'view';
c.requestUpdate();
await c.updateComplete;
c._resetZoom(); await waitCamera();
c._clearRoomFocus(true);
const currentLabel = root.querySelector('.roomlabel[data-id="room-a"]');
const ownerProbe = document.createElement('span');
ownerProbe.setAttribute('role', 'button');
ownerProbe.dataset.roomFitBlock = 'smoke-action';
currentLabel.append(ownerProbe);
pointer(ownerProbe, 'pointerdown', 15203);
pointer(ownerProbe, 'pointerup', 15203);
await c.updateComplete;
result.commonInteractiveOwnerSuppressesRoomFit = c._roomPointer === null
&& c._roomFocus === null;
const areaLink = currentLabel.querySelector('.rlgo');
const beforeLink = JSON.stringify(c._view);
pointer(areaLink, 'pointerdown', 15204);
pointer(areaLink, 'pointerup', 15204);
await c.updateComplete;
result.areaLinkSuppressesRoomFit = JSON.stringify(c._view) === beforeLink
&& c._roomPointer === null && c._roomFocus === null;
const stage = root.querySelector('.stage');
const base = c._baseVb();
const moveAway = async () => {
c._doubleFit.clear();
c._applyView(2, base[0] + base[2] * 0.65, base[1] + base[3] * 0.4);
await c.updateComplete;
};
await moveAway();
c._fitAll('fit');
await waitCamera();
const toolbarTarget = JSON.stringify({ zoom: c._zoom, view: c._view });
await moveAway();
c._showFar = false;
c._frame = null;
c._roomFocus = { spaceId: 'fit-floor', roomId: 'outer' };
const beforeFirstTap = JSON.stringify({ zoom: c._zoom, view: c._view });
let firstTapUpdates = 0;
const requestUpdate = c.requestUpdate;
c.requestUpdate = (...args) => { firstTapUpdates++; return requestUpdate.apply(c, args); };
pointer(stage, 'pointerdown', 44901, 400, 500);
pointer(stage, 'pointerup', 44901, 401, 500);
c.requestUpdate = requestUpdate;
result.firstBackgroundTapIsPassive = firstTapUpdates === 0
&& JSON.stringify({ zoom: c._zoom, view: c._view }) === beforeFirstTap;
pointer(stage, 'pointerdown', 44902, 650, 500);
pointer(stage, 'pointerup', 44902, 651, 500);
result.normalViewUsesDoubleTapReason = c._cameraTransition.state?.reason === 'double-tap';
await waitCamera();
result.normalViewDoubleClickMatchesFitAll = c._showFar === true && c._roomFocus === null
&& JSON.stringify({ zoom: c._zoom, view: c._view }) === toolbarTarget;
await moveAway();
pointer(stage, 'pointerdown', 44903, 400, 500, { pointerType: 'touch' });
pointer(stage, 'pointerup', 44903, 400, 500, { pointerType: 'touch' });
pointer(stage, 'pointerdown', 44904, 500, 500, { pointerType: 'pen' });
pointer(stage, 'pointerup', 44904, 500, 500, { pointerType: 'pen' });
result.mixedModalitiesDoNotPair = c._zoom === 2 && !c._cameraTransition.active;
pointer(stage, 'pointerdown', 44905, 550, 500, { pointerType: 'pen' });
pointer(stage, 'pointerup', 44905, 550, 500, { pointerType: 'pen' });
await waitCamera();
result.penPairUsesSameFitAll = JSON.stringify({ zoom: c._zoom, view: c._view }) === toolbarTarget;
await moveAway();
pointer(stage, 'pointerdown', 44906, 400, 500, { pointerType: 'touch' });
pointer(stage, 'pointerup', 44906, 400, 500, { pointerType: 'touch' });
pointer(stage, 'pointerdown', 44907, 400, 500, { pointerType: 'touch' });
pointer(stage, 'pointercancel', 44907, 400, 500, { pointerType: 'touch' });
pointer(stage, 'pointerdown', 44908, 400, 500, { pointerType: 'touch' });
pointer(stage, 'pointerup', 44908, 400, 500, { pointerType: 'touch' });
result.cancelDisarmsThePreviousTap = c._zoom === 2 && !c._cameraTransition.active;
c._mode = 'decor';
c._doubleFit.clear();
pointer(stage, 'pointerdown', 44909, 400, 500);
pointer(stage, 'pointerup', 44909, 400, 500);
pointer(stage, 'pointerdown', 44910, 400, 500);
pointer(stage, 'pointerup', 44910, 400, 500);
result.editorBackgroundDoesNotFit = c._zoom === 2 && !c._cameraTransition.active;
c._mode = 'view';
c._doubleFit.clear();
pointer(roomNode(), 'pointerdown', 15205, 500, 300);
pointer(roomNode(), 'pointermove', 15205, 520, 300);
pointer(roomNode(), 'pointerup', 15205, 520, 300);
await c.updateComplete;
result.panCancelsRoomIntent = c._roomFocus === null && c._roomPointer === null;
pointer(roomNode(), 'pointerdown', 15206, 500, 300);
pointer(roomNode(), 'pointerup', 15206, 500, 300);
await waitCamera();
c.style.width = '760px';
c._lastValidStageSize = [1000, c._stageEl.clientHeight];
c._refitView();
await new Promise((resolve) => setTimeout(resolve, 80));
await c.updateComplete;
const resizedMargins = margins();
result.resizeRefitsAtomically = c._roomFocus?.roomId === 'room-a'
&& Object.values(resizedMargins).every((value) => value >= 0.099);
c.style.width = '1000px';
await new Promise((resolve) => setTimeout(resolve, 100));
c._clearRoomFocus(true);
c._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
await c.updateComplete;
c._setProjection('iso');
await c.updateComplete;
pointer(roomNode(), 'pointerdown', 15207);
pointer(roomNode(), 'pointerup', 15207);
await waitCamera();
const isoMargins = margins();
result.isoUsesProjectedBounds = c._effectiveProjection() === 'iso'
&& Object.values(isoMargins).every((value) => value >= 0.099)
&& (c._zoom === 8 || Math.min(...Object.values(isoMargins)) <= 0.102);
const kiosk = document.createElement('houseplan-card');
kiosk.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
kiosk.hass = c.hass;
kiosk.style.cssText = 'position:fixed;inset:0;width:900px;height:700px;z-index:99';
document.body.appendChild(kiosk);
await new Promise((resolve) => setTimeout(resolve, 450));
kiosk.hass = { ...c.hass };
await kiosk.updateComplete;
const kioskRoot = kiosk.shadowRoot || kiosk.renderRoot;
const kioskRoom = kioskRoot.querySelector('[data-hp="room"][data-id="room-a"]');
const kioskPointer = (target, type, id) => target.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id,
pointerType: 'touch', isPrimary: true, button: 0,
buttons: type === 'pointerdown' ? 1 : 0, clientX: 450, clientY: 300,
}));
for (const id of [15208, 15209]) {
kioskPointer(kioskRoom, 'pointerdown', id);
kioskPointer(kioskRoom, 'pointerup', id);
const started = performance.now();
while (kiosk._cameraTransition.active && performance.now() - started < 1200) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
}
const beforeKioskBackground = JSON.stringify({ zoom: kiosk._zoom, view: kiosk._view });
kioskPointer(kiosk._stageEl, 'pointerdown', 15210);
kioskPointer(kiosk._stageEl, 'pointerup', 15210);
result.kioskRoomTapDoesNotEnterDoubleTap = kiosk._roomFocus?.roomId === 'room-a'
&& JSON.stringify({ zoom: kiosk._zoom, view: kiosk._view }) === beforeKioskBackground;
kiosk._clearRoomFocus(true);
kiosk._applyView(2);
for (const id of [15211, 15212]) {
kioskPointer(kiosk._stageEl, 'pointerdown', id);
kioskPointer(kiosk._stageEl, 'pointerup', id);
}
const resetStarted = performance.now();
while (kiosk._cameraTransition.active && performance.now() - resetStarted < 1200) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
result.kioskBackgroundDoubleTapStillResets = Math.abs(kiosk._zoom - 1) < 1e-9;
kiosk.remove();
return result;
});
await finish(browser, checkAll(out));
+2 -2
View File
@@ -94,7 +94,7 @@ const result = await page.evaluate(async () => {
await card._saveSettingsDialog();
await card.updateComplete;
out.falsePersists = card._serverCfg.settings.show_room_tooltip === false;
out.visibleRoomTipCleared = card._tip === null && !root().querySelector('.tip');
out.visibleRoomTipCleared = card._tip === null && root().querySelector('.tip')?.hidden === true;
let roomAreaCalls = 0;
const originalRoomArea = card._roomArea.bind(card);
@@ -105,7 +105,7 @@ const result = await page.evaluate(async () => {
room().dispatchEvent(mouse('pointerenter', 190, 190));
room().dispatchEvent(mouse('pointermove', 190, 190));
await card.updateComplete;
out.disabledRoomTip = card._tip === null && !root().querySelector('.tip');
out.disabledRoomTip = card._tip === null && root().querySelector('.tip')?.hidden === true;
out.disabledSkipsArea = roomAreaCalls === 0;
out.roomHighlightSurvives = card._hoverRoom !== null
&& !!root().querySelector('.room-hover-fill-layer,.room-hover-outline-layer');
+6 -2
View File
@@ -112,7 +112,7 @@ const out = await page.evaluate(async () => {
directBaseline(2);
if (reason === 'home') card._fitAll('home');
else if (reason === 'fit') card._fitAll('fit');
else card._resetZoom('double-tap');
else card._fitAll('double-tap');
reasons[reason] = {
liveReason: card._cameraTransition.state?.reason,
samples: (await settle()).map((sample) => sample.zoom),
@@ -141,7 +141,11 @@ const out = await page.evaluate(async () => {
card._reducedMotion = false;
// Projection owns an atomic structural conversion and cancels the camera.
card._labs = { ...card._labs, active: [...new Set([...card._labs.active, 'iso'])] };
card._labs = {
...card._labs,
alpha: true,
active: [...new Set([...card._labs.active, 'iso'])],
};
card._viewPreference = { ...card._viewPreference, [card._space]: 'flat' };
directBaseline(1);
card._stepZoom(1);
+1 -3
View File
@@ -110,10 +110,8 @@ const res = await page.evaluate(async () => {
out.kioskParity = sr().querySelector('.wallbody')?.getAttribute('d') === planD;
c._config.kiosk = kioskBefore;
history.replaceState(null, '', `?hp-labs=iso#space=${encodeURIComponent(c._space)}`);
history.replaceState(null, '', `?hp_alpha=1#space=${encodeURIComponent(c._space)}`);
dispatchEvent(new HashChangeEvent('hashchange'));
if (typeof c._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
c._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
await c.updateComplete;
c._setProjection('iso');
await update();
+2 -1
View File
@@ -54,7 +54,8 @@ const res = await page.evaluate(async () => {
const humLine = metricLines.findIndex((line) => line.includes('средняя влажность'));
const lqiLine = metricLines.findIndex((line) => line.includes(c._t('tip.lqi')));
out.tipMetricOrder = tempLine >= 0 && humLine === tempLine + 1 && lqiLine === humLine + 1;
out.tipPositionUnchanged = sr().querySelector('.tip')?.getAttribute('style') === 'left:212px;top:212px';
out.tipPositionUnchanged = sr().querySelector('.tip')?.style.left === '212px'
&& sr().querySelector('.tip')?.style.top === '212px';
// _roomArea consumes the rendered room geometry (0..1000), not the stored
// normalized config polygon (0..1).
const expectedArea = c._roomArea(c._spaceModel().rooms[0]);
+1 -3
View File
@@ -118,10 +118,8 @@ const out = await page.evaluate(async () => {
await update();
result.planViewParity = !!planD && root().querySelector('.wallbody')?.getAttribute('d') === planD;
history.replaceState(null, '', '?hp-labs=iso#space=junctions');
history.replaceState(null, '', '?hp_alpha=1#space=junctions');
dispatchEvent(new HashChangeEvent('hashchange'));
if (typeof card._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
await card.updateComplete;
card._setProjection('iso');
await update();
+2 -1
View File
@@ -114,7 +114,8 @@ const result = await page.evaluate(async (source) => {
staticCard.remove();
const labs = Object.freeze(['iso']);
card._onLabsSnapshot({ active: labs, space: '' });
card._onLabsSnapshot({ alpha: true, active: labs, space: '' });
window.__hpAlpha = true;
window.__hpLabs = labs;
card._setProjection('iso');
await update(false);
+2 -1
View File
@@ -73,7 +73,8 @@ const result = await page.evaluate(async (fixtureConfig) => {
staticCard.remove();
const labs = Object.freeze(['iso']);
card._onLabsSnapshot({ active: labs, space: '' });
card._onLabsSnapshot({ alpha: true, active: labs, space: '' });
window.__hpAlpha = true;
window.__hpLabs = labs;
card._setProjection('iso');
card.requestUpdate();
+1 -3
View File
@@ -129,10 +129,8 @@ const res = await page.evaluate(async () => {
out.kioskParity = sr().querySelector('.wallbody')?.getAttribute('d') === planD;
c._config.kiosk = kioskBefore;
history.replaceState(null, '', `?hp-labs=iso#space=${encodeURIComponent(c._space)}`);
history.replaceState(null, '', `?hp_alpha=1#space=${encodeURIComponent(c._space)}`);
dispatchEvent(new HashChangeEvent('hashchange'));
if (typeof c._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
c._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
await c.updateComplete;
c._setProjection('iso');
await update();
+102
View File
@@ -0,0 +1,102 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const result = await page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const wait = (predicate, timeout = 3000) => new Promise((resolve, reject) => {
const started = performance.now();
const tick = () => {
if (predicate()) return resolve();
if (performance.now() - started > timeout) return reject(new Error('topology smoke timeout'));
setTimeout(tick, 20);
};
tick();
});
const mouse = (type, relatedTarget = null) => new PointerEvent(type, {
pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200, relatedTarget,
});
const out = {};
await card.updateComplete;
out.defaultOff = !root().querySelector('hp-zigbee-topology-overlay');
out.offHasNoLazyChunk = !performance.getEntriesByType('resource')
.some((entry) => /zigbee-topology-runtime|hp-zigbee-topology-overlay/.test(entry.name));
let zhaCalls = 0;
const originalHass = card.hass;
card.hass = { ...originalHass, callWS: async (message) => {
if (message.type !== 'zha/devices') return originalHass.callWS(message);
zhaCalls++;
return [
{ ieee: '00124b0000000001', device_reg_id: 'd_light1', neighbors: [
{ ieee: '00124b0000000002', lqi: 180 },
{ ieee: '00124b0000000003', lqi: 80 },
] },
{ ieee: '00124b0000000002', device_reg_id: 'd_lamp', neighbors: [] },
{ ieee: '00124b0000000003', device_reg_id: 'd_mower', neighbors: [] },
{ ieee: '00124b0000000004', device_reg_id: 'd_temp', neighbors: [
{ ieee: '00124b0000000005', lqi: 20 },
] },
{ ieee: '00124b0000000005', device_reg_id: 'd_tv', neighbors: [] },
];
} };
await card._ensureEditorRuntime();
card._openSettingsDialog();
await card.updateComplete;
let settings = root().querySelector('hp-zigbee-topology-settings');
await wait(() => !!settings?.shadowRoot);
settings._emit({ enabled: true, z2mBaseTopics: [] });
await card.updateComplete;
await card._saveSettingsDialog();
await card.updateComplete;
await wait(() => !!root().querySelector('hp-zigbee-topology-overlay'));
out.settingPersists = card._serverCfg.settings.zigbee_topology?.enabled === true;
out.enableDoesNotFetch = zhaCalls === 0;
card._openSettingsDialog();
await card.updateComplete;
settings = root().querySelector('hp-zigbee-topology-settings');
await wait(() => !!settings?.shadowRoot?.querySelector('button'));
await settings._readZha();
await wait(() => settings._snapshot?.states?.zha?.phase === 'ready');
out.explicitZhaRead = zhaCalls === 1;
card._settingsDialog = null;
card.requestUpdate();
await card.updateComplete;
const source = root().querySelector('.dev[data-id="d_light1"]');
source.dispatchEvent(mouse('pointerover'));
await wait(() => root().querySelector('hp-zigbee-topology-overlay')?.shadowRoot
?.querySelector('[data-hp="zigbee-topology-lines"]'));
const overlay = root().querySelector('hp-zigbee-topology-overlay');
out.incidentOnly = overlay.shadowRoot.querySelectorAll('line').length === 1
&& overlay.shadowRoot.querySelectorAll('[data-hp="zigbee-topology-neighbor"]').length === 1;
out.crossSpaceCount = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-remote"]')
?.textContent.trim() === '+1 in other spaces';
out.pointerTransparent = getComputedStyle(overlay).pointerEvents === 'none'
&& getComputedStyle(overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-remote"]')).pointerEvents === 'none';
source.dispatchEvent(mouse('pointerout', root().querySelector('.stage')));
await card.updateComplete;
out.leaveClears = !overlay.shadowRoot.querySelector('line');
source.dispatchEvent(mouse('pointerover'));
await wait(() => overlay.shadowRoot.querySelector('line'));
root().querySelector('.stage').dispatchEvent(new PointerEvent('pointermove', {
pointerType: 'touch', bubbles: true, composed: true, pointerId: 7,
}));
await wait(() => !overlay.shadowRoot.querySelector('line'));
out.touchClears = !overlay.shadowRoot.querySelector('line');
card._setMode('plan');
await card.updateComplete;
out.editorHasNoOverlay = !root().querySelector('hp-zigbee-topology-overlay');
card._setMode('view');
card.hass = { ...card.hass, user: { ...card.hass.user, is_admin: false } };
await card.updateComplete;
out.nonAdminHasNoOverlay = !root().querySelector('hp-zigbee-topology-overlay') && zhaCalls === 1;
return out;
});
checkAll(result);
await finish(browser, result);
+119 -62
View File
@@ -1,125 +1,182 @@
{
"schema": 1,
"fingerprint": "9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15",
"fingerprint": "3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-n2PxGqZY.js",
"houseplan-assets/houseplan-card-CeQyO4TX.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 292752,
"initialViewGzipBytes": 296719,
"lazyFiles": [
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
"houseplan-assets/de-BP_2FY3A.js",
"houseplan-assets/editor-BoI8ovCT.js",
"houseplan-assets/fr-DOlNp4ji.js",
"houseplan-assets/houseplan-editor-runtime-De35hz5H.js",
"houseplan-assets/houseplan-onboarding-runtime-dO-9we_v.js"
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
"houseplan-assets/de-B9ewWWX2.js",
"houseplan-assets/editor-CDcQqv3k.js",
"houseplan-assets/fr-4rsWX0CK.js",
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
"houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
"houseplan-assets/live-interaction-runtime-ClI0mgY3.js",
"houseplan-assets/zigbee-topology-Q4h3gz_F.js",
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
],
"lazyGzipBytes": 223409,
"lazyGzipBytes": 240651,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
"houseplan-assets/editor-BoI8ovCT.js",
"houseplan-assets/houseplan-editor-runtime-De35hz5H.js"
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
"houseplan-assets/editor-CDcQqv3k.js",
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
],
"lazyEditorGzipBytes": 167369,
"lazyEditorGzipBytes": 178324,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
"houseplan-assets/houseplan-onboarding-runtime-dO-9we_v.js"
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js"
],
"lazyOnboardingGzipBytes": 14244,
"lazyOnboardingGzipBytes": 14239,
"lazyLocaleFiles": [
"houseplan-assets/de-BP_2FY3A.js",
"houseplan-assets/fr-DOlNp4ji.js"
"houseplan-assets/de-B9ewWWX2.js",
"houseplan-assets/fr-4rsWX0CK.js"
],
"lazyLocaleGzipBytes": 49080,
"lazyLocaleGzipBytes": 49104,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DMGa8wc7.js",
"sha256": "6226595cb5753e94dbba8a99943f5d7133eb19cfb43d2c7af574b2cd1c995c7a",
"rawBytes": 21418,
"gzipBytes": 7284,
"path": "houseplan-assets/backdrop-pick-Br9hp4fM.js",
"sha256": "473ca1094a2f837fffc71026ba5f9c5787a6a1e18db7b37328f586bf7bdeb73a",
"rawBytes": 21417,
"gzipBytes": 7280,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-n2PxGqZY.js"
"houseplan-assets/houseplan-card-CeQyO4TX.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-BP_2FY3A.js",
"sha256": "0925d353b13fb9ed03f06fa910fb44f9f7460f1edf8efaf1299c91117d223697",
"rawBytes": 82140,
"gzipBytes": 24809,
"path": "houseplan-assets/de-B9ewWWX2.js",
"sha256": "3c291653c70e131993810e78b7ace06a10758b9fbdfa54636f890b36aa8cfc1a",
"rawBytes": 82182,
"gzipBytes": 24822,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-BoI8ovCT.js",
"sha256": "4c882564e63a08f64b1b34ad231bd85d897e12cd2973a1ffcbcfe0d091ceff02",
"rawBytes": 3826,
"gzipBytes": 1585,
"path": "houseplan-assets/editor-CDcQqv3k.js",
"sha256": "76a014106e36d05f727ecc2bfb654708df0d5d3db54d498bb58254eb9ebec15b",
"rawBytes": 3831,
"gzipBytes": 1583,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-n2PxGqZY.js"
"houseplan-assets/houseplan-card-CeQyO4TX.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-DOlNp4ji.js",
"sha256": "d54f2c24b83c924eba22439817b54d7793af00d28fd4f16e18e0cf35e8737ea3",
"rawBytes": 84267,
"gzipBytes": 24271,
"path": "houseplan-assets/fr-4rsWX0CK.js",
"sha256": "b0508cefcba06761e14436fd8ab8bef916c0bdcb70a71e1d338ba7722057d6f1",
"rawBytes": 84312,
"gzipBytes": 24282,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-n2PxGqZY.js",
"sha256": "f2e5ff91866347e77fe2d924294e119651f41641356d1f761188f175500c2af1",
"rawBytes": 1039630,
"gzipBytes": 291954,
"path": "houseplan-assets/houseplan-card-CeQyO4TX.js",
"sha256": "24597a66c6c0b7ddb3859a5bc2e8b58e08ef57d4612d0cf9222895122edfe92b",
"rawBytes": 1053904,
"gzipBytes": 295923,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-BP_2FY3A.js",
"houseplan-assets/editor-BoI8ovCT.js",
"houseplan-assets/fr-DOlNp4ji.js",
"houseplan-assets/houseplan-editor-runtime-De35hz5H.js",
"houseplan-assets/houseplan-onboarding-runtime-dO-9we_v.js"
"houseplan-assets/de-B9ewWWX2.js",
"houseplan-assets/editor-CDcQqv3k.js",
"houseplan-assets/fr-4rsWX0CK.js",
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
"houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
"houseplan-assets/live-interaction-runtime-ClI0mgY3.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-De35hz5H.js",
"sha256": "9f44dafa722abba6a47073110496e99d178ee661f38bed2e189280f629980f73",
"rawBytes": 601529,
"gzipBytes": 158500,
"path": "houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
"sha256": "fe6417012721db8c1124e8d1a1b9f037db6edd8edb1ccf328bbc1c9697186cbd",
"rawBytes": 622491,
"gzipBytes": 164615,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
"houseplan-assets/houseplan-card-n2PxGqZY.js"
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
"houseplan-assets/houseplan-card-CeQyO4TX.js",
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
],
"dynamicImports": [
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
"sha256": "69cf99a15ce74f74a309241f88f2cf0eade072507f2ec463560956367549d5d2",
"rawBytes": 28088,
"gzipBytes": 6959,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
"houseplan-assets/houseplan-card-CeQyO4TX.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-dO-9we_v.js",
"sha256": "429df2912042da7959c13847d96b6a957a5e097c2899b82e82bb0f9820e86067",
"rawBytes": 28088,
"gzipBytes": 6960,
"path": "houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
"sha256": "e64d8d0dae12904eba4aa3f87fb7f046962091eb7284016e0442c838be3cdfb2",
"rawBytes": 6006,
"gzipBytes": 2376,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
"houseplan-assets/houseplan-card-n2PxGqZY.js"
"houseplan-assets/houseplan-card-CeQyO4TX.js",
"houseplan-assets/zigbee-topology-Q4h3gz_F.js",
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/live-interaction-runtime-ClI0mgY3.js",
"sha256": "05f378c2eef76f35ea98d05821b4680cae1bd599e410d27fbe69ad5ad76eb0d6",
"rawBytes": 5196,
"gzipBytes": 2127,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CeQyO4TX.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-Q4h3gz_F.js",
"sha256": "c6f7dc8aad18071bd7766901c382201b0252725fae2c77993483030b3f73ca67",
"rawBytes": 12382,
"gzipBytes": 4846,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CeQyO4TX.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-runtime-CVolox2I.js",
"sha256": "e4dcb60eb07d78c23782ac134e498fff6b348f4f35a2a7820a13582ceb1887fa",
"rawBytes": 3941,
"gzipBytes": 1761,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CeQyO4TX.js",
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "8c663d28d60b83468ee17e7b8e6891d9579e96e1c8c89cd5ca2ecd23c89e84a3",
"sha256": "03b15da3629659eba3779885ccf8f0206455ba95378dd1fa246c8ea4bb372a8e",
"rawBytes": 1183,
"gzipBytes": 798,
"gzipBytes": 796,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-n2PxGqZY.js"
"houseplan-assets/houseplan-card-CeQyO4TX.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
-14
View File
@@ -1,14 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-n2PxGqZY.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);
+14
View File
@@ -0,0 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{v as e,l as o,z as t,dt as s,A as a,du as i,dv as l,dw as n,E as r,c}from"./houseplan-card-CeQyO4TX.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15";import{l as t,aQ as s,A as e,t as o,c as a,cd as l,ce as i,cf as h,cg as c,aN as n,ch as r,cm as p,ci as d,cj as _,ck as u,cl as g,dc as m,aM as b,aO as $,dd as v}from"./houseplan-card-n2PxGqZY.js";import{i as f,c as y,e as w,r as D,a as k,b as S,d as C,t as M}from"./backdrop-pick-DMGa8wc7.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{l as t,a$ as s,A as e,z as o,c as a,cj as l,ck as i,cl as h,cm as c,aY as n,cn as r,cs as p,co as d,cp as _,cq as u,cr as g,dj as m,aX as b,aZ as $,dk as v}from"./houseplan-card-CeQyO4TX.js";import{i as f,c as y,e as w,r as D,a as k,b as S,d as C,t as M}from"./backdrop-pick-Br9hp4fM.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,p="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:r(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:p,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=p(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:_,tempMax:d,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){const s=this.host._spaceDialog,e=s?.saved?.find(s=>s.name===t);if(!s||!e||e.used_by.length||e.url===s.planUrl)return;const o=await this.host._confirmDanger({key:"delete-plan",kind:"destructive",title:this.host._t("confirm.delete_plan_title"),message:this.host._t("confirm.delete_plan_body"),objectName:t,confirmLabel:this.host._t("btn.delete"),cancelLabel:this.host._t("btn.cancel")}),a=this.host._spaceDialog,l=a?.saved?.find(s=>s.name===t);if(o&&a&&l&&l.url===e.url&&l.modified===e.modified&&!l.used_by.length&&l.url!==a.planUrl)try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
${s.map(s=>{return a`
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
@@ -0,0 +1,36 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{v as e,A as t,dx as i,w as s,c as o,l as r,u as n}from"./houseplan-card-CeQyO4TX.js";import{a,r as h,t as d}from"./zigbee-topology-Q4h3gz_F.js";import{zigbeeTopologyRuntimeSnapshot as p,subscribeZigbeeTopology as c}from"./zigbee-topology-runtime-CVolox2I.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,n=e&&e.runtime===this._runtime&&e.devices===this.devices&&e.registry===this.registry?e.mapped:a(this._runtime.topologies,this.devices,this.registry);e&&n===e.mapped||(this._mappedMemo={runtime:this._runtime,devices:this.devices,registry:this.registry,mapped:n});const p=h(n,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=n`
: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
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{n as t,b as n}from"./zigbee-topology-Q4h3gz_F.js";import{I as e}from"./houseplan-card-CeQyO4TX.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
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15";try{await import("./houseplan-assets/houseplan-card-n2PxGqZY.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__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";try{await import("./houseplan-assets/houseplan-card-CeQyO4TX.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)}
+24
View File
@@ -1636,6 +1636,30 @@ diffs the real `seedHiddenBindings`/`buildDevices` outputs — there is no
second copy of the filter logic to drift. The field registry (#33) carries
their passports; `scripts/config-audit.mjs` treats both as `current`.
## Contextual Zigbee topology (#54, 2026-09-04)
The initial View graph owns only the fail-closed settings reader and a dynamic
overlay bridge. A saved `settings.zigbee_topology.enabled === true`, an actual
HA admin, full-card View and non-kiosk surface are all required before the
topology overlay chunk is requested. Opening the lazy General Settings runtime
does not load provider transport until an enabled saved setting needs status or
the admin presses a provider action.
`zigbee-topology.ts` normalizes ZHA and Zigbee2MQTT into unordered edge pairs
with separate directional observations, maps IEEE nodes through exact HA
registry ownership and resolves only edges incident to the hovered marker.
Routes never invent neighbor edges. `zigbee-topology-runtime.ts` owns a
per-connection memory cache and in-flight dedupe: ZHA reads `zha/devices`
without requesting a scan; Z2M verifies the retained bridge-info topic, sends
one correlated raw `routes:false` request through `mqtt.publish`, rejects
retained/foreign/late replies and always releases subscriptions.
The pointer-transparent overlay is stacked above architectural/isometric
layers and below device markers. It reads the already projected marker centres,
so flat/isometric camera and zoom remain single-source. Cache data, IEEE
addresses and raw payloads are never persisted, logged, exported or admitted
to support diagnostics.
## The initial bundle carries English and Russian whole (#400, 2026-08-31)
`en` and `ru` are synchronous dictionaries in the initial chunk; `de` and `fr`
+20 -2
View File
@@ -252,7 +252,8 @@ produce a zero-sized SVG `viewBox`.
* **Zoom out** — `MIN_ZOOM = 1/3`: you can see three times the content
frame and no further. Empty space beyond that is not information.
* **Discrete camera motion** (#82) — wheel, `−`/`+`, Fit all, the home arrow
and kiosk double-tap interpolate the existing exact camera target for
and the free-background double-click/tap in View or kiosk interpolate the
existing exact camera target for
160–220 ms with `cubic-bezier(0.2, 0.7, 0.2, 1)`. Zoom is logarithmic and
the world centre is linear; the final frame is the same clamped `viewBox`
the immediate path used before #82. Rapid wheel input retargets one RAF from
@@ -283,6 +284,23 @@ produce a zero-sized SVG `viewBox`.
the gesture is classified once, on the first movement past 8 px, and
keeps that role until the finger lifts (`_panLock`), so the plan
never slides under a swipe and a vertical drag still pans.
* **Room fit (#152).** A clean primary click/tap on the browser-painted room
target fits that room's final floor plus its visible boundary wall body into
the middle 80% of the stage. Devices, openings/actions and the HA Area link
keep their own gesture; the non-interactive room-label surface maps to the
same room id. The action uses the existing camera transition controller and
does not write the per-space zoom preference. Its session-only room intent
is reapplied atomically on a stable stage resize, then cleared by manual
camera input, Fit all/home, mode/space/projection changes, structural
adoption, hidden state or disconnect. In kiosk, room-owned taps never enter
the free-background double-tap sequence.
* **Free-background fit (#449).** Two clean primary clicks/taps within 350 ms
on the stage background invoke the same Fit all command in View and kiosk.
Room, device, vacuum, opening, link and control paths disarm the sequence;
so do pan, pinch, swipe, long press, cancellation, editor/mode/space/projection
changes and lifecycle adoption. The new recognizer adds no timer or render on
the first tap. Mouse, touch and pen sequences are separate; editors do not
expose this shortcut.
* **The lock is final, at the release too** (audit DEV-1DA1-02). The
release used to ask `swipeTarget()` again from the raw start→end
vector, ignoring the lock — so a *curved* gesture (a short vertical
@@ -292,7 +310,7 @@ produce a zero-sized SVG `viewBox`.
'pan'` now means no floor change, whatever the overall vector ends up
looking like; only a gesture locked as `swipe` may reach
`swipeTarget()`, and it never pans on the way. A motionless tap locks
nothing, so the double-tap zoom reset is untouched.
nothing, so a clean free-background double-tap remains available.
* **"Home is that way" arrow** — when the content frame is entirely
outside the current view, a small pointer appears at the view edge
in the frame's direction. Clicking it fits the content. Cheap
+66
View File
@@ -2,6 +2,72 @@
## Unreleased
- Large plans now keep their heavy room, wall, lighting, decor and device scene
stable during hover, pan, pinch and editor drags, and unrelated Home Assistant
state updates no longer rebuild the full card; interactions remain visually
and functionally unchanged while responding much more smoothly
([#451](https://github.com/Matysh/houseplan-card/issues/451)).
- Double-clicking or double-tapping free plan background in View or kiosk now
runs the same **Fit all** action as the toolbar button; rooms, devices,
openings and editor surfaces keep their own gestures
([#449](https://github.com/Matysh/houseplan-card/issues/449)).
- Zigbee2MQTT topology refresh now accepts the real camelCase raw network-map
fields (while retaining snake_case compatibility), so valid maps populate
devices and links instead of failing as unsupported data
([#450](https://github.com/Matysh/houseplan-card/issues/450)).
## v1.72.0-beta.1 — 2026-09-04
- Hidden experiments now use one indefinite browser-local switch:
`hp_alpha=1` enables the complete alpha set and restores the existing 3-D
View, while `hp_alpha=0` disables it. Previous `hp-labs=iso` state is not
migrated, so testers must enable alpha again
([#448](https://github.com/Matysh/houseplan-card/issues/448)).
- Administrators can optionally load a ZHA or Zigbee2MQTT topology snapshot and
see only the observed direct neighbours of the device currently under a real
mouse pointer. The diagnostic layer is off by default, never scans on hover,
counts rather than draws cross-space links, and stays absent from touch,
kiosk, editors and the static card
([#54](https://github.com/Matysh/houseplan-card/issues/54)).
- In View and kiosk, clicking or tapping a room now centres it and fits its
visible floor and boundary walls with 10% screen margins; a visible room
label offers the same action from the keyboard
([#152](https://github.com/Matysh/houseplan-card/issues/152)).
- Furniture can now snap to either physical face of an exterior wall without
jumping through the masonry, and Arrow keys move any selected Background
object by exactly one current grid cell without re-running wall or decor
magnets ([#447](https://github.com/Matysh/houseplan-card/issues/447)).
## v1.71.0 — 2026-09-04
- The Background editor can now upload and reuse private PNG, JPEG, WebP and
safe SVG images, then place, resize, mirror, rotate, replace and layer them
like furniture without wall magnetism; missing files keep a repairable
placeholder and portable exports retain integrity metadata without embedding
image bytes ([#51](https://github.com/Matysh/houseplan-card/issues/51)).
- Robot vacuums with several maps can now assign and calibrate each map against
its own floor, route the live position and trail there while leaving the dock
in place, and explain unassigned or ambiguous active maps instead of guessing
where to draw them
([#162](https://github.com/Matysh/houseplan-card/issues/162)).
- General settings can hide the room information window without disabling its
highlight, furniture snaps to the visible physical wall face, and the HACS
catalog now names the integration simply **House Plan**
([#426](https://github.com/Matysh/houseplan-card/issues/426),
[#445](https://github.com/Matysh/houseplan-card/issues/445),
[#444](https://github.com/Matysh/houseplan-card/issues/444)).
- Confirmation and **Help & feedback** flows now fail safely across missing
spaces, delayed previews, edited retries, mixed compatible versions and
malformed nested data; Home Assistant Area tracking and rejected General,
device or calibration saves also preserve the last confirmed plan
([#417](https://github.com/Matysh/houseplan-card/issues/417),
[#418](https://github.com/Matysh/houseplan-card/issues/418),
[#419](https://github.com/Matysh/houseplan-card/issues/419),
[#420](https://github.com/Matysh/houseplan-card/issues/420),
[#423](https://github.com/Matysh/houseplan-card/issues/423),
[#439](https://github.com/Matysh/houseplan-card/issues/439),
[#442](https://github.com/Matysh/houseplan-card/issues/442)).
## v1.71.0-beta.4 — 2026-09-04
- Furniture now snaps its back edge to the visible physical wall surface rather
+70 -1
View File
@@ -8,6 +8,75 @@
## Не выпущено
- На больших планах тяжёлая сцена комнат, стен, света, подложки и устройств
теперь остаётся неизменной во время hover, pan, pinch и перетаскиваний в
редакторах, а посторонние обновления Home Assistant больше не перестраивают
карточку целиком; внешний вид и результат действий не меняются, но отклик
становится заметно плавнее
([#451](https://github.com/Matysh/houseplan-card/issues/451)).
- Двойной клик или двойной тап по свободному фону плана в Просмотре и киоске
теперь выполняет то же действие **«Вписать всё»**, что кнопка в панели;
комнаты, устройства, проёмы и редакторы сохраняют собственные жесты
([#449](https://github.com/Matysh/houseplan-card/issues/449)).
- Обновление топологии Zigbee2MQTT теперь понимает реальные camelCase-поля
raw-карты сети, сохраняя совместимость со snake_case: корректная карта
заполняет устройства и связи вместо ошибки неподдерживаемых данных
([#450](https://github.com/Matysh/houseplan-card/issues/450)).
## v1.72.0-beta.1 — 2026-09-04
- Скрытые эксперименты теперь включаются одним бессрочным локальным ключом
браузера: `hp_alpha=1` активирует весь alpha-набор и возвращает существующий
3-D Просмотр, а `hp_alpha=0` выключает его. Прежнее состояние
`hp-labs=iso` не переносится, поэтому тестировщикам нужно включить alpha
заново ([#448](https://github.com/Matysh/houseplan-card/issues/448)).
- Администратор теперь может явно загрузить снимок топологии ZHA или
Zigbee2MQTT и при наведении реальной мыши видеть только наблюдаемых прямых
соседей выбранного устройства. Диагностика по умолчанию выключена, никогда не
запускает сканирование от наведения, считает вместо отрисовки связи с другими
пространствами и не появляется на touch, в киоске, редакторах и статической
карточке ([#54](https://github.com/Matysh/houseplan-card/issues/54)).
- В Просмотре и киоске клик или тап по комнате теперь центрирует и вписывает
её видимый пол и ограничивающие стены с полями 10%; видимая подпись комнаты
даёт то же действие с клавиатуры
([#152](https://github.com/Matysh/houseplan-card/issues/152)).
- Мебель теперь может примагничиваться к обеим физическим граням наружной стены
и не проваливается сквозь кладку, а стрелки сдвигают любой выбранный объект
подложки ровно на одну текущую клетку без повторного магнита стен или декора
([#447](https://github.com/Matysh/houseplan-card/issues/447)).
## v1.71.0 — 2026-09-04
- В редакторе подложки теперь можно загружать и повторно использовать закрытые
PNG, JPEG, WebP и безопасные SVG, а затем размещать, менять размер, отражать,
вращать, заменять и раскладывать их по слоям как мебель, но без магнита к
стенам; для пропавшего файла сохраняется восстанавливаемая рамка, а переносной
экспорт хранит данные целостности без встраивания байтов
([#51](https://github.com/Matysh/houseplan-card/issues/51)).
- Роботу с несколькими картами теперь можно сопоставить и откалибровать каждую
карту на своём этаже: живая позиция и след рисуются там, а док остаётся на
месте; для несопоставленной или неоднозначной активной карты план показывает
точную причину вместо попытки угадать этаж
([#162](https://github.com/Matysh/houseplan-card/issues/162)).
- В общих настройках можно скрыть информационное окно комнаты, не отключая её
подсветку; мебель примагничивается к видимой физической грани стены, а в
каталоге HACS интеграция теперь называется просто **House Plan**
([#426](https://github.com/Matysh/houseplan-card/issues/426),
[#445](https://github.com/Matysh/houseplan-card/issues/445),
[#444](https://github.com/Matysh/houseplan-card/issues/444)).
- Подтверждения и **«Помощь и обратная связь»** теперь безопасно завершаются при
пропавшем пространстве, позднем превью, изменённом повторе, совместимых
смешанных версиях и некорректных вложенных данных; отслеживание HA-зон и
отклонённые сохранения общих настроек, устройства или калибровки также
сохраняют последний подтверждённый план
([#417](https://github.com/Matysh/houseplan-card/issues/417),
[#418](https://github.com/Matysh/houseplan-card/issues/418),
[#419](https://github.com/Matysh/houseplan-card/issues/419),
[#420](https://github.com/Matysh/houseplan-card/issues/420),
[#423](https://github.com/Matysh/houseplan-card/issues/423),
[#439](https://github.com/Matysh/houseplan-card/issues/439),
[#442](https://github.com/Matysh/houseplan-card/issues/442)).
## v1.71.0-beta.4 — 2026-09-04
- Мебель теперь примагничивается задней гранью к видимой физической поверхности
@@ -214,7 +283,7 @@
## v1.70.0-beta.1 — 2026-08-30
- Колесо, кнопки масштаба, «Показать всё», стрелка возврата и двойной тап в
- Колесо, кнопки масштаба, «Вписать всё», стрелка возврата и двойной тап в
киоске теперь плавно перемещают камеру за короткую анимацию. Щипок и
панорамирование остаются непосредственными, а при системном уменьшении
движения масштаб меняется мгновенно
+15
View File
@@ -77,6 +77,21 @@ new frontend restores the disabled behavior after upgrade. Full backup/import
preserves the setting and the privacy-safe support projection includes only a
validated boolean.
## Contextual Zigbee topology (#54)
`settings.zigbee_topology` is an optional global object. Only exact
`enabled: true` activates the admin-only full-card View layer; absence,
malformed values and older configs are off. `z2m_base_topics` is a bounded list
of exact MQTT base topics: empty entries, wildcards, duplicates and control
characters are dropped. Disabling the feature retains valid topics; resetting
General settings removes the object.
Provider snapshots, IEEE addresses, links, errors and timestamps are runtime
memory only. They are not config fields, do not enter backup/export or support
diagnostics, and disappear with the HA connection/page. Older frontends ignore
the settings object; the backend's unknown-settings policy preserves it. No
model or store version migration is required.
## Vacuum map routes (#162)
`marker.vacuum.map_routes` is an optional array of
+8 -1
View File
@@ -16,7 +16,7 @@ light routing, device state or Home Assistant actions.
| Physical values | Stroke and text sizes are stored in centimetres; the UI shows cm/in for small values and m/ft for object dimensions. |
| Undo | Decor and backdrop use the same named 50-command stack as plan geometry. |
| Cancel | `Esc` restores the state at the start of an active drag/draw/resize/rotate gesture. A completed gesture is reverted with Undo. |
| Selection | One click selects; drag moves; the selected object has one common transform frame; double click opens all editable properties. |
| Selection | One click selects; drag moves; Arrow keys move the selected decor by one visible grid cell without re-running magnets; the selected object has one common transform frame; double click opens all editable properties. |
| Scale | Corner drag preserves aspect ratio. Hold `Shift` for independent axes. Furniture and custom images also have four one-axis middle handles and may cross the fixed edge to mirror. |
| Rotation | Ordinary decor uses 5° steps by default and `Shift` for free rotation. Furniture and custom images are free by default and `Shift` snaps to 45°. Lines use endpoint handles instead of a rotation handle. |
| Magnet targets | Only other decor objects and room contours: corners, edge centres, centres and edges. The image, devices and openings are excluded. |
@@ -114,6 +114,13 @@ shared history. `Ctrl+Shift+Z` and `Ctrl+Y` obey the same transaction boundary:
the first invocation cancels a live gesture, the next redoes. Native text-field
history wins while focus is inside an input.
With **Select** active, each Arrow key moves any selected line, shape, text,
furniture or custom image by one current grid cell along the canvas axes.
`Shift` does not accelerate this command. The move preserves any existing
off-grid remainder and deliberately bypasses decor and wall magnets, so it can
fine-tune furniture away from a wall. Each keydown is one named Undo step;
controls, dialogs and live pointer gestures keep their own Arrow behaviour.
## Code ownership
| Concern | File |
+13 -11
View File
@@ -119,18 +119,20 @@ git config core.untrackedCache true
Labs is an internal, presentation-only runtime in `src/labs.ts`; it must never
gate config/schema migrations, persistence writes, HA services or network
requests. Enable the current experiment with `?hp-labs=iso` (or
`#hp-labs=iso&space=<id>`), remove it with `-iso`, and clear all flags with
`hp-labs=off`. Known URL operations persist to
`houseplan_card_labs_v1` without rewriting the URL. Active flags are exposed for
diagnostics as the frozen sorted `window.__hpLabs` array.
requests. `?hp_alpha=1` or `#hp_alpha=1&space=<id>` enables every experimental
capability in the current build and persists `1` in
`houseplan_card_alpha_v1`; `hp_alpha=0` disables them and persists `0`. Query is
applied before hash and the last exact `1`/`0` wins. The URL is not rewritten,
unknown values fail closed for the current resolution, and the legacy
`hp-labs`/`houseplan_card_labs_v1` inputs are not read or migrated. Diagnostics
expose the boolean `window.__hpAlpha` together with the frozen sorted
`window.__hpLabs` capability array.
To add a flag, add one unique lowercase id plus issue, `since`, `expires` and a
non-empty summary to `LABS_FLAGS`, then cover URL/storage ordering, runtime
subscription and expiry. Versions compare the numeric `major.minor.patch` core:
at `expires` (including its first beta) the flag is dead and every activation
path fails closed. Remove or graduate a flag before its expiry cycle; do not
extend an expiry silently. See `docs/ISOMETRIC.md` for the current Stage 1 use.
To add a capability, add one unique lowercase id plus issue and a non-empty
summary to `LABS_FLAGS`, then cover registry validation and the alpha-on active
set. Capabilities have no individual public key or version lifetime: the one
persisted alpha switch is deliberately indefinite until the owner changes the
contract. See `docs/ISOMETRIC.md` for the current use.
These commands are read-only diagnostics, not release gates:
+9 -6
View File
@@ -114,12 +114,15 @@ Consequently:
nothing instead of breaking the plan.
The top edge of every drawing is BACK (`y = 0`). Placement and dragging put
BACK on the room-facing **physical surface** of a wall, including the local
atomic half-thickness; the invisible centreline is not the contact surface.
On a shared wall the raw pointer side selects the room, while an exact-axis
drag keeps the piece's current side. The magnet reach is measured from the
physical surface. `Shift` keeps free placement and bypasses the wall magnet.
Preview, commit and drag share the same surface resolver. Furniture is decor:
BACK on a **physical surface** of a wall, including the local atomic
half-thickness; the invisible centreline is not the contact surface. An outer
wall exposes both its room-facing and exterior surfaces, so raw pointer intent
keeps furniture inside or outside instead of pulling it through the masonry.
On a shared wall the raw pointer side selects the room. A new placement exactly
on an outer-wall axis defaults inside, while an exact-axis drag keeps the
piece's current side. The magnet reach is measured from the selected physical
surface. `Shift` keeps free placement and bypasses the wall magnet. Preview,
commit and drag share the same surface resolver. Furniture is decor:
it has no entity, state, room aggregation, collision model or automatic
binding to later wall edits, and already saved coordinates are never migrated.
+23 -13
View File
@@ -5,23 +5,26 @@ hidden, presentation-only volumetric View experiment. The normative contract is
`docs/specs/089-isometric-view-stage1.md`; the fixed rendering decisions are in
`docs/adr/089-isometric-stage1-renderer.md`.
## Activation and lifetime
## Activation
The feature exists only while the `iso` entry in `src/labs.ts` is live. For the
v1.62 cycle it can be enabled with either `?hp-labs=iso` or
`#hp-labs=iso&space=<id>`. Query operations are applied first and hash operations
second. `-iso` removes this flag and `off` clears every Labs flag. A known URL
operation is persisted in `houseplan_card_labs_v1`; the URL itself is not
rewritten.
The feature belongs to the single hidden alpha set in `src/labs.ts`. Enable all
experiments in the current build with either `?hp_alpha=1` or
`#hp_alpha=1&space=<id>`; disable them with `hp_alpha=0`. Query operations are
applied first, hash operations second, and the last exact `1`/`0` wins. A known
operation is persisted as that exact string in `houseplan_card_alpha_v1`; the
URL itself is not rewritten. Unknown values fail closed for the current
resolution and do not overwrite storage.
The selected presentation is stored per space in `houseplan_card_view_v1`. Flat
is always the initial default. Kiosk has no toggle but reads the saved preference.
Editors and `houseplan-space-card` are always flat.
The current registry entry is live from numeric core `1.62.0` and expires at
`1.65.0`. Thus `1.65.0-beta.1` is already expired. Malformed versions, malformed
entries and duplicate ids fail closed. Labs never gate schemas, migrations,
stores, service calls or network requests.
The switch has no version expiry and enables the complete capability set known
to the installed build. The legacy `hp-labs` URL and
`houseplan_card_labs_v1` storage are not read or migrated, so former testers
must enable `hp_alpha` once. Malformed registry entries and duplicate ids fail
closed. Alpha never gates schemas, migrations, plan stores, service calls or
network requests.
## Coordinate systems
@@ -45,6 +48,12 @@ preserves scalar zoom and converts the view centre through logical floor space.
`projectedFrame()` includes both floor corners and wall-top corners so fit/home
cannot clip the volume.
Room fit (#152) projects every final floor vertex at floor and floor-edge depth
and every selected-room boundary-wall vertex at floor and wall-top height before
building the AABB. It never projects a plan-space bounding rectangle, so a
concave room does not gain fictitious corners. The resulting camera still uses
the shared View controller; changing projection cancels the room-focus intent.
## Geometry and composition
`src/iso-projection.ts` owns pure projection math. `src/iso-walls.ts` consumes
@@ -101,8 +110,9 @@ the exact Linux CI SHA.
Stage 2 evolves the same hidden `iso` experiment; it does not add a flag,
setting or public activation path. The accepted implementation contract is
`docs/specs/122-isometric-stage2.md` and the fixed composition decisions are in
`docs/adr/122-isometric-stage2-composition.md`. The Labs `since: 1.62.0` and
exclusive `expires: 1.65.0` boundary are unchanged.
`docs/adr/122-isometric-stage2-composition.md`. Its historical per-feature
lifetime was superseded by the single indefinite `hp_alpha` gate in #448; the
Stage 2 rendering contract itself is unchanged.
### One structural scene, live opening leaves
+23 -9
View File
@@ -1,18 +1,32 @@
<!-- release: v1.71.0-beta.4 -->
<!-- release: v1.72.0-beta.1 -->
## Основное
- Вторую и последующие карты робота теперь можно безопасно добавить после выбора этажа; пустой список маршрутов остаётся пустым, а удалённые пространства и лишний обход устройств обрабатываются корректно ([#441](https://github.com/Matysh/houseplan-card/issues/441), [#443](https://github.com/Matysh/houseplan-card/issues/443)).
- Если Home Assistant отклоняет сохранение устройства или калибровки робота, план атомарно возвращается к подтверждённому состоянию, сохраняя открытый редактор и введённый черновик для повтора ([#442](https://github.com/Matysh/houseplan-card/issues/442)).
- Мебель теперь примагничивается к видимой физической поверхности стены с учётом локальной толщины и стороны общей стены, а не проваливается в кладку из-за привязки к оси ([#445](https://github.com/Matysh/houseplan-card/issues/445)).
- Скрытый 3-D Просмотр снова доступен тестировщикам через единый бессрочный
ключ `hp_alpha=1`; старое включение `hp-labs=iso` намеренно не переносится
([#448](https://github.com/Matysh/houseplan-card/issues/448)).
- В Просмотре и киоске клик или тап по комнате теперь центрирует её и вписывает
видимый пол и стены с полями 10%, а подпись даёт то же действие с клавиатуры;
мебель примагничивается к обеим физическим граням наружной стены, а стрелки
сдвигают выбранный объект подложки ровно на одну клетку
([#152](https://github.com/Matysh/houseplan-card/issues/152),
[#447](https://github.com/Matysh/houseplan-card/issues/447)).
- Администратор может включить диагностику Zigbee-сети и при наведении видеть только прямые связи выбранного устройства из снимка ZHA или Zigbee2MQTT, не закрывая план полной сеткой ([#54](https://github.com/Matysh/houseplan-card/issues/54)).
- Мелкие исправления и улучшения.
## Highlights
- A robot vacuum's second and later maps can now be added safely after choosing a floor; an empty route list stays empty, while deleted spaces and unnecessary device scans are handled correctly ([#441](https://github.com/Matysh/houseplan-card/issues/441), [#443](https://github.com/Matysh/houseplan-card/issues/443)).
- If Home Assistant rejects a device or robot-calibration save, the plan atomically restores its confirmed state while keeping the editor and entered draft ready for another attempt ([#442](https://github.com/Matysh/houseplan-card/issues/442)).
- Furniture now snaps to the visible physical wall surface with the local thickness and shared-wall side taken into account, instead of entering the masonry through centreline snapping ([#445](https://github.com/Matysh/houseplan-card/issues/445)).
- Hidden 3-D View is available to testers again through the single indefinite
`hp_alpha=1` switch; the former `hp-labs=iso` state is intentionally not
migrated ([#448](https://github.com/Matysh/houseplan-card/issues/448)).
- In View and kiosk, clicking or tapping a room now centres it and fits its
visible floor and walls with 10% margins, while the label offers the same
keyboard action; furniture snaps to either physical face of an exterior wall,
and Arrow keys move a selected Background object by exactly one grid cell
([#152](https://github.com/Matysh/houseplan-card/issues/152),
[#447](https://github.com/Matysh/houseplan-card/issues/447)).
- Administrators can enable Zigbee diagnostics and hover a device to see only its direct links from a ZHA or Zigbee2MQTT snapshot, without covering the plan with the full mesh ([#54](https://github.com/Matysh/houseplan-card/issues/54)).
- Small fixes and improvements.
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.71.0-beta.4/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.71.0-beta.4/docs/CHANGELOG.md)
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.72.0-beta.1/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.72.0-beta.1/docs/CHANGELOG.md)
+1 -1
View File
@@ -46,7 +46,7 @@ deliberate degradation is allowed under `TOUCH-SUPPORT.md`.
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, filtered auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
| J5 | "Room climate at a glance" — per-room temperature/humidity, comfort-range fills, room-card metrics | **Closed** |
| J6 | "Keep the plan true as the home evolves" — new-device flag, two editors, drag/resize, merge/split, multi-client live sync, optimistic locking | **Closed** |
| J7 | "Is my Zigbee mesh healthy *here*?" — LQI badges, per-room average, LQI fill | **Closed** (kept deliberately: cheap, spatial by nature, no in-plan competitor) |
| J7 | "Is my Zigbee mesh healthy *here*?" — LQI badges, per-room average/fill and opt-in direct-neighbour links for one hovered device | **Closed** (spatial diagnostics; no persistent full-mesh graph) |
## Partially covered — improvement backlog stays inside these
+2 -2
View File
@@ -21,8 +21,8 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
| Version | **v1.71.0-beta.4** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| Current local cycle | v1.71.0-beta.4 fixes adding and removing multi-floor robot routes (#441, #443), atomically rolls rejected device and robot-calibration writes back to server-confirmed state (#442), snaps furniture to the physical wall face (#445), and improves the HACS storefront copy (#444). Publish only after exact-SHA Validate is green. |
| Version | **v1.72.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| Current local cycle | v1.72.0-beta.1 adds contextual per-device Zigbee topology diagnostics (#54), click/tap room fitting with 10% margins (#152), exterior wall-face furniture snapping and exact one-cell keyboard nudging for Background objects (#447). Publish from `dev` only after exact-SHA Validate is green; `main` remains at v1.71.0. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first); every bullet links its GitHub issue (#NN) so the #328 rules stay machine-checkable. A STABLE body aggregates the changelog since the PREVIOUS STABLE release (never since the last beta): features/fixes described across the line's beta changelogs must appear, while bugs that were introduced and fixed strictly inside the beta line (never shipped in any stable) are excluded — draft with `npm run release:notes -- <tag>`, curate by hand, then `npm run release:notes -- <tag> --verify` must pass. `Мелкие исправления и улучшения` / `Small fixes and improvements` is allowed only when the range really contains user-visible work not itemised in the body; a single-issue hotfix ships without it (the verifier enforces this). Every body ends with separate links to the Russian and English changelogs. Open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
+5
View File
@@ -25,6 +25,11 @@ URLs, paths, filenames, plan/backdrop/manual bytes, vacuum calibration and
trails, backup history, message and contact. Unknown fields are dropped rather
than copied and redacted later.
The Zigbee topology snapshot is never offered to this package: raw provider
payloads, IEEE addresses, neighbor links, base topics, timestamps and provider
errors stay out of support preview/download/submission and out of browser
storage and logs.
## Preview and authorization
Only a Home Assistant user allowed to write House Plan can build or send a
+34
View File
@@ -3706,6 +3706,31 @@ require hands on real hardware — they remain for the human pass.
Целочисленность обрезки — единственная часть съёмки, которую можно проверить без
браузера: `node --test test/capture-clip.test.mjs`.
## Вписывание выбранной комнаты (#152)
- [ ] В View чистый mouse click и одиночный touch tap по полу простой,
вогнутой и вложенной комнаты центрируют именно browser-target и помещают
её видимый пол вместе с ограничивающими стенами в центральные 80% stage.
- [ ] В Flat и Iso ограничивающая ось даёт поля `10% ± 1 CSS px`; устройства,
badges, room label, Glow, солнце, подложка и декор bounds не расширяют.
- [ ] Device/capsule, opening/lock/action, vacuum и HA Area link выполняют только
своё действие; pan выше click threshold, pinch и long press не запускают
room fit, а движение ниже threshold остаётся tap.
- [ ] В kiosk два tap по комнате не запускают Fit all и не меняют background
double-tap sequence; два tap по свободному фону сохраняют прежний reset.
- [ ] Быстрые клики разных комнат retarget-ят один camera controller; повторный
fit уже вписанной комнаты не создаёт RAF и не пишет `LS_ZOOM`.
- [ ] Stable resize атомарно пересчитывает активную комнату. Wheel, zoom buttons,
Fit all/home, pan/pinch, mode/space/projection, hidden/disconnect и новая
structural snapshot снимают intent.
- [ ] Видимая подпись имеет один `role=button`/tab stop, локализованное имя и
`:focus-visible`; Enter/Space вызывают тот же fit и не двигают focus.
Скрытая/безымянная подпись не создаёт невидимый tab stop.
- [ ] Pure proofs: `node --test test/room-fit.test.mjs`; полный cycle:
`npm run typecheck`, `npm test`, `npm run build`. Browser matrix и
мутации room ownership и session-only zoom persistence выполняются перед
бетой.
## Ядра фронтенда растут только осознанно (#425, заменяет #34)
- [ ] `node --test test/core-file-budget.test.mjs` — потолки на
@@ -3719,3 +3744,12 @@ require hands on real hardware — they remain for the human pass.
Вторая половина правила (уменьшение ниже потолка на 250 строк тоже краснеет)
нужна затем, что без неё вынос двух тысяч строк ничего не меняет: потолок
остаётся прежним, и ядро дорастает до него обратно «в рамках бюджета».
## Реальная raw-карта Zigbee2MQTT (#450)
- [ ] **Обновить карту** на Zigbee2MQTT 1.x/2.x принимает camelCase-поля
`ieeeAddr` / `networkAddress` и показывает связи вместо
`error_invalid_payload`; сохранена совместимость со snake_case.
- [ ] Плоские `sourceIeeeAddr` / `targetIeeeAddr` имеют приоритет над
вложенными концами связи, а массив `failed` не превращается в ложную
недоступность устройства [unit + production-shaped fixture + mutation].
+2
View File
@@ -28,6 +28,8 @@ On phones, tablets, wall panels and HA Companion apps, the ordinary View must:
- render the plan and current states correctly;
- support convenient pan, pinch zoom and space switching;
- fit the whole plan after two clean taps on free scene background, while one
tap on a room keeps the immediate room-fit action;
- provide a touch path for essential information that desktop exposes through
hover;
- open and close View dialogs without clipping their essential content or
+19 -3
View File
@@ -201,6 +201,20 @@ but hide the floating room summary, turn off **General settings → Show the roo
information window on hover**. The option is on by default and does not affect
device tooltips.
For an occasional Zigbee placement check, an administrator can enable
**General settings → Show Zigbee links when hovering over a device**. The option
is off by default. Load the provider snapshot there: **Read ZHA data** reads
ZHA's existing cache, while **Update map** starts an explicit Zigbee2MQTT raw
network-map scan for each entered base topic (default `zigbee2mqtt`). The latter
may take 10 seconds to 2 minutes and can temporarily slow the Zigbee network.
After data is loaded, moving a real mouse over a mapped Zigbee marker shows
only its observed direct neighbours. Links to markers on the current space are
lines; drawable neighbours on other spaces are summarized as a temporary
count. This is a snapshot, not the route currently used by every packet. The
layer does not appear on touch/pen, in kiosk, in editors or in the static card,
and hovering never starts a scan.
Each editor has a stable primary toolbar. Tool parameters and selected-object
actions appear in a context tray over the top of the canvas. On a narrow screen
the tray scrolls horizontally instead of shrinking the plan.
@@ -228,7 +242,8 @@ objects; each space keeps its local View viewport.
| Scenario | Mouse | Touch View | Touch editors | Keyboard |
|---|---|---|---|---|
| Zoom and pan | Wheel; drag empty space; `−`/`+` | Pinch; drag; double-tap resets kiosk | Available but precision is not guaranteed | — |
| Zoom and pan | Wheel; drag empty space; `−`/`+`; double-click free background to Fit all | Pinch; drag; double-tap free background to Fit all | Available but precision is not guaranteed; no Fit-all double-tap | — |
| Room | A clean click fits the room with 10% margins | One tap fits the room; repeated room taps never become Fit all | — | `Enter`/`Space` on a visible room label |
| Change space | Click a tab | Tap; kiosk swipe at 1:1 | Tap a tab | — |
| Device | Click/double-click per mode | Tap; safe actions equal desktop | Drag/properties are best effort | `Esc` closes the top surface |
| Walls drawing or precise drag | Full contract | Not applicable | Best effort; use desktop for Resize and exact nodes | `Shift` changes magnet/angle; `Esc` finishes a Walls chain or cancels the current precise drag |
@@ -238,8 +253,9 @@ objects; each space keeps its local View viewport.
At zoom above 1:1 a horizontal kiosk gesture pans instead of changing space.
Any manual kiosk operation pauses auto-cycle for 60 seconds.
Wheel, `−`/`+`, **Fit all**, the return arrow and kiosk double-tap use a short
smooth camera transition. Rapid wheel input changes the current destination
Wheel, `−`/`+`, **Fit all**, the return arrow and a free-background
double-click/tap in View or kiosk use a short smooth camera transition. Rapid
wheel input changes the current destination
instead of building a queue. Pinch and pan stay directly under the fingers.
With the operating system's reduced-motion preference enabled, every zoom
command is immediate.

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