Compare commits

...
Author SHA1 Message Date
claude[bot] 5fb510290a docs: review document for #103
Validate / docs (push) Successful in 34s
Validate / provenance (push) Successful in 59s
Validate / changes (push) Successful in 52s
Validate / process-gate (push) Failing after 57s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / smoke (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Validate / golden (push) Skipped
Issue: #103
User-Visible: no
2026-08-19 00:34:45 +00:00
Sergey Matyunin 932773773f Show state in toggle confirmations
Issue: #103
User-Visible: yes
2026-08-19 03:23:58 +03:00
claude[bot]andSergey Matyunin 6c7958c6d0 docs: add spec review r1 for toggle confirmation state
Issue: #103
User-Visible: no
2026-08-19 03:12:56 +03:00
Sergey Matyunin a449edc545 docs: specify toggle confirmation states
Issue: #103
User-Visible: no
2026-08-19 03:12:55 +03:00
claude[bot] e88c23b8ee docs: review document for #113
Validate / provenance (push) Successful in 45s
Validate / changes (push) Successful in 56s
Validate / process-gate (push) Failing after 58s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / smoke (push) Skipped
Validate / backend (push) Skipped
Validate / frontend (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / docs (push) Failing after 34s
Issue: #113
User-Visible: no
2026-08-19 00:11:27 +00:00
Sergey Matyunin acad3b32c1 Refresh documentation source fingerprint
Issue: #113
User-Visible: no
2026-08-19 03:01:22 +03:00
claude[bot] b443a333e0 docs: review document for #113
Issue: #113
User-Visible: no
2026-08-18 23:59:36 +00:00
Sergey Matyunin 66fa8f476c Keep mutation anchor aligned after optional model change
Issue: #113
User-Visible: no
2026-08-19 02:51:50 +03:00
claude[bot]andSergey Matyunin 047363c2d3 docs: code review document for #113
Issue: #113
User-Visible: no
2026-08-19 02:49:39 +03:00
Sergey Matyunin 1e8503bd46 Make empty space model explicit
Issue: #113
User-Visible: no
2026-08-19 02:49:39 +03:00
claude[bot]andSergey Matyunin 135497b272 docs: review document for #113
Issue: #113
User-Visible: no
2026-08-19 02:47:50 +03:00
Sergey Matyunin 0c2a5dedea docs: specify optional space model contract
Issue: #113
User-Visible: no
2026-08-19 02:47:50 +03:00
claude[bot] c1676cf26a docs: review document for #117
Validate / docs (push) Failing after 35s
Validate / provenance (push) Successful in 53s
Validate / process-gate (push) Failing after 55s
Validate / changes (push) Successful in 46s
Validate / smoke (push) Skipped
Validate / backend (push) Skipped
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Issue: #117
User-Visible: no
2026-08-18 23:20:52 +00:00
claude[bot]andSergey Matyunin c65cbcc96c docs: review document for #117
Issue: #117
User-Visible: no
2026-08-19 02:12:37 +03:00
Sergey Matyunin 01fe48de00 fix: support registryless opening entities
Issue: #117
User-Visible: yes
2026-08-19 02:12:37 +03:00
claude[bot]andSergey Matyunin 9baf533c90 docs: review document for #117
Issue: #117
User-Visible: no
2026-08-19 02:11:36 +03:00
Sergey Matyunin 563a850aac docs: specify registryless opening entities
Issue: #117
User-Visible: no
2026-08-19 02:11:35 +03:00
claude[bot] 9e5ff0b8a0 docs: review document for #132
Issue: #132
User-Visible: no
2026-08-18 22:51:35 +00:00
Sergey Matyunin 3fe0f8c443 fix: address partition opening review regressions
Issue: #132
User-Visible: yes
2026-08-19 01:34:51 +03:00
claude[bot] 742b3279a2 docs: code review document for #132
Issue: #132
User-Visible: no
2026-08-18 22:28:09 +00:00
Sergey Matyunin 9f77e3e932 feat: support openings in independent walls
Issue: #132
User-Visible: yes
2026-08-19 01:11:55 +03:00
Sergey Matyunin 083621342a docs: address partition openings spec review
Issue: #132
User-Visible: no
2026-08-19 00:35:59 +03:00
claude[bot] 4f20befd77 docs: review document for #132
Issue: #132
User-Visible: no
2026-08-18 21:33:56 +00:00
Sergey Matyunin 2aaabc48d6 docs: update partition openings spec after unified walls
Issue: #132
User-Visible: no
2026-08-19 00:21:38 +03:00
Sergey Matyunin b9bf210804 docs: specify partition openings
Issue: #132
User-Visible: no
2026-08-19 00:19:01 +03:00
Sergey Matyunin d6007dc444 ci: retry prerelease gate after runner mirror timeout
Issue: #150
Issue: #157
Issue: #170
Issue: #172
Issue: #173
Issue: #174
Issue: #178
User-Visible: no
2026-08-18 22:44:29 +03:00
Sergey Matyunin 54c5ca3840 test: accept v1.65.0-beta.2 golden baselines
Issue: #150
Issue: #172
Issue: #173
Issue: #178
User-Visible: no
Release: v1.65.0-beta.2
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32175473418
2026-08-18 22:26:47 +03:00
Sergey Matyunin 57fc434d4f Release v1.65.0-beta.2 candidate
Issue: #150
Issue: #157
Issue: #170
Issue: #172
Issue: #173
Issue: #174
Issue: #178
User-Visible: yes
2026-08-18 22:11:05 +03:00
Sergey Matyunin 9530ee2e5a fix: preserve short wall closure gestures
Issue: #173
User-Visible: yes
2026-08-18 22:05:03 +03:00
claude[bot] 58ba15ac89 docs: code review document for #178
Issue: #178
User-Visible: no
2026-08-18 18:47:17 +00:00
Sergey Matyunin f46be0e0e2 chore: refresh documentation screenshot metadata
Issue: #178
User-Visible: no
2026-08-18 21:33:53 +03:00
Sergey Matyunin 8086399aa6 feat: select exact toggle entity
Issue: #178
User-Visible: yes
2026-08-18 21:31:04 +03:00
claude[bot] 034eb3f5f3 docs: review document for #178
Issue: #178
User-Visible: no
2026-08-18 18:17:26 +00:00
Sergey Matyunin 76f75f85aa docs: address review of toggle entity spec
Issue: #178
User-Visible: no
2026-08-18 21:10:21 +03:00
claude[bot] 7f1655c7f5 docs: spec review document for #178
Issue: #178
User-Visible: no
2026-08-18 18:08:34 +00:00
Sergey Matyunin e46ef6f55c docs: specify toggle entity selection
Issue: #178
User-Visible: no
2026-08-18 20:58:27 +03:00
claude[bot] 63eea47ac6 docs: review document for #173
Validate / docs (push) Failing after 20s
Validate / provenance (push) Successful in 33s
Validate / process-gate (push) Failing after 43s
Validate / changes (push) Successful in 41s
Validate / frontend (push) Skipped
Validate / smoke (push) Skipped
Validate / golden (push) Skipped
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Issue: #173
User-Visible: no
2026-08-18 17:51:16 +00:00
Sergey Matyunin f54b9c0ccd docs: refresh screenshot fingerprint for #173
Issue: #173
User-Visible: no
2026-08-18 20:38:07 +03:00
claude[bot]andSergey Matyunin aac2978359 docs: review document for #173
Issue: #173
User-Visible: no
2026-08-18 20:33:44 +03:00
Sergey Matyunin fc1e7951e4 feat: unify Plan wall drawing
Issue: #173
User-Visible: yes
2026-08-18 20:33:44 +03:00
claude[bot]andSergey Matyunin e206e8761c docs: review document for #173
Issue: #173
User-Visible: no
2026-08-18 20:31:51 +03:00
Sergey Matyunin 8a25b5224f docs(spec): define unified wall drawing
Issue: #173
User-Visible: no
2026-08-18 20:31:51 +03:00
claude[bot]andclaude[bot] 1f11f8f410 docs: code review document for #174
Issue: #174
User-Visible: no
2026-08-18 17:29:48 +00:00
Sergey Matyuninandclaude[bot] f4b1a4766f Refresh documentation screenshot fingerprint
Issue: #174
User-Visible: no
2026-08-18 17:29:48 +00:00
Sergey Matyuninandclaude[bot] 4fda569f74 Fix linked virtual light controller authority
Issue: #174
User-Visible: yes
2026-08-18 17:29:48 +00:00
claude[bot]andclaude[bot] 393ec62c61 docs: review document for #174
Issue: #174
User-Visible: no
2026-08-18 17:29:48 +00:00
Sergey Matyuninandclaude[bot] dae2efc280 docs: specify linked virtual light control
Issue: #174
User-Visible: no
2026-08-18 17:29:48 +00:00
Matysh e727023bf4 fix: install Chromium without --with-deps in the review pipeline
Validate / smoke (push) Failing after 31s
Validate / golden (push) Failing after 12s
Validate / backend (push) Failing after 19m25s
Validate / performance_smoke (push) Failing after 14m0s
Validate / docs (push) Failing after 27s
Validate / changes (push) Successful in 44s
Validate / provenance (push) Successful in 48s
Validate / process-gate (push) Failing after 46s
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 24s
Validate / frontend (push) Successful in 6m14s
On a Playwright cache miss the flag pulled Chromium's system libraries
through apt, spending minutes of the 45-minute review budget on packages
the ubuntu-latest image already ships — and the runner's retries against
the unreachable azure mirror made the step look hung on a live run. If the
image ever drops a required library, Chromium fails to launch with a clear
missing-libraries error; that is the moment to bring the flag back.

validate.yml keeps the flag deliberately: it is the prerelease gate, where
predictability is worth more than minutes.

Issue: #175
User-Visible: no
2026-08-18 20:01:23 +03:00
claude[bot] 690f57aaba docs: review document for #172
Issue: #172
User-Visible: no
2026-08-18 15:05:46 +00:00
claude[bot] c4ba218ee8 docs: review document for #172
Issue: #172
User-Visible: no
2026-08-18 15:05:46 +00:00
Sergey Matyuninandclaude[bot] df093c7e51 fix: cap zero-depth divider joins
Issue: #172
User-Visible: yes
2026-08-18 15:05:46 +00:00
claude[bot] a9d999e2f2 docs: review document for #172
Issue: #172
User-Visible: no
2026-08-18 15:05:46 +00:00
Sergey Matyuninandclaude[bot] 6ca9aacebc docs(spec): define zero divider taper fix
Issue: #172
User-Visible: no
2026-08-18 15:05:46 +00:00
Matysh ae7fb621e7 fix: fail loudly when a review verdict has no document
On #150 both spec-review verdicts survived only as issue comments: the
publish step found nothing staged, printed a warning, and exited zero, so
the label moved and the missing artifact went unnoticed until the next
review caught it (#171). A verdict without a document in docs/reviews/ now
fails the run before the label step, preserving the invariant that an
unchanged label means a failed run.

An empty working copy alone is not a failure: the reviewer occasionally
commits the document itself through its app token, bypassing this step
(CODE-REVIEW-150-r1, committer GitHub), so the branch is checked first. A
postcondition verifies the exact expected filename reached the branch, and
the rebase-conflict path no longer exits zero either.

Issue: #171
User-Visible: no
2026-08-18 18:02:44 +03:00
Sergey Matyunin 4d71f57b4f test: enable wall transition smoke surfaces
Issue: #150
User-Visible: no
2026-08-18 17:36:54 +03:00
claude[bot]andSergey Matyunin 0ee69ccb20 docs: add code review r1 for issue 150
Issue: #150
User-Visible: no
2026-08-18 17:36:54 +03:00
Sergey Matyunin 6cbf9fbdf0 fix: preserve wall thickness transitions
Issue: #150
User-Visible: yes
2026-08-18 17:36:54 +03:00
Sergey Matyunin fd0b52f3a9 docs: address issue 150 spec review
Issue: #150
User-Visible: no
2026-08-18 17:36:54 +03:00
Sergey Matyunin 2667f71bd6 docs(spec): define wall thickness transitions
Issue: #150
User-Visible: no
2026-08-18 17:36:54 +03:00
Matysh c27185cfa4 fix: verify the PAT before reviewing, pick the freshest task branch
Issue #150 reached a green verdict and then hit two pipeline defects at once.
The review document push came back 403 as github-actions[bot]: the PAT had
died, and checkout's persisted credential quietly took its place — a masked
actor instead of a loud failure. Credentials are no longer persisted, and the
token is now proven alive before the review starts, not after forty minutes of
reviewer work.

Branch selection took the first match alphabetically, and with a spec-era
branch sitting next to the implementation branch that meant the stale one.
The freshest branch by commit date is chosen instead, with a warning naming
every candidate when more than one exists.

Verified against the real #150 branches: the fix branch wins, the warning
fires.

Issue: #114
User-Visible: no
2026-08-18 17:21:06 +03:00
Sergey Matyunin a05aa5dc06 fix: honor area-less room device binding
Validate / docs (push) Failing after 22s
Validate / provenance (push) Successful in 45s
Validate / changes (push) Successful in 35s
Validate / process-gate (push) Failing after 44s
Validate / hacs (push) Failing after 26s
Validate / hassfest (push) Failing after 31s
Validate / frontend (push) Successful in 6m39s
Validate / backend (push) Failing after 11m48s
Validate / golden (push) Failing after 9m52s
Validate / performance_smoke (push) Failing after 10m15s
Validate / smoke (push) Failing after 24m6s
Issue: #170
User-Visible: yes
2026-08-18 09:56:58 +03:00
Sergey Matyunin 93a29cd79e docs: address review for room binding spec
Issue: #170
User-Visible: no
2026-08-18 09:49:24 +03:00
Sergey Matyunin 260a994f5f docs: specify area-less room device binding
Issue: #170
User-Visible: no
2026-08-18 09:28:18 +03:00
Sergey Matyunin c9a00b2a37 feat: add open passage openings
Validate / docs (push) Failing after 20s
Validate / provenance (push) Successful in 54s
Validate / hacs (push) Failing after 15s
Validate / process-gate (push) Failing after 50s
Validate / changes (push) Successful in 46s
Validate / hassfest (push) Failing after 14s
Validate / frontend (push) Successful in 5m54s
Validate / backend (push) Failing after 8m1s
Validate / smoke (push) Failing after 4m25s
Validate / golden (push) Failing after 4m26s
Validate / performance_smoke (push) Failing after 8m39s
Issue: #157
User-Visible: yes
2026-08-17 16:45:41 +03:00
Sergey Matyunin 4a8f44210f docs: address open passage spec review
Issue: #157
User-Visible: no
2026-08-17 16:25:15 +03:00
Sergey Matyunin 91c8b01d7d docs: specify open passage behavior
Issue: #157
User-Visible: no
2026-08-17 16:25:15 +03:00
Sergey Matyunin bc478b1756 fix: raise release subprocess buffer
Issue: #169
User-Visible: no
2026-08-17 14:11:49 +03:00
Sergey Matyunin 135a18a3ca test: keep expired iso smoke profiles explicit
Issue: #168
User-Visible: no
2026-08-17 14:02:24 +03:00
Sergey Matyunin c23b2d7a90 test: accept v1.65.0-beta.1 golden baselines
Issue: #164
Issue: #167
Issue: #168
User-Visible: no
Release: v1.65.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32021771064
2026-08-17 13:51:46 +03:00
Sergey Matyunin 61978d4f1e test: inject iso golden state before model boot
Issue: #168
User-Visible: no
2026-08-17 13:46:37 +03:00
Sergey Matyunin 141c657813 test: keep expired iso goldens explicit
Issue: #168
User-Visible: no
2026-08-17 13:40:04 +03:00
Sergey Matyunin 0040615a45 Release v1.65.0-beta.1 candidate
Issue: #164
Issue: #166
Issue: #167
User-Visible: yes
2026-08-17 13:30:25 +03:00
Sergey Matyunin ff9f20f52e Merge issue #167 into dev
Issue: #167
User-Visible: no
2026-08-17 13:25:31 +03:00
Sergey Matyunin a1b8861eff fix: preserve plan-only room label scale
Issue: #167
User-Visible: yes
2026-08-17 13:16:04 +03:00
Sergey Matyunin 2e47473619 docs: refresh source-bound screenshots
Issue: #167
User-Visible: no
2026-08-17 12:36:47 +03:00
Sergey Matyunin b369eb5dcd test: preserve ordinary space layout scope
Issue: #167
User-Visible: no
2026-08-17 12:34:45 +03:00
Sergey Matyunin 7f397a6875 feat: add plan-only space export
Issue: #167
User-Visible: yes
2026-08-17 12:33:52 +03:00
Sergey Matyunin bf2937c1ed merge: sync dev into issue 167
Issue: #167
User-Visible: no

# Conflicts:
#	docs/specs/README.md
2026-08-17 12:19:58 +03:00
Sergey Matyunin 8c6ac30300 Merge issue #166 into dev
Issue: #166
User-Visible: no

# Conflicts:
#	custom_components/houseplan/frontend/houseplan-card.js
#	demo/srv/assets/houseplan-card.js
#	dist/houseplan-card.js
#	docs/CHANGELOG.md
#	docs/CHANGELOG.ru.md
#	docs/images/screenshots.json
2026-08-17 12:17:00 +03:00
Sergey Matyunin 6df4722438 docs(spec): define plan-only space export
Issue: #167
User-Visible: no
2026-08-17 12:12:05 +03:00
Matysh bdae05dafc Merge issue #164 implementation into dev
The previous merge (6beb404) took a stale local ref and brought only the
specification; this one brings the implementation and the error-channel fix.

Issue: #164
User-Visible: no
2026-08-17 12:06:51 +03:00
Sergey Matyunin 4ed86b38bc fix: align sun rays with plan north
Validate / docs (push) Successful in 25s
Validate / provenance (push) Successful in 42s
Validate / process-gate (push) Failing after 38s
Validate / changes (push) Successful in 37s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / backend (push) Skipped
Validate / frontend (push) Successful in 5m19s
Validate / performance_smoke (push) Failing after 1m49s
Validate / smoke (push) Failing after 2m9s
Validate / golden (push) Failing after 7m21s
Issue: #166
User-Visible: yes
2026-08-17 12:01:14 +03:00
Sergey Matyunin 9c0e9fab68 merge: sync dev into issue 166
Issue: #166
User-Visible: no

# Conflicts:
#	docs/specs/README.md
2026-08-17 11:56:41 +03:00
Sergey Matyunin 154c662a0b docs: address issue 166 spec review
Issue: #166
User-Visible: no
2026-08-17 11:55:38 +03:00
Matysh 6beb40435f Merge issue #164 into dev
Validate / docs (push) Successful in 24s
Validate / provenance (push) Successful in 46s
Validate / changes (push) Successful in 44s
Validate / process-gate (push) Successful in 51s
Validate / hacs (push) Failing after 17s
Validate / hassfest (push) Failing after 17s
Validate / frontend (push) Successful in 5m1s
Validate / performance_smoke (push) Failing after 1m46s
Validate / smoke (push) Failing after 2m1s
Validate / backend (push) Failing after 7m28s
Validate / golden (push) Failing after 8m10s
Issue: #164
User-Visible: no
2026-08-17 08:55:51 +03:00
Sergey Matyunin e8a5771a63 test: isolate washer golden fixture
Issue: #164
User-Visible: no
2026-08-16 22:46:12 +03:00
Sergey Matyunin f7c8609ac5 fix: detect active appliance lifecycle
Issue: #164
User-Visible: yes
2026-08-16 22:35:54 +03:00
Sergey Matyunin 8ce7d1f0bb Merge remote-tracking branch 'origin/dev' into issue/164-washer-active-cycle 2026-08-16 22:19:13 +03:00
Sergey Matyunin d8e3b82da6 docs: revise washer lifecycle specification
Issue: #164
User-Visible: no
2026-08-16 22:13:15 +03:00
Sergey Matyunin 18f5155bbf fix: base first-push validation on dev
Validate / backend (push) Failing after 9m11s
Validate / golden (push) Failing after 8m16s
Validate / docs (push) Failing after 22s
Validate / provenance (push) Successful in 33s
Validate / process-gate (push) Failing after 36s
Validate / changes (push) Successful in 26s
Validate / hassfest (push) Failing after 17s
Validate / hacs (push) Failing after 21s
Validate / frontend (push) Successful in 4m44s
Validate / performance_smoke (push) Failing after 2m2s
Validate / smoke (push) Failing after 2m6s
Issue: #165
User-Visible: no
2026-08-16 21:53:41 +03:00
Sergey Matyunin b94b1b93cd docs: specify sun north rotation fix
Issue: #166
User-Visible: no
2026-08-16 21:46:32 +03:00
Sergey Matyunin 82cf3ad2db docs: specify appliance active-cycle detection
Issue: #164
User-Visible: no
2026-08-16 21:35:50 +03:00
Sergey Matyunin 0253c4765c docs: isolate screenshot tooling from release fixtures
Validate / process-gate (push) Failing after 4m52s
Validate / changes (push) Successful in 4m39s
Validate / provenance (push) Successful in 4m55s
Validate / docs (push) Failing after 5m3s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / smoke (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Validate / golden (push) Skipped
Issue: #35
User-Visible: no
2026-08-16 01:22:55 +03:00
Sergey Matyunin 88a647f6b2 docs: refresh the current user experience guide
Issue: #35
User-Visible: no
2026-08-16 01:12:14 +03:00
Sergey Matyunin 053e2a9b68 ci: move workflows to Node 24 actions
Issue: #145
User-Visible: no
2026-08-16 00:00:36 +03:00
Sergey Matyunin 91f460e80c test: make the column-shadow mutant effective
Issue: #144
User-Visible: no
2026-08-15 23:53:53 +03:00
Sergey Matyunin 32518c6284 test: guard the filled-tunnel golden
Issue: #143
User-Visible: no
2026-08-15 23:42:34 +03:00
Sergey Matyunin 9f2c5f5ff4 build: promote v1.64.0 after beta.3
Validate / performance_smoke (push) Failing after 10m18s
Validate / provenance (push) Successful in 57s
Validate / process-gate (push) Failing after 1m6s
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 14m19s
Validate / golden (push) Failing after 12m25s
Validate / backend (push) Failing after 13m5s
Validate / smoke (push) Failing after 29m32s
Validate / changes (push) Successful in 44s
Full Performance / performance (push) Failing after 1h24m44s
Issue: #153
User-Visible: yes
2026-08-14 23:01:44 +03:00
Sergey Matyunin 20d7883699 Release v1.64.0-beta.3 candidate
Issue: #156
User-Visible: yes
2026-08-14 22:31:36 +03:00
Sergey Matyunin 6ebf12af1e docs: self-review performance repair
Issue: #156
User-Visible: no
2026-08-14 22:28:11 +03:00
Sergey Matyunin 09143e23a6 perf: remove v1.64 render regressions
Issue: #156
User-Visible: yes
2026-08-14 22:27:29 +03:00
Sergey Matyunin 0e6cb7570b docs: specify v1.64 performance repairs
Issue: #156
User-Visible: no
2026-08-14 22:19:50 +03:00
Sergey Matyunin 321d153c22 fix: ignore published main commits during dev reconciliation
Issue: #155
User-Visible: no
2026-08-14 21:25:45 +03:00
Sergey Matyunin 7f70b64f48 Merge main into dev for v1.64.0 2026-08-14 21:10:39 +03:00
Sergey Matyunin 6c37cd5f05 build: promote v1.64.0
Issue: #153
User-Visible: yes
2026-08-14 21:01:17 +03:00
Matysh 7642c484d2 feat: analysis proceeds on its own, questions are product-only and spec-stage
The analyst used to ask the owner to confirm every estimate and waited for an
answer on each point. Most issues are unambiguous, and most of that waiting
changed nothing — the owner's own measure is that seven issues in ten should
travel from S1-new to a finished spec without a single question.

Section 2.2 flips the default. Estimates, type, priority and track go on as
labels immediately; the analysis comment is a notification, not a request —
the owner's silence is consent, his disagreement is a label edit, and neither
stops the work. The analyst moves the issue to S3-spec himself. The only
questions that ever reach the owner are product questions, asked at the spec
stage in one batch with defaults and blocked, and only when the spec cannot be
written without the answer; anything that can wait for the spec waits, anything
that does not block it becomes a recorded assumption instead. The one full stop
left in analysis is a genuine SCOPE conflict, where the analyst proposes
rejection and the owner decides.

Issue: #114
User-Visible: no
2026-08-14 20:31:08 +03:00
Matysh ec7408f3d5 docs: the pre-1.62 "no tests, no commits" workflow line is dead
The author agent read STATUS.md, saw the owner's 2026-08-07 rule that ordinary
fixes are made locally without tests or commits, correctly ranked it below
AGENTS.md and PROCESS.md, and followed the canon instead. That is the trust
order doing its job — and the canon's second half says a divergence is not
ignored but fixed.

The line now says what replaced it: since release 1.62 every product change goes
through the process — an issue in S5-ready or later, a task branch, trailers on
every commit, the review pipeline. The release mechanics in the same cell were
still accurate and stay.

Issue: #114
User-Visible: no
2026-08-14 20:17:39 +03:00
Matysh 0f8d35f516 docs: run the AC-named smokes locally before S7-code-review
The owner's machine now carries Playwright with Chromium on Windows and a full
WSL environment — verified by execution: 34/34 smoke assertions, and 242 backend
tests passed where native Windows silently skips every test_ha_* file. A red
smoke that reaches the review costs a cycle of forty-five minutes plus the
return trip; run locally it costs a minute, and #89 already paid that price
once.

WSL runs of the full harness 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
golden:accept --reviewed on a complete Linux CI artefact.

Issue: #151
User-Visible: no
2026-08-14 19:50:47 +03:00
Sergey Matyunin 295257240d test: accept v1.64.0-beta.2 Linux golden baselines
Issue: #137
Issue: #141
Issue: #146
User-Visible: no
Release: v1.64.0-beta.2
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/31813468028
2026-08-14 19:04:08 +03:00
Sergey Matyunin f2fcf0d594 Release v1.64.0-beta.2 candidate
Validate / changes (push) Successful in 40s
Validate / provenance (push) Successful in 46s
Validate / process-gate (push) Failing after 45s
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 8m16s
Validate / backend (push) Failing after 8m22s
Validate / smoke (push) Failing after 3m27s
Validate / golden (push) Failing after 9m42s
Validate / performance_smoke (push) Failing after 13m13s
Issue: #146
User-Visible: yes
2026-08-14 18:14:02 +03:00
claude[bot] 3e4e549b56 docs: code review r2 for #146
Issue: #146
User-Visible: no
2026-08-14 14:52:20 +00:00
Sergey Matyunin 875b09cd8d fix: preserve zoom badge above sun background
Issue: #146
User-Visible: yes
2026-08-14 17:41:18 +03:00
claude[bot] 84cd5f9331 docs: review document for #146
Issue: #146
User-Visible: no
2026-08-14 14:38:19 +00:00
Sergey Matyunin debb13baa2 feat: add four-phase sun background
Issue: #146
User-Visible: yes
2026-08-14 17:17:25 +03:00
claude[bot] ab2a014568 docs: spec review r1 for #146
Issue: #146
User-Visible: no
2026-08-14 13:47:32 +00:00
Sergey Matyunin 558dae95cd docs: specify four-phase sun background
Issue: #146
User-Visible: no
2026-08-14 16:34:45 +03:00
claude[bot]andSergey Matyunin 1937c32572 docs: review document for #140
Issue: #140
User-Visible: no
2026-08-14 16:08:32 +03:00
Sergey Matyunin 5ed2821161 Fix editor property dialog footer width
Issue: #140
User-Visible: yes
2026-08-14 16:08:31 +03:00
Sergey Matyuninandclaude[bot] f66cf8ad4d fix: auto-close rooms along shared walls
Issue: #138
User-Visible: yes
2026-08-14 12:59:09 +00:00
claude[bot] d66cd2ebab docs: review document for #138
Issue: #138
User-Visible: no
2026-08-14 12:59:09 +00:00
Sergey Matyuninandclaude[bot] f0e7700805 docs(spec): guard autoclose minimum vertices
Issue: #138
User-Visible: no
2026-08-14 12:59:09 +00:00
claude[bot] aad625a84d docs: review document for #138
Issue: #138
User-Visible: no
2026-08-14 12:59:09 +00:00
Sergey Matyuninandclaude[bot] b57ea94cb8 docs(spec): define adjacent-room autoclose
Issue: #138
User-Visible: no
2026-08-14 12:59:09 +00:00
Matysh 737e7b62aa fix: the golden mutant guard runs capture, because verify forbids one scene
First real run of the gate failed before reaching a single mutant: the clean
run of the golden guard was red on untouched code. demo/golden/policy.mjs
refuses `verify --scenario=...` on purpose — a partial verify is the "make CI
green" loophole the policy exists to close. The gate built to catch dishonest
tests had reached for a dishonest shortcut, and the policy caught it.

capture keeps the whole check: a failed semantic assertion becomes status
error, and goldenRunFailed treats an error as failure in either mode. The scene
carries warmPixelRegion with minPixels 2500 over the receiving half, so a lamp
moved out of reach still fails it — which is exactly what this mutant asserts.

Issue: #85
User-Visible: no
2026-08-14 15:18:46 +03:00
claude[bot] f11a4e1085 docs: review document for #141
Issue: #141
User-Visible: no
2026-08-14 12:11:08 +00:00
Sergey Matyuninandclaude[bot] 548677a99c fix: preserve single wall previews
Issue: #141
User-Visible: yes
2026-08-14 12:11:08 +00:00
Sergey Matyuninandclaude[bot] 087f7cf381 feat: join independent wall junctions
Issue: #141
User-Visible: yes
2026-08-14 12:11:08 +00:00
claude[bot]andclaude[bot] b860ef4c43 docs: spec review for #141
Issue: #141
User-Visible: no
2026-08-14 12:11:08 +00:00
Sergey Matyuninandclaude[bot] 3b0b9eea50 docs: specify seamless wall junctions
Issue: #141
User-Visible: no
2026-08-14 12:11:08 +00:00
Matysh 0cf10613f2 ci: mutation-gate must live on the default branch to be dispatchable
Validate / hacs (push) Failing after 15s
Validate / hassfest (push) Failing after 13s
Validate / provenance (push) Successful in 43s
Validate / process-gate (push) Failing after 44s
Validate / frontend (push) Successful in 6m54s
Validate / backend (push) Failing after 10m31s
Validate / golden (push) Failing after 9m38s
Validate / performance_smoke (push) Failing after 10m29s
Validate / smoke (push) Failing after 29m50s
Full Performance / performance (push) Failing after 1h43m37s
gh workflow run answered 404: workflow_dispatch and schedule both resolve the
workflow file against the default branch, and the file sat only in dev. The
same trap as the process pipeline — even documented in that file's header — and
still stepped in a second time. The job itself checks out dev, so running from
main tests exactly the code it should.

Issue: #85
User-Visible: no
2026-08-14 15:08:31 +03:00
Matysh e894ce2986 docs: fix the working-tree layout in writing
Two agents sharing one checkout share one HEAD, and twice in an hour a commit
landed on someone else's task branch that way. The layout that ends it: the main
clone belongs to the author and its task branches, hp-dev is the owner's
permanent worktree on dev, and the reviewer and the infrastructure agent own no
local tree at all — one runs in CI on a fresh checkout, the other reads through
git show and publishes through the API, so it has no HEAD to collide with.

Also recorded: a worktree is only usable on the machine that created it, because
its .git file stores an absolute path in that machine's format. We hit this in
both directions within a day.

Issue: #115
User-Visible: no
2026-08-14 12:31:12 +03:00
Matysh ac30f8913d fix: build the gate CLI path with fileURLToPath, not URL.pathname
On Windows URL.pathname yields /C:/..., which spawnSync then reads as C:\C:\...
and the whole npm test run dies in this one test. Linux CI never caught it
because both spellings coincide there — which is exactly why the canonical gate
lives on Linux and the local run is advisory.

Issue: #133
User-Visible: no
2026-08-14 12:15:06 +03:00
Matysh de46db3343 docs: restore exact wording in the moved #89 spec review
One word was mistyped while transferring the file: "на каждый HA state
update" instead of "на каждом". The moved document must match the original
byte for byte.

Issue: #142
User-Visible: no
2026-08-14 11:50:54 +03:00
Matysh 782ff54e0f docs: remove originals after the move to docs/reviews and legacy
Issue: #142
User-Visible: no
2026-08-14 11:41:05 +03:00
Matysh b989c84b71 docs: remove originals after the move to docs/reviews and legacy
Issue: #142
User-Visible: no
2026-08-14 11:40:57 +03:00
Matysh 400ca7043e docs: remove originals after the move to docs/reviews and legacy
Issue: #142
User-Visible: no
2026-08-14 11:40:48 +03:00
Matysh 9f5d729538 docs: remove originals after the move to docs/reviews and legacy
Issue: #142
User-Visible: no
2026-08-14 11:40:36 +03:00
Matysh 8eb4bab7c6 docs: file reviews where reviews live, retire the #89 draft, honest markers
Three review documents sat in the repository root, committed before the pipeline
existed and before docs/reviews/ did. The directory exists now and the pipeline
writes into it, so they move there and the root stops being a second place to
look.

The #89 spec had a twin: the research draft next to the normative stage1
document, two files for one issue. The draft goes to legacy — it fed the
decisions and is worth keeping, but nothing should read it as current.

ROADMAP.md carried a live link to the Project v2 board that was dropped
yesterday; missed then because the sweep grepped for status-canon wording, not
for every link. And docs/README.ru.md said "verified against v1.60.0" as if
that were fresh — the line is now an explicit warning naming what to trust
instead: USER-GUIDE.ru.md and the changelogs.

Issue: #142
User-Visible: no
2026-08-14 11:40:26 +03:00
Sergey Matyuninandclaude[bot] 6c48c6d5c6 feat: add architectural snap overlay
Issue: #137
User-Visible: yes
2026-08-14 08:27:45 +00:00
claude[bot]andclaude[bot] 5bebc26aeb docs: review document for #137
Issue: #137
User-Visible: no
2026-08-14 08:27:45 +00:00
Sergey Matyuninandclaude[bot] f5c36da648 docs: specify plan snap overlay
Issue: #137
User-Visible: no
2026-08-14 08:27:45 +00:00
Matysh e9a148315a docs: restore the paragraph lost while publishing PROCESS.md
Three lines of section 10.4 and the trailing newline went missing in transit.
The lost paragraph is the one that says a label which did not change means the
run failed rather than the work — the sentence that tells a waiting author to
read the logs instead of polling for another forty-five minutes. Losing exactly
that one while copying a document about silent failures is a joke the situation
made on its own.

Caught by the byte comparison that follows every publish, which is the whole
reason it follows every publish.

Issue: #139
User-Visible: no
2026-08-14 11:11:41 +03:00
Matysh fb4096f67b chore: drop Project v2 from the process, the docs and the release script
The owner stopped using GitHub Projects. Most of this is wording, but one part
was not: release-prerelease.mjs talked to the Project in code. finishIssues
looked up the project id, listed its items and its Status=Done option, and threw
when an issue was missing from the board — so the first release that closed an
issue would have died on a step with nothing to do with publishing. Found by
reading rather than by releasing, which was luck.

Closing issues stays, and now strips the status label first. That order is not
cosmetic: the invariant that a closed issue carries no status label has broken
twice already, both times because a manual step did it the other way round. The
close-merged job already does it in this order.

The documents now say labels and only labels. The explicit "no longer used"
lines are kept on purpose, in PROCESS.md and next to the code that used to sync:
a decision that vanishes quietly gets reintroduced a month later by someone who
never knew it was made.

Issue: #139
User-Visible: no
2026-08-14 10:59:09 +03:00
Matysh e83da25085 chore: drop Project v2 from the process, the docs and the release script
The owner stopped using GitHub Projects. Most of this is wording, but one part
was not: release-prerelease.mjs talked to the Project in code. finishIssues
looked up the project id, listed its items and its Status=Done option, and threw
when an issue was missing from the board — so the first release that closed an
issue would have died on a step with nothing to do with publishing. Found by
reading rather than by releasing, which was luck.

Closing issues stays, and now strips the status label first. That order is not
cosmetic: the invariant that a closed issue carries no status label has broken
twice already, both times because a manual step did it the other way round. The
close-merged job already does it in this order.

The documents now say labels and only labels. The explicit "no longer used"
lines are kept on purpose, in PROCESS.md and next to the code that used to sync:
a decision that vanishes quietly gets reintroduced a month later by someone who
never knew it was made.

Issue: #139
User-Visible: no
2026-08-14 10:54:34 +03:00
Matysh ae10b2861b chore: drop Project v2 from the process, the docs and the release script
The owner stopped using GitHub Projects. Most of this is wording, but one part
was not: release-prerelease.mjs talked to the Project in code. finishIssues
looked up the project id, listed its items and its Status=Done option, and threw
when an issue was missing from the board — so the first release that closed an
issue would have died on a step with nothing to do with publishing. Found by
reading rather than by releasing, which was luck.

Closing issues stays, and now strips the status label first. That order is not
cosmetic: the invariant that a closed issue carries no status label has broken
twice already, both times because a manual step did it the other way round. The
close-merged job already does it in this order.

The documents now say labels and only labels. The explicit "no longer used"
lines are kept on purpose, in PROCESS.md and next to the code that used to sync:
a decision that vanishes quietly gets reintroduced a month later by someone who
never knew it was made.

Issue: #139
User-Visible: no
2026-08-14 10:50:56 +03:00
Matysh eef3634f23 chore: drop Project v2 from the process, the docs and the release script
The owner stopped using GitHub Projects. Most of this is wording, but one part
was not: release-prerelease.mjs talked to the Project in code. finishIssues
looked up the project id, listed its items and its Status=Done option, and threw
when an issue was missing from the board — so the first release that closed an
issue would have died on a step with nothing to do with publishing. Found by
reading rather than by releasing, which was luck.

Closing issues stays, and now strips the status label first. That order is not
cosmetic: the invariant that a closed issue carries no status label has broken
twice already, both times because a manual step did it the other way round. The
close-merged job already does it in this order.

The documents now say labels and only labels. The explicit "no longer used"
lines are kept on purpose, in PROCESS.md and next to the code that used to sync:
a decision that vanishes quietly gets reintroduced a month later by someone who
never knew it was made.

Issue: #139
User-Visible: no
2026-08-14 10:48:58 +03:00
Matysh 6ecbedfb85 ci: heavy Validate jobs run only where relevant paths changed
Validate / changes (push) Successful in 52s
Validate / process-gate (push) Failing after 1m17s
Validate / provenance (push) Successful in 1m20s
Validate / hacs (push) Failing after 16s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 7m30s
Validate / backend (push) Failing after 8m41s
Validate / performance_smoke (push) Failing after 9m45s
Validate / golden (push) Failing after 14m25s
Validate / smoke (push) Failing after 28m53s
Every push to every branch ran 128 browser smokes, 50 golden scenes, a Home
Assistant install and a performance pass — including a push that added one spec
file. The pipeline made such pushes routine: every spec revision and every
review document is a push to a task branch and used to cost the full suite.

A changes job classifies the push range; frontend, smoke, golden, performance
and backend now run only when their paths moved, and hacs and hassfest only for
manifests, translations or Python. provenance and process-gate always run — they
judge commits, not code.

The exception carries the design. On dev everything runs, always, unfiltered:
the beta gate accepts "green Validate at the exact SHA", and if the volume of a
run depends on the diff, green stops meaning one thing — a release candidate
touches manifests and changelogs, would skip the browser suites under filtering,
and a run with skipped jobs still concludes success. That would be the sixth
silent success of the week. Filters save time on task branches, where Validate is
an early signal and the real acceptance is the code review running gates itself.

A new branch with a zero before-sha is classified from the merge-base with dev,
not from the root of history.

Issue: #136
User-Visible: no
2026-08-14 10:16:18 +03:00
Matysh e6366b6548 fix: load virtual_lights by path so offline collection survives
The file declared itself pure but imported the module through the package, and
the package __init__ unconditionally imports homeassistant. Without homeassistant
installed pytest did not skip the file — it stopped collecting the whole
tests_backend directory, taking the previously working pure suite down with it.
test_validation.py had already established the by-path pattern; virtual_lights.py
imports nothing beyond the standard library, so it loads cleanly.

The async tests also dropped their pytest-asyncio dependency in favour of
asyncio.run: the offline environment does not carry the plugin, and without it
the two tests failed as unsupported async defs. The offline gate has to be green,
or nobody runs it.

Verified in both environments: pytest+voluptuous only — 129 passed where
collection previously stopped dead; with pytest-asyncio as in CI — 129 passed.

Issue: #135
User-Visible: no
2026-08-14 10:10:34 +03:00
269 changed files with 32849 additions and 3133 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
steps:
- name: Check out release notes for a reusable call
if: ${{ inputs.reusable == true }}
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
- name: Send to Telegram
+18 -6
View File
@@ -13,6 +13,11 @@ name: Mutation gate
on:
workflow_dispatch:
inputs:
ref:
description: Git ref whose mutation guards must be proved
required: false
default: dev
schedule:
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
- cron: '20 5 * * 1'
@@ -27,25 +32,32 @@ concurrency:
jobs:
mutants:
runs-on: ubuntu-latest
# Шесть мутантов × (сборка + браузерный смок) — это десятки минут, и это
# нормально: гейт предрелизный. Час — потолок против зависшего Chromium.
# Все мутанты × (сборка + свой guard) — это десятки минут, и это нормально:
# гейт предрелизный. Час — потолок против зависшего Chromium.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: dev
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Установить backend test dependencies
run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
+7 -4
View File
@@ -30,7 +30,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Check out candidate
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
path: candidate
fetch-depth: 2
@@ -116,12 +116,12 @@ jobs:
echo "Comparison base: $sha ($source)" >> "$GITHUB_STEP_SUMMARY"
- name: Check out base SHA
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
ref: ${{ steps.base.outputs.sha }}
path: baseline
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
@@ -150,6 +150,8 @@ jobs:
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
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
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
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
@@ -164,12 +166,13 @@ jobs:
run: |
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
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
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
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
- name: Upload full performance reports
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: full-performance
path: artifacts/performance
+70 -11
View File
@@ -131,17 +131,37 @@ jobs:
# Время — единственный настоящий ограничитель зациклившегося прогона.
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: dev
# Иначе в конфиге git остаётся креденшел GITHUB_TOKEN, и push с
# мёртвым PAT молча уходит от github-actions[bot] — 403 при
# contents: read. Отказ обязан быть громким и правильным.
persist-credentials: false
# Живость PAT проверяется ДО ревью. На #150 истёкший токен обнаружился
# только на публикации документа — после сорока минут работы ревьюера.
- name: Секрет HP_PROCESS_TOKEN жив
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::HP_PROCESS_TOKEN пуст — секрет удалён или недоступен"
exit 1
fi
if ! login=$(gh api user -q .login 2>/dev/null); then
echo "::error::HP_PROCESS_TOKEN не аутентифицируется — истёк или отозван. Обновить: Settings -> Secrets and variables -> Actions -> HP_PROCESS_TOKEN"
exit 1
fi
echo "токен жив, действует от: $login"
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
# тяжёлых job, здесь его не было.
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
@@ -154,8 +174,15 @@ jobs:
env:
NUM: ${{ github.event.issue.number }}
run: |
branch=$(git ls-remote --heads origin "issue/${NUM}-*" \
| head -1 | sed 's|.*refs/heads/||')
# Свежая по последнему коммиту, а не первая по алфавиту: на #150 рядом
# жили ветка ТЗ и ветка реализации, и head -1 выбрал устаревшую.
git fetch -q origin "+refs/heads/issue/${NUM}-*:refs/remotes/origin/issue/${NUM}-*" || true
branches=$(git for-each-ref --sort=-committerdate \
--format='%(refname:lstrip=3)' "refs/remotes/origin/issue/${NUM}-*")
branch=$(printf '%s\n' "$branches" | head -1)
if [ "$(printf '%s\n' "$branches" | grep -c .)" -gt 1 ]; then
echo "::warning::веток issue/${NUM}-* несколько ($(echo $branches | tr '\n' ' ')) — выбрана свежая по коммиту: $branch. Устаревшую следует удалить."
fi
if [ -n "$branch" ]; then
git checkout -q "origin/$branch"
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
@@ -174,14 +201,19 @@ jobs:
# но когда нужен — качать его заново дороже, чем держать в кэше.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
# Без --with-deps: системные библиотеки Chromium предустановлены в
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
# библиотека когда-нибудь пропадёт из образа, Chromium не запустится с
# внятной ошибкой — тогда флаг вернуть.
run: npx playwright install chromium
- name: Review
id: review
@@ -303,6 +335,8 @@ jobs:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
run: |
# Ветки задачи может не быть: у задач, размеченных до появления
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
@@ -313,12 +347,28 @@ jobs:
if [ -z "$BRANCH" ]; then
echo "::warning::ветки задачи нет — документ ревью ляжет в dev"
fi
marker=CODE-REVIEW
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
git checkout -- . 2>/dev/null || true
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
git add docs/reviews 2>/dev/null || true
if git diff --cached --quiet; then
echo "::warning::документ ревью не создан"
exit 0
# Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит
# документ сам, своим app-токеном мимо этого шага (CODE-REVIEW-150-r1,
# коммиттер GitHub). Провал — когда файла нет и на ветке.
git fetch -q origin "$target"
if git cat-file -e "origin/$target:$doc" 2>/dev/null; then
echo "документ уже опубликован ревьюером: $doc"
exit 0
fi
# Ревью без артефакта запрещено (PROCESS.md §2.4/§10.4/§12). Раньше
# здесь стоял warning с exit 0: на #150 оба вердикта ревью ТЗ
# остались только комментариями, метки переставились, и пропажу
# заметило лишь следующее ревью — issue #171. Падение ДО шага с
# меткой сохраняет инвариант «метка не сменилась = прогон упал».
echo "::error::вердикт есть, а документа $doc нет ни в рабочей копии, ни в $target — ревью без артефакта (#171)"
exit 1
fi
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
@@ -338,13 +388,22 @@ jobs:
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
rebase "origin/$target"; then
git rebase --abort || true
echo "::error::документ ревью не удалось опубликовать в $target: конфликт"
exit 0
# Тоже вердикт без артефакта: раньше exit 0 переставил бы метку.
echo "::error::документ ревью не удалось опубликовать в $target: конфликт (#171)"
exit 1
fi
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"
fi
echo "документ опубликован в $target"
# Постусловие: до ветки дошёл именно ожидаемый файл. Коммит с
# документом, названным не по формату, — тот же вердикт без
# артефакта, только дороже в обнаружении.
git fetch -q origin "$target"
if ! git cat-file -e "origin/$target:$doc" 2>/dev/null; then
echo "::error::коммит в $target опубликован, но ожидаемого $doc в нём нет — файл назван не по формату (#171)"
exit 1
fi
echo "документ опубликован в $target: $doc"
- name: Решение по вердикту
id: decide
+5 -5
View File
@@ -24,11 +24,11 @@ jobs:
sha: ${{ steps.candidate.outputs.sha }}
tag: ${{ steps.candidate.outputs.tag }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Pin the current dev candidate
id: candidate
@@ -67,11 +67,11 @@ jobs:
url: ${{ steps.verify.outputs.url }}
newly_published: ${{ steps.release.outputs.newly_published }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ needs.gate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Build and verify both release assets before publication
env:
@@ -173,7 +173,7 @@ jobs:
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n- assets: `houseplan-card.js`, `houseplan.zip`\n' \
"$TAG" "$SHA" "$URL" >> "$GITHUB_STEP_SUMMARY"
- name: Verify HACS prerelease discovery order
uses: actions/github-script@v7
uses: actions/github-script@v9
env:
EXPECTED_TAG: ${{ needs.gate.outputs.tag }}
with:
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ github.event.inputs.tag }}
run: echo "tag=${EVENT_TAG:-$INPUT_TAG}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ steps.tag.outputs.tag }}
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
+7 -7
View File
@@ -16,11 +16,11 @@ jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ github.event.release.tag_name }}
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Require a green Validate for this exact commit
env:
@@ -47,10 +47,10 @@ jobs:
needs: gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ github.event.release.tag_name }}
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with: { node-version: 22 }
- run: npm ci && npm run build
- name: Verify compositor frame continuity for a stable release
@@ -61,13 +61,13 @@ jobs:
npm run continuity:screencast
- name: Upload failed continuity frames
if: ${{ failure() && !github.event.release.prerelease }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: continuity-screencast
path: artifacts/continuity-screencast
- run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
- name: Attach card to release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: dist/houseplan-card.js
hacs-discovery:
@@ -80,7 +80,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Verify the published tag is the prerelease HACS will discover
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
+89 -22
View File
@@ -15,12 +15,21 @@ concurrency:
cancel-in-progress: true
jobs:
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Validate public documentation
run: node scripts/check-docs.mjs --external
provenance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Validate commit trailers and hook mode
env:
@@ -28,8 +37,9 @@ jobs:
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
DEVELOPMENT_BRANCH: dev
run: |
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range
# Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на
@@ -40,9 +50,9 @@ jobs:
process-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Process gate
env:
@@ -50,34 +60,89 @@ jobs:
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
DEVELOPMENT_BRANCH: dev
TARGET_REF: ${{ github.ref }}
# Публичный репозиторий: штатного токена хватает на чтение issue.
GH_TOKEN: ${{ github.token }}
run: |
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
node scripts/process-gate.mjs --github-range --issues
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
# относящееся к ним. НА DEV ФИЛЬТРОВ НЕТ: гейт беты принимает «зелёный Validate
# на точном SHA», и если объём прогона зависит от diff, «зелёный» перестаёт
# значить одно и то же — кандидат релиза (манифесты + changelog) пропустил бы
# браузерные тесты, а прогон с пропущенными job всё равно success. Фильтры
# экономят на ветках задач, где Validate — ранний сигнал: настоящую приёмку
# там делает код-ревью, которое гоняет гейты само (#127).
changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.classify.outputs.frontend }}
backend: ${{ steps.classify.outputs.backend }}
integration: ${{ steps.classify.outputs.integration }}
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- id: classify
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
REF: ${{ github.ref }}
run: |
if [ "$REF" = "refs/heads/dev" ]; then
echo "dev: без фильтров, всё true"
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
exit 0
fi
zero=$(printf '%040d' 0)
base="$BEFORE_SHA"
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
# иначе классифицировалась бы вся история.
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|| ! git cat-file -e "$base" 2>/dev/null; then
git fetch -q origin dev
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1")
fi
files=$(git diff --name-only "$base" "$HEAD_SHA")
printf '%s\n' "$files" | head -50
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
{
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|pytest\.ini$)')"
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
} >> "$GITHUB_OUTPUT"
hacs:
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: HACS validation
uses: hacs/action@main
with:
category: integration
hassfest:
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Hassfest validation
uses: home-assistant/actions/hassfest@master
frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
@@ -98,8 +163,8 @@ jobs:
needs: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
@@ -125,7 +190,7 @@ jobs:
exit $fail
- name: Upload smoke logs
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: smoke-logs
path: /tmp/smoke-logs
@@ -136,8 +201,8 @@ jobs:
needs: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
@@ -158,7 +223,7 @@ jobs:
fi
- name: Upload golden candidates/diffs
if: failure() || steps.golden.outputs.has_baselines == 'false'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: golden-images
path: artifacts/golden
@@ -170,8 +235,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
@@ -188,20 +253,22 @@ jobs:
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
- name: Upload performance smoke report
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: performance-smoke
path: artifacts/performance-smoke
backend:
needs: changes
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
# Browser fixtures are generated by their real ESM factories and then
# validated through the Python CONFIG_SCHEMA/LAYOUT_SCHEMA in the same test.
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with: { node-version: 22 }
- uses: actions/setup-python@v5
- uses: actions/setup-python@v7
with: { python-version: "3.13" }
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
- name: Backend unit tests (pure + HA harness)
+36 -5
View File
@@ -38,8 +38,7 @@ task records: problem, scope, acceptance criteria and discussion.
**Status lives in labels:** `S1-new`, `S2-analysis`, `S3-spec`, `S4-spec-review`,
`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`, plus `blocked` on top
of a status and `rejected` on a closed issue. Exactly one `S*` label per open
issue. [GitHub Projects (v2)](https://github.com/users/Matysh/projects/1) is a
human-facing view synchronised from the labels, not the source of truth.
issue. Labels are the whole of it: GitHub Projects is no longer used.
Two shortcuts exist for small work. `small` — the light track: the spec lives in
the issue body and its review is a comment. `trivial` — the short track: no spec
@@ -155,6 +154,28 @@ arrive. Cycles are counted per stage, so a code review spends its own budget.
Everything else still requires the owner's explicit command: pushing `main`,
creating tags, publishing betas and releases, closing issues.
## Working trees (#115)
One checkout, one `HEAD`: two agents sharing a directory inherit each other's
branch, and twice in one hour a commit landed on someone else's task branch that
way. The layout is therefore fixed:
- **`houseplan-card-src/houseplan-card`** — the author's tree. Task branches live
here; nobody else commits in it. Unfamiliar local changes belong to the author
or the owner — never reset or clean them away.
- **`houseplan-card-src/hp-dev`** — the owner's worktree, permanently on `dev`. For owner-side operations that must not disturb the
author's tree: pushing `dev`, restoring a hook's executable bit, emergencies.
- **The reviewer and the infrastructure agent own no local tree.** The reviewer
runs in CI on a fresh checkout. The infrastructure agent reads via `git show`
and publishes through the GitHub API; it makes no local commits at all, so it
needs no `HEAD` of its own. Its scratch worktrees live outside the repo and are
pruned after use.
A worktree is only usable on the machine that created it: the `.git` file records
an absolute path in that machine's format. One created from a Linux sandbox is
dead on Windows and vice versa — create worktrees on the machine that will use
them, which for `hp-dev` means the owner's.
## Two-agent workflow
**Codex** writes analysis, specs and all product code. **Claude** reviews specs and
@@ -299,9 +320,19 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
```
During the implementation cycle only the fast gates run. `smoke`, `golden` and
`performance_smoke` spin up Chromium and belong to the pre-beta run — which is then
mandatory and complete.
During the implementation cycle the fast gates always run. Since 2026-08-14 the
owner's machine also carries Playwright with Chromium (Windows) and a full WSL
environment, which changes one thing (#151): **before moving an issue to
`S7-code-review`, run the smokes named in its AC locally** — `node
demo/smoke_<name>.mjs`. A red smoke that reaches the review costs a cycle; run
locally it costs a minute. Precedent: on #89 a fixture error lived through a
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.
**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.
+6 -7
View File
@@ -18,13 +18,12 @@ requests still belong in [issues](https://github.com/Matysh/houseplan-card/issue
## Backlog and work status
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and the linked
[GitHub Project v2](https://github.com/users/Matysh/projects/1) are the
only active project backlog. Issues own scope and acceptance criteria; Project
v2 owns prioritization and workflow status. Before starting planned work, link
it to an existing issue or create one, add it to the Project, and keep both
surfaces current until the verified result is closed. Design specs and ADRs may
support an issue, but they do not replace it or maintain a separate checklist.
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the only
active backlog. An issue owns scope and acceptance criteria; its **labels** own
priority and workflow status — `PROCESS.md` §9 holds the vocabulary. Before
starting planned work, link it to an existing issue or create one, and keep it
current until the verified result is closed. Design specs and ADRs may support an
issue, but they do not replace it or maintain a separate checklist.
## Five-minute setup
+34 -10
View File
@@ -11,7 +11,7 @@
> его не содержал вовсе.
>
> **Приоритет источников.** Канонический бэклог — GitHub Issues; статус живёт в
> метках, Project v2 остаётся человеческим представлением. При расхождении
> метках и больше нигде: Project v2 не используется. При расхождении
> документации с GitHub побеждает GitHub. При расхождении этого документа с
> `.github/workflows/*.yml` и `scripts/*` побеждает **фактическая автоматизация**:
> она исполняется, а описание — нет. Расхождение при этом не игнорируется, а
@@ -86,9 +86,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
### 2.2 Аналитика и оценка
Задача разбирается, продуктовое «да» ещё не дано.
Задача разбирается — и разобранная **сама идёт дальше**. Умолчание изменено
решением владельца 2026-08-14: раньше аналитика ждала подтверждения по каждому
пункту, и большинство ожиданий ничего не меняло — issue в основном описаны
однозначно.
- **Кто:** агент-аналитик готовит, владелец решает.
- **Кто:** агент-аналитик. Владелец не утверждает переход — он правит асинхронно.
- **Чек-лист**, результат — комментарием в issue:
1. дубликаты проверены (ссылки на похожие issue);
2. в скоупе по `docs/SCOPE.md` и `docs/TOUCH-SUPPORT.md`;
@@ -98,10 +101,21 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
5. приоритет **P1/P2/P3**;
6. тип: баг / фича / техдолг;
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
8. лёгкий трек — да/нет по критериям §5.
- **Приоритет и ценность — поля владельца.** Агент предлагает, владелец
утверждает; иначе агенты приоритизируют сами и P1 разрастается.
- **Выход:** «ТЗ в работе» либо «Отклонено» с записанной причиной.
8. трек: обычный / `small` / `trivial` по критериям §5 и §5.1.
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
согласие**, несогласие он выражает правкой меток или комментарием, и это не
останавливает работу. Право отклонить задачу (`rejected`) остаётся за
владельцем на любой стадии.
- **Вопросов владельцу на этом этапе нет.** Единственный класс вопросов, который
вообще задаётся владельцу, — продуктовые (§7.1: что человек видит или делает,
объём видимых изменений), и их место — этап ТЗ, пачкой, с вариантами по
умолчанию и `blocked`. Вопрос, который можно отложить до ТЗ, не задаётся в
аналитике; вопрос, не блокирующий написание ТЗ, не задаётся вовсе — вместо
него в ТЗ пишется блок принятых предположений.
- **Выход:** `S3-spec` — переход выполняет сам аналитик, не дожидаясь ответа.
Либо, при явном конфликте со `SCOPE.md`, — предложение отклонить с причиной:
это единственный случай, когда аналитика останавливается и ждёт владельца.
### 2.3 ТЗ в работе — написание ТЗ
@@ -492,9 +506,12 @@ Performance зелёные на точном SHA; статусов issue не к
## 9. Метки — канонический статус
Статус читается из меток: их видно в списке issue и их читает любой токен с
доступом к Issues, в отличие от Project v2, который требует отдельного скоупа.
Project v2 остаётся человеческим представлением и синхронизируется по меткам.
Статус читается из меток: их видно в списке issue, их читает любой токен с
доступом к Issues, и по ним же работает конвейер — смена метки порождает событие
(§10.4). **Project v2 не используется** (решение владельца 2026-08-14): второе
представление статуса рядом с метками требовало отдельного скоупа токена,
синхронизации и внимания, а давало вид доски. Два источника одного факта
расходятся — это уже случалось с колонкой «Статус ТЗ» в `docs/specs/README.md`.
**Имена меток английские** (решение владельца 2026-08-12). Русские имена в этом
документе были только на бумаге; репозиторий с самого начала жил на английских.
@@ -571,6 +588,13 @@ Project v2 остаётся человеческим представление
считается от `merge-base` с `origin/dev`, а не от начала истории — иначе в него
попали бы все нарушения, совершённые до появления гейта.
При возврате `main` в `dev` диапазон merge-коммита содержит второй родитель —
уже опубликованные в `main` коммиты с закрытыми issue. Для destination `dev`
общий скрипт pre-push/CI исключает только SHA, доказанно достижимые из
`origin/main`; сам merge и новые post-merge коммиты остаются под всеми
проверками. На `main`, beta/issue-ветки и обычный push в `dev` это исключение
не распространяется (issue #155).
Проверка статуса issue требует `gh`, поэтому при его отсутствии хук печатает
предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук,
который не работает в самолёте, отключают целиком, а строгий проход всё равно
+103 -325
View File
@@ -1,379 +1,157 @@
# 🏠 House Plan — interactive floor plan card for Home Assistant
# 🏠 House Plan — a live home map for Home Assistant
[![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases)
[![GitHub stars](https://img.shields.io/github/stars/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/stargazers)
[![CI](https://github.com/Matysh/houseplan-card/actions/workflows/validate.yml/badge.svg)](https://github.com/Matysh/houseplan-card/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Live demo](https://img.shields.io/badge/demo-try_it_live-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
[![Telegram chat](https://img.shields.io/badge/Telegram-chat-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
**Turn Home Assistant into a live, interactive map of your home.** Upload or draw
a floor plan, outline the rooms with your mouse — and every smart device appears
in its real place: live states, tap-to-toggle lights, temperature and humidity per
room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wall
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
right on your Lovelace dashboard.
📘 **[Full user guide](docs/USER-GUIDE.md)** · 🇷🇺 **[Русский](README.ru.md)** · 🗂 **[Project issues](https://github.com/Matysh/houseplan-card/issues)**
> **Use a desktop computer to edit plans.** View and kiosk are fully supported
> on phones and tablets. The editors are designed primarily for a desktop
> browser with a mouse and keyboard; individual editing operations on touch
> devices may be awkward, limited, or unavailable.
<!-- docs-section: overview -->
![Interactive Home Assistant floor plan: live rooms, devices, lights and climate on a real floorplan card](docs/images/demo.gif)
## Your whole home at a glance
> ### 🚀 Try it live — no install needed
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — a real Home Assistant
> with a ready-made plan. Log in as **`demo`** / **`demo`** and click anything:
> toggle lights, open the editors, break things. The stand resets itself to a
> pristine state every hour.
House Plan turns Home Assistant into a live map of your home. Upload a plan or
draw rooms directly on the dashboard, bind them to Home Assistant areas, and
the area's devices appear automatically. You can immediately see where a light
is on, a door is open, a room is too cold, Zigbee signal is weak, or a leak
sensor has fired.
🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan)
![Synthetic home in View mode with rooms, devices, light and climate](docs/images/01-view-desktop.png)
**Feature highlights**
Setup is entirely graphical: no floor-plan YAML, Inkscape, or external editor.
Plan data and device positions live on the Home Assistant server and stay in
sync across screens.
- ♾️ **An infinite canvas** — there is no "plan size" and no edge to run
past: draw and place devices anywhere, pan at any zoom, zoom out to see
everything, and let one tap fit the whole plan back on screen.
- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
virtual walls and a visual decor layer, all drawn with clicks; room resize
by dragging walls, with live lengths and areas as you drag; smart
alignment guides and a live ruler in real meters/feet.
- 🖼 **A backdrop you can move and scale** — drag the floor-plan picture into
place and pull a corner to size it, with its real size in metres shown as
you drag, so the drawing and the photo of your plan finally line up.
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
whole groups of lights (works for dumb switches and stateless remotes too).
- 🌒 **“Light sources” fill** — a dark house where every lit lamp lights exactly
the floor it can see: through doorways and open boundaries, stopped by walls,
columns and partitions, which cast real shadows.
- ☀️ **The sun on the plan** — set the compass and the backdrop lives with
the day (white noon → golden hour → deep night), while windows on exterior
walls cast real wedges of sunlight into the rooms; optional cloud cover
from a weather entity.
- 🪟 **Curtains and blinds open on a tap** — one action opens, closes or
stops a cover, and the icon itself morphs between open and closed while a
soft ring pulses as it travels.
- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
comfort-range temperature fills, per-room signal heatmap.
- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
explicit button, never an accidental tap.
- 📺 **Kiosk mode** for wall tablets and TVs: fullscreen, swipe between floors,
auto-carousel, per-screen icon sizes.
- 🤖 **Live robot vacuums** — the dock marker stays put while a round puck
drives the plan in real time, pouring its path out from under itself;
current and previous cleanup runs are recorded server-side. Calibration is
one click (rooms matched by name) or a drag-and-stretch overlay. A diagnostic
source picker also covers registry-less map cameras without silently
rebinding broken sources. Works with Xiaomi Cloud Map Extractor, Tasshack
dreame-vacuum and Valetudo.
- 🔔 New devices appear automatically with a red “new” dot; the layout is stored
**server-side** — one shared plan for every user and screen, synced live.
> **Edit on a desktop computer.** View and kiosk are fully supported on phones
> and tablets. The editors are designed primarily for a mouse and keyboard;
> individual touch editing operations may be awkward or unavailable. See the
> exact [touch support contract](docs/TOUCH-SUPPORT.md).
---
<!-- docs-section: features -->
## What it is and why
## What House Plan provides
House Plan shows your smart home the way it actually looks — on a floor plan. Instead of long lists of entities, you see rooms and devices in their real places: where the leak is, what the temperature is in the kids' room, whether the light is on in the hallway, whether the gate is open.
- **Live state and safe actions.** Lights and other safe devices can toggle from
the plan; a lock cannot be opened by an accidental plan tap.
- **Three built-in editors.** Plan creates rooms, walls and openings; Device
places and configures markers; Background adds lines, labels and furniture.
- **Area-aware rooms.** New devices appear automatically, while room cards can
show temperature, humidity, light state and average LQI.
- **Light and environment.** Room fills, lamp Glow, wall shadows, a day-cycle
backdrop and sunlight through windows.
- **Doors, windows, gates and vacuums.** Openings follow real contacts and locks;
a robot can show its position, dock and travelled path.
- **Several floors and screens.** Space tabs, swipe navigation, local viewport,
and a separate initial floor for each card.
- **Wall-display kiosk.** A plan-only view with fullscreen navigation and icon
sizes saved for that display.
This is convenient when:
![The same synthetic home in touch View mode](docs/images/02-view-touch.png)
- you have many devices and lists are awkward to use;
- you need to grasp the state of the house "at a glance";
- you want to give access to family members — anyone can figure out a picture;
- you want a beautiful overview screen for a wall-mounted tablet.
<!-- docs-section: first-run -->
The integration consists of two parts that are installed together:
## Your first working room
- **the Lovelace card** `houseplan-card` — the interactive plan itself;
- **the server-side component** — stores the room markup and icon positions in Home Assistant, so the plan is identical in all browsers and on all devices.
1. Install the integration and add the card to a dashboard.
2. Create the first **space**: upload SVG/PNG/JPG/WebP, reuse an uploaded image,
or choose no image and draw the plan by hand.
3. In Plan, select **Room outline**, place vertices, and click the first point to
close the outline.
4. Name the room and bind it to a Home Assistant area. Use “No area” for a room
that has no devices.
5. Open Device: devices from the bound area are already placed; drag their
markers to the correct positions.
6. Optionally use Background for lines, text and furniture.
7. Return to View. The plan now displays live state and accepts safe actions.
---
![Creating the first space](docs/images/03-space-create.png)
## How it differs from alternatives
![Closing a room outline on its first point](docs/images/04-room-contour-close.png)
A house plan in Home Assistant is usually built with `picture-elements`,
`ha-floorplan`, or newer GUI cards that draw walls and furniture in the
dashboard. Those either lock you into YAML/SVG, or store the plan in the
Lovelace card config. House Plan is a **shared live map** backed by a Home
Assistant integration:
![A selected partition and the Plan context tray](docs/images/05-plan-context-tray.png)
| | House Plan | picture-elements / ha-floorplan | GUI draw cards (e.g. easy-floorplan) |
|---|---|---|---|
| **Setup** | Entirely through the UI, with the mouse | Manual YAML / Inkscape SVG | In-card drawing of walls & furniture |
| **Adding devices** | Automatic, by HA **area** | You type every entity by hand | Place entities by hand on the drawing |
| **Icon coordinates** | Drag with the mouse | Count pixels into YAML | Drag on the canvas |
| **Room markup** | Built-in outline editor bound to areas | External SVG editor | Draw walls yourself (furniture CAD) |
| **Storage** | On the HA server (`.storage`, shared, multi-client) | In the dashboard YAML | In the card / dashboard YAML |
| **Overlays** | Glow, climate, LQI, sun, vacuums, kiosk | Whatever you script in SVG/CSS | Varies by card |
| **Zoom** | Smooth vector zoom | Usually a fixed image | SVG / virtual canvas |
![Device settings with binding provenance and the exact action result](docs/images/06-device-editor.png)
**One sentence:** House Plan is the shared, area-aware live map of your home —
not a general-purpose CAD package. Its Background editor covers practical
decor, labels and furniture; if you need unrestricted architectural drafting,
a draw-centric tool may fit better. With a plan and HA areas, House Plan keeps
every tablet on the same live layout.
![Live presentation preview for the same device](docs/images/06-device-display-preview.png)
Key advantages in short:
Every workflow and edge case is in the [full user guide](docs/USER-GUIDE.md).
The [Background editor contract](docs/DECOR-EDITOR.md) and
[vacuum guide](docs/VACUUM.md) are the authorities for those subsystems.
- **No code at all.** Everything — spaces, rooms, devices — is configured with clicks.
- **Automatic device placement.** Outline a room and bind it to a Home Assistant area — the devices of that area appear on the plan by themselves.
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
a light is shining, a socket is powering, a fan is spinning, a vacuum is
cleaning, a radiator valve is actually heating (not merely enabled). For climate integrations,
a reported work action is authoritative; when an integration exposes only its enabled HVAC mode,
that mode is the best available fallback. Orange = open / unlocked.
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
A translucent icon = unavailable. Dark = idle.
- **A coherent visual Background editor.** Draw lines/shapes, place labels and
furniture, edit physical styles and transform every object with the same
selection model. The plan image has its own move/resize/rotate tool, numeric
properties and shared Undo/Redo.
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
---
## Wall tablet / TV (kiosk mode)
Add the card to a dedicated dashboard with a **panel view** and set `kiosk: true`
(or tick "Wall device (kiosk) mode" in the card editor):
```yaml
type: custom:houseplan-card
kiosk: true
cycle: 0 # seconds between auto space switches, 0 = off (nice for TVs)
```
No header, no editors — just the live plan. Swipe to change floors (at 1:1),
pinch to zoom, double-tap to reset. Long-press an empty spot for 3 seconds to
tune icon and text sizes for THIS screen (saved per device). To hide Home
Assistant's own header use the companion app's kiosk settings or the
[kiosk-mode](https://github.com/NemesisRE/kiosk-mode) plugin.
<!-- docs-section: installation -->
## Installation
One click if you already run HACS:
### HACS
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
[![Open the repository in HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
1. In HACS open **⋮ → Custom repositories**.
2. Add `https://github.com/Matysh/houseplan-card` as an **Integration**.
3. Install House Plan and restart Home Assistant.
4. Open **Settings → Devices & services → Add integration → House Plan**.
### Via HACS (recommended)
The card is registered automatically. If you manage Lovelace resources
manually, use the URL served by the integration:
1. Open **HACS → menu (⋮) → Custom repositories**.
2. Paste the URL of this repository, set the category to **Integration**, and click **Add**.
3. Find **House Plan** in the list, install it and **restart Home Assistant**.
4. Go to **Settings → Devices & Services → Add integration** and select **House Plan**.
```yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
```
The card is registered automatically — no need to add a Lovelace resource manually.
Do not use the on-disk path inside `custom_components`; Home Assistant does not
serve that path as a JavaScript module.
> **Card doesn't load (`Custom element doesn't exist: houseplan-card`) or you manage Lovelace
> resources in YAML?** Add the resource manually pointing at the URL the integration *serves*:
>
> ```yaml
> resources:
> - url: /houseplan_files/houseplan-card.js
> type: module
> ```
>
> Do **not** use `/custom_components/houseplan/frontend/houseplan-card.js` — that is the file
> on disk, which Home Assistant does not serve over HTTP (you'll get a `text/plain` MIME error
> and the element never registers). The correct, integration-served URL is
> `/houseplan_files/houseplan-card.js`. Both cards (`houseplan-card` and
> `houseplan-space-card`) ship in that one file — no separate resource is needed.
### Manual installation
### Manually
Copy `custom_components/houseplan` to `config/custom_components`, restart Home
Assistant, and add the House Plan integration.
1. Copy the `custom_components/houseplan` folder into the `config/custom_components` directory of your Home Assistant.
2. Restart Home Assistant.
3. Add the integration: **Settings → Devices & Services → Add integration → House Plan**.
### Add the card
### Adding a plan screen
Create a new dashboard tab (a "Panel" view works best) and add the card:
Create a dashboard view (Panel works best) and add the card in the UI or as:
```yaml
type: custom:houseplan-card
title: House plan
```
Nothing else needs to be specified — everything else is configured right on the screen.
Different screens may start on different spaces:
---
```yaml
type: custom:houseplan-card
default_floor: ground
```
## How to use
All cards share server-side rooms and coordinates. Current mode, viewport and
selected space remain local to the screen. Revision checks and live sync cover
concurrent clients, but avoid editing the same object in two browsers at once.
### Step 1. Add a space (floor)
## Detailed documentation
On first open the plan is still empty — House Plan immediately offers to create the first space.
If your Home Assistant already has **floors** configured, a wizard offers to create a space
for each floor (names prefilled, a plan image is asked for one by one; any floor can be skipped).
- [Full user guide](docs/USER-GUIDE.md)
- [Mouse/touch/keyboard matrix](docs/USER-GUIDE.md#6-navigation-zoom-and-input)
- [Plan tools](docs/USER-GUIDE.md#plan-tools-at-a-glance)
- [Background editor](docs/DECOR-EDITOR.md)
- [Robot vacuums](docs/VACUUM.md)
- [Touch support](docs/TOUCH-SUPPORT.md)
![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
<!-- docs-section: support -->
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions by default and can be moved, resized or rotated at any time in the Background editor.
## Support and feedback
![Space creation dialog](docs/images/03-space-dialog.png)
- Questions and plan examples: [Telegram @ha_houseplan](https://t.me/ha_houseplan).
- Bugs and proposals: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues).
- Before reporting, update House Plan, restart HA and hard-refresh the page.
Include the version, browser, logs and reproduction steps; private entity IDs
may be replaced with fictional ones.
> 💡 You can draw the background in any floor planner (for example, REMPLANNER) or photograph a paper plan. SVG works best — it stays crisp when zoomed in.
Documentation screenshots are produced by the reproducible
`npm run build && node demo/docs/capture.mjs` command using synthetic data only. Scenario version,
source fingerprint and every image hash are recorded in the
[screenshot index](docs/images/screenshots.json).
Later you can add as many spaces as you like (floors, yard, garage) with the **+** button next to the tabs.
### Step 2. Outline the rooms
After the first space is added, the card switches to the **Plan** tab by itself. The card has three mode tabs in the header — **View** (default: display and device control only, nothing can be moved or edited), **Plan** (rooms, openings, labels, space settings) and **Devices** (placing and configuring markers); the edit tabs are shown to administrators. In Plan, click grid points, connecting them with lines, and close the room outline by clicking the first point.
As soon as the outline is closed, the room-save dialog appears. Here you need to **bind the room to a Home Assistant area** — this is exactly what enables the automation. For utility rooms with no devices (hall, sauna) there is a **"No area"** button.
![Marking up a room and saving it](docs/images/05-room-dialog.png)
While drawing, a ruler follows the cursor showing the current segment's real length (metres, or feet + inches on an imperial Home Assistant). The scale is set per space — the **"Scale (grid cell size)"** field in the space dialog says how many centimetres one grid cell represents (default 5 cm).
Rooms may not overlap: a click strictly inside an existing room, or an outline that would swallow one, is refused. Two more tools help you reshape the plan later:
- **Merge** — click a room, then a neighbour that shares a wall; they fuse into one. A dialog picks which name and area survive.
- **Split** — click a room, then two points on its walls; the chord cuts it in two. The bigger part stays the room it was (name, area, devices); the smaller one asks for a new name and area.
### Doors, windows, gates and locks
In markup mode the **"Opening"** tool places doors, windows and gates: click next to a wall and the
opening snaps onto it. Pick the type, the **length in real centimetres** (defaults: door 90 cm,
window 120 cm, gate 300 cm), an open/close sensor and — for doors and gates — a **lock entity**.
With a sensor bound, the plan comes alive: the door leaf swings on its hinge and the swing arc
draws itself in as the real door opens; a window opens its two casements. While open, the moving
parts take an accent colour. A gate keeps a 3–4 m opening compact on the plan: two half-width
leaves open only 10° outwards, without a full-width swing arc, while contact, lock and light
passage work exactly like a door. A door or gate with a lock shows a padlock badge next to it — green when
locked, orange when unlocked. For safety the lock can **not** be toggled from the plan; a click
on the opening shows a status card with both states instead.
Openings are easy to adjust later: hovering one highlights it, you can **drag it along the
walls** (it slides around corners too), and a **double click opens its properties**.
### Step 3. Devices appear by themselves
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Hidden and disabled"**: user-hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light. A device disabled in Home Assistant appears there as a labelled grey service ghost and is excluded from all plan data/actions until it is enabled in HA again.
From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
![Device card on click](docs/images/08-info.png)
### Step 4. Zoom
The mouse wheel or the **- / ⊹ / +** buttons zoom the plan in and out; on a touch screen the two-finger pinch works. Zoomed out you see the whole plan, zoomed in you see the details, and everything stays crisp. The zoom level is remembered separately for each space.
![Zoomed-in plan — everything stays crisp](docs/images/09-zoom.png)
### Step 5. Put the icons in their places
Switch to the **Devices** tab to arrange icons: drag them with the mouse, click one to open its editor. In **View** mode nothing can be moved — panning the map never displaces a sensor (a top user request). Positions are saved on the server and are identical in all browsers and devices. The **↺** button restores the automatic layout.
![Dragging icons — available at all times](docs/images/06-edit.png)
### Tap actions: control devices from the plan
By default a tap on an icon opens its info card. A device can instead use the
universal **Toggle state** action. Its editor shows the exact entity or configured
group, the current state and what the next tap will do; when nothing can be toggled,
it says so and the tap is a quiet no-op rather than an unexpected info-card fallback.
Lights keep their convenient toggle default. Covers and valves use open/close/stop
semantics automatically, while locks, alarm panels and secure garage/door/gate covers
remain blocked. An exact entity binding never falls through to a sibling switch, and
temporarily unavailable group members are skipped without erasing the configuration.
A **long press** still opens the info card and right-click still opens HA more-info.
### Icon rules
Which MDI icon a device gets is decided by **icon rules** — editable right in the card
(the ⬡ button in the header): an ordered list of “name pattern → icon” regexes with a
live test field, bilingual defaults (EN/RU) and a one-click reset. When no rule
matches, the entity *device class* decides (thermometer for temperature sensors, etc.).
### Step 6. Adding your own devices manually
You can also place a **single entity** (not just a whole device): start typing in the binding search and individual entities appear next to devices — handy when one device exposes several values (e.g. temperature and humidity) and you want each as its own icon.
Not everything has to be left to the automation. With the **+** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
To represent a dumb physical lamp controlled by a smart relay, place a virtual
point where the lamp really is and set **Light source → Always**. Manual colour,
brightness and radius stay available even though the point has no HA entity.
Then open the relay and add that plan source under **Controls other light
sources**. The relay continues to show the aggregate working state, while Glow,
room fill and statistics belong to the lamp's position. An unlinked passive
Always source is deliberately constant-on. With several own `light.*`/`switch.*`
entities, Always also offers a leading-entity selector; a missing saved choice
is warned about and retained while a deterministic fallback is used.
To make that virtual lamp manually switchable without creating a Home
Assistant helper, also choose **Tap action → Toggle state** on the lamp itself.
This exact combination — virtual binding, **Light source → Always**, and
**Toggle state** — stores a shared on/off state in the House Plan integration.
It survives page reloads and Home Assistant restarts and updates Glow, room
fill/statistics, full cards and `houseplan-space-card` together. Any signed-in
dashboard viewer may toggle it. While this manual mode is active, saved
**Controls other light sources** remain intact but are not called; changing the
role, binding or tap action restores their normal behaviour. This operational
state is deliberately not part of plan exports or Home Assistant entities.
The same dialog controls how the device looks on the plan. **Display** switches between the
icon badge, an animated **presence ripple** (pulsing rings while the entity is active, a faint
dot when idle — great for motion sensors) or both, with a per-device ring colour and size. The
**icon size** (×0.5–3) and **rotation** are also per-device, so a wall valve can be small and
turned the way it is mounted.
![Adding a device manually](docs/images/07-marker-dialog.png)
### Styling the plan with card-mod (advanced, unsupported)
The card ships finished and has no CSS field of its own — but if you already run [card-mod](https://github.com/thomasloven/lovelace-card-mod), every object on the plan now carries a stable hook you can aim at: `data-hp="device"` (plus `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. We promise not to rename them; we do not ship card-mod, do not support it, and are not responsible for what your CSS does to the card. The full table, the examples and the limits are in **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
---
## Uninstalling
1. Remove the card (or the tab with the plan) from the dashboard.
2. **Settings → Devices & Services → House Plan → Delete** the integration entry.
3. Remove the integration from **HACS** (or delete the `custom_components/houseplan` folder if installed manually) and restart Home Assistant.
4. Optionally delete the saved plan data: the `config/houseplan/` files (backgrounds and attachments) and the `houseplan.config` / `houseplan.layout` entries in the `config/.storage` directory.
---
## Getting help & sharing your plan
- 💬 **[Telegram chat — @ha_houseplan](https://t.me/ha_houseplan)** — questions,
setup help, feature ideas, and screenshots of your plans. The fastest way to
reach the author and other users.
- 🐞 [GitHub issues](https://github.com/Matysh/houseplan-card/issues) — bug
reports and feature requests (please attach your House Plan version).
- 💡 [GitHub discussions](https://github.com/Matysh/houseplan-card/discussions) —
longer-form ideas.
- 📜 [Changelog](docs/CHANGELOG.md) — what changed in every version
([на русском](docs/CHANGELOG.ru.md)).
When reporting a problem, the version number helps a lot: it is shown in the
browser console on load (`HOUSEPLAN-CARD vX.Y.Z`) and in **Settings → Devices &
Services → House Plan**.
---
## Frequently asked questions
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. Open the device editor and press **"Hidden and disabled"**: a blue ghost is user-hidden and can be shown; a grey disabled ghost must first be enabled in Home Assistant.
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
**Is the data stored in the cloud?** No. Everything is stored locally in your Home Assistant.
---
<p align="center"><sub>Screenshots were taken on a real Home Assistant configuration.</sub></p>
License: [MIT](LICENSE).
+107 -297
View File
@@ -1,351 +1,161 @@
# 🏠 House Plan — интерактивный поэтажный план дома для Home Assistant
# 🏠 House Plan — живой план дома для Home Assistant
[![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases)
[![GitHub stars](https://img.shields.io/github/stars/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/stargazers)
[![CI](https://github.com/Matysh/houseplan-card/actions/workflows/validate.yml/badge.svg)](https://github.com/Matysh/houseplan-card/actions)
[![Live demo](https://img.shields.io/badge/демо-попробовать-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
[![Telegram chat](https://img.shields.io/badge/Telegram-чат-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · 🗂 **[Беклог проекта](https://github.com/users/Matysh/projects/1)**
📘 **[Полное руководство](docs/USER-GUIDE.ru.md)** · 🇬🇧 **[English](README.md)** · 🗂 **[Задачи проекта](https://github.com/Matysh/houseplan-card/issues)**
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
своих местах: живые состояния, свет по клику, температура и влажность по
комнатам, карта Zigbee-сигнала, светящиеся пятна ламп и полноэкранный
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
редакторов — весь план настраивается прямо на дашборде.
<!-- docs-section: overview -->
> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
> операции могут быть менее удобны, работать ограниченно или отсутствовать.
## Дом целиком — одним взглядом
![Интерактивный план дома для Home Assistant: комнаты, устройства, свет и климат на реальном поэтажном плане](docs/images/demo.gif)
House Plan превращает Home Assistant в живую карту дома. Загрузите изображение
плана или нарисуйте комнаты прямо на дашборде, свяжите их с зонами Home
Assistant — и устройства появятся на плане автоматически. Сразу видно, где
горит свет, открыта дверь, слишком холодно, слабый Zigbee-сигнал или сработал
датчик протечки.
> ### 🚀 Попробовать вживую — без установки
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — настоящий Home
> Assistant с готовым планом. Вход **`demo`** / **`demo`**, можно нажимать всё:
> включать свет, открывать редакторы, ломать что угодно. Каждый час стенд сам
> возвращается в исходное состояние.
![Синтетический дом в режиме просмотра: комнаты, устройства, свет и климат](docs/images/01-view-desktop.png)
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
Настройка выполняется в графическом интерфейсе: без YAML-разметки, Inkscape и
внешнего редактора плана. Данные плана и расположение устройств хранятся на
сервере Home Assistant и синхронизируются между экранами.
**Главное**
> **Редактируйте на компьютере.** Режим просмотра и киоск полноценно работают
> на телефонах и планшетах. Редакторы рассчитаны прежде всего на мышь и
> клавиатуру; на touch отдельные операции могут быть неудобны или недоступны.
> Подробный контракт: [поддержка touch](docs/TOUCH-SUPPORT.md).
- ♾️ **Бесконечный холст** — нет «размера плана» и нет края, за который
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
план обратно в экран.
- 🖱 **Редакторы прямо в карточке** — комнаты, двери, окна и ворота, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
линейка в реальных метрах.
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
освещает ровно тот пол, который видит: через проёмы и открытые границы,
а стены, колонны и перегородки его не пропускают и дают настоящие тени.
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
в комнаты настоящие клинья солнечного света; облачность — опционально, от
weather-сущности.
- 🪟 **Шторы открываются тапом** — одно действие открывает, закрывает или
останавливает штору, а сам значок морфится между открытым и закрытым
видом и мягко пульсирует кольцом, пока штора едет.
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
температурная заливка по комфортным границам.
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
никогда случайным тапом.
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
этажами, автокарусель, свои размеры на каждом экране.
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
(по именам комнат) или перетаскиванием призрака карты. Диагностика и явный
выбор источника поддерживают registry-less камеры карт и не подменяют молча
сломавшуюся привязку. Работают Xiaomi Cloud Map Extractor, dreame-vacuum
(Tasshack) и Valetudo.
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
<!-- docs-section: features -->
---
## Что умеет House Plan
- **Живые состояния и безопасные действия.** Свет и другие безопасные устройства
переключаются с плана; замок нельзя открыть случайным нажатием.
- **Три встроенных редактора.** «План» создаёт комнаты, стены и проёмы;
«Устройства» размещает и настраивает маркеры; «Подложка» добавляет линии,
подписи и мебель.
- **Комнаты, связанные с зонами HA.** Новые устройства появляются автоматически,
а карточки комнат показывают температуру, влажность, свет и средний LQI.
- **Свет и окружение.** Заливки комнат, Glow от ламп, тени от стен, дневной фон и
солнечные лучи из окон.
- **Двери, окна, ворота и пылесосы.** Проёмы отражают реальные датчики и замки;
робот показывает позицию, базу и пройденный путь.
- **Несколько этажей и экранов.** Вкладки пространств, жесты переключения,
локальный масштаб и отдельный стартовый этаж для каждой карточки.
- **Киоск для настенного экрана.** Только план, полноэкранная навигация и размеры
значков, сохранённые отдельно для этого устройства.
## Что это и зачем
![Тот же синтетический дом в touch-режиме просмотра](docs/images/02-view-touch.png)
House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота.
<!-- docs-section: first-run -->
Это удобно, когда:
## Первая рабочая комната
- устройств много, и списками пользоваться неудобно;
- нужно быстро понять состояние дома «одним взглядом»;
- хочется отдать доступ близким — по картинке разберётся любой;
- вы хотите красивый обзорный экран для настенного планшета.
1. Установите интеграцию и добавьте карточку на дашборд.
2. Создайте первое **пространство**: загрузите SVG/PNG/JPG/WebP либо выберите
вариант без изображения, чтобы нарисовать план вручную.
3. В редакторе «План» выберите **Контур комнаты**, поставьте вершины и замкните
контур нажатием на первую точку.
4. Назовите комнату и свяжите её с зоной Home Assistant. Для помещения без
устройств выберите «Без зоны».
5. Откройте «Устройства»: устройства связанной зоны уже размещены автоматически;
перетащите маркеры в нужные места.
6. При необходимости оформите подложку линиями, текстом и мебелью.
7. Вернитесь в «Просмотр» — теперь план показывает живые состояния и принимает
безопасные действия.
Интеграция состоит из двух частей, которые ставятся вместе:
![Создание первого пространства](docs/images/03-space-create.png)
- **карточка Lovelace** `houseplan-card` — сам интерактивный план;
- **серверный компонент** — хранит разметку комнат и позиции иконок в Home Assistant, поэтому план одинаков во всех браузерах и на всех устройствах.
![Замыкание контура комнаты на первой точке](docs/images/04-room-contour-close.png)
---
![Выбранная перегородка и контекстная панель редактора плана](docs/images/05-plan-context-tray.png)
## Чем отличается от аналогов
![Настройка устройства с источником привязки и точным результатом действия](docs/images/06-device-editor.png)
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan`
или новые GUI-карточки, где стены и мебель рисуют прямо на дашборде. Там либо
YAML/SVG, либо конфиг живёт в YAML карточки. House Plan — это **общий живой
план** на серверной интеграции Home Assistant:
![Живой предпросмотр отображения того же устройства](docs/images/06-device-display-preview.png)
| | House Plan | picture-elements / ha-floorplan | GUI-рисовалки (напр. easy-floorplan) |
|---|---|---|---|
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML / Inkscape SVG | Рисование стен и мебели в карточке |
| **Добавление устройств** | Автоматически по **зоне** HA | Каждую сущность вписываете руками | Ставите сущности руками на чертёж |
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели в YAML | Drag на холсте |
| **Разметка комнат** | Встроенный редактор контуров, привязка к зонам | Сторонний SVG-редактор | Сами рисуете стены (мебельный CAD) |
| **Хранение** | На сервере HA (`.storage`, общее, multi-client) | В YAML дашборда | В карточке / YAML дашборда |
| **Оверлеи** | Glow, климат, LQI, солнце, пылесосы, киоск | Что пропишете в SVG/CSS | Зависит от карточки |
| **Масштаб** | Плавный векторный зум | Обычно фиксированная картинка | SVG / виртуальный холст |
Пошаговые сценарии, все инструменты и особые случаи описаны в
[полном руководстве](docs/USER-GUIDE.ru.md). Возможности подложки отдельно
зафиксированы в [документе редактора](docs/DECOR-EDITOR.md), а роботов — в
[руководстве по пылесосам](docs/VACUUM.md).
**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не
универсальная CAD-система. Редактор подложки покрывает практический декор,
надписи и мебель; для свободного архитектурного черчения лучше отдельный
draw-инструмент. При наличии плана и зон HA House Plan держит один живой layout
на всех планшетах.
Ключевые преимущества коротко:
- **Никакого кода.** Всё — пространства, комнаты, устройства — настраивается кликами.
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно;
если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением.
Оранжевый = открыто / не заперто. Пульсирующее красное
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
---
## Настенный планшет / ТВ (киоск-режим)
Отдельный дашборд с view типа «панель», у карточки — `kiosk: true` (или
галочка «Режим настенного устройства» в редакторе карточки):
```yaml
type: custom:houseplan-card
kiosk: true
cycle: 0 # автосмена пространств каждые N секунд, 0 = выкл (удобно для ТВ)
```
Без шапки и редакторов — только живой план. Свайп листает этажи (при 1:1),
пинч — зум, двойной тап — сброс. Долгое нажатие (3 с) по пустому месту —
настройка размеров значков и текста для ЭТОГО экрана (хранится на
устройстве). Шапку самого Home Assistant скрывают настройки companion-app
или плагин [kiosk-mode](https://github.com/NemesisRE/kiosk-mode).
<!-- docs-section: installation -->
## Установка
В один клик, если у вас уже есть HACS:
### Через HACS
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
[![Открыть репозиторий в HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
1. В HACS откройте **⋮ → Пользовательские репозитории**.
2. Добавьте `https://github.com/Matysh/houseplan-card` с типом **Интеграция**.
3. Установите House Plan и перезапустите Home Assistant.
4. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
### Через HACS (рекомендуется)
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
добавьте именно URL, который публикует интеграция:
1. Откройте **HACS → меню (⋮) → Custom repositories**.
2. Вставьте URL этого репозитория, категория — **Integration**, и нажмите **Add**.
3. Найдите в списке **House Plan**, установите и **перезапустите Home Assistant**.
4. Перейдите в **Настройки → Устройства и службы → Добавить интеграцию** и выберите **House Plan**.
```yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
```
Карточка подключается автоматически — добавлять ресурс Lovelace вручную не нужно.
> **Карточка не грузится (`Custom element doesn't exist: houseplan-card`) или вы ведёте ресурсы
> Lovelace в YAML?** Добавьте ресурс вручную, указав URL, который *раздаёт сама интеграция*:
>
> ```yaml
> resources:
> - url: /houseplan_files/houseplan-card.js
> type: module
> ```
>
> **Не** используйте `/custom_components/houseplan/frontend/houseplan-card.js` — это путь к файлу
> на диске, который Home Assistant не отдаёт по HTTP (получите ошибку MIME `text/plain`, и элемент
> не зарегистрируется). Правильный URL, который раздаёт интеграция, — `/houseplan_files/houseplan-card.js`.
> Обе карточки (`houseplan-card` и `houseplan-space-card`) лежат в этом одном файле — отдельный ресурс
> не нужен.
Не используйте путь к файлу внутри `custom_components`: Home Assistant не
публикует его как JavaScript-модуль.
### Вручную
1. Скопируйте папку `custom_components/houseplan` в каталог `config/custom_components` вашего Home Assistant.
2. Перезапустите Home Assistant.
3. Добавьте интеграцию: **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
Скопируйте `custom_components/houseplan` в `config/custom_components`,
перезапустите Home Assistant и добавьте интеграцию House Plan.
### Добавление экрана с планом
### Добавление карточки
Создайте новую вкладку дашборда (удобнее всего — в режиме «Панель»/Panel) и добавьте карточку:
Создайте представление дашборда (лучше Panel) и добавьте карточку через UI либо:
```yaml
type: custom:houseplan-card
title: План дома
```
Больше ничего указывать не нужно — всё остальное настраивается прямо на экране.
Для нескольких экранов можно задать разные стартовые пространства:
---
```yaml
type: custom:houseplan-card
default_floor: ground
```
## Как пользоваться
Все карточки используют общие серверные комнаты и координаты. Текущий режим,
масштаб и выбранное пространство локальны для экрана. Одновременное
редактирование поддерживает синхронизацию и проверку ревизий, но один объект
лучше не менять параллельно в двух браузерах.
### Шаг 1. Добавьте пространство (этаж)
## Где искать подробности
При первом открытии план ещё пуст — House Plan сразу предложит создать первое пространство.
- [Полное руководство пользователя](docs/USER-GUIDE.ru.md)
- [Матрица mouse/touch/keyboard](docs/USER-GUIDE.ru.md#6-навигация-масштаб-и-жесты)
- [Инструменты плана](docs/USER-GUIDE.ru.md#инструменты-плана-в-короткой-таблице)
- [Редактор подложки](docs/DECOR-EDITOR.md)
- [Роботы-пылесосы](docs/VACUUM.md)
- [Поддержка touch](docs/TOUCH-SUPPORT.md)
Если в вашем Home Assistant уже настроены **этажи**, мастер предложит создать
пространство для каждого: названия подставятся сами, план попросит по очереди,
любой этаж можно пропустить.
<!-- docs-section: support -->
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png)
## Помощь и обратная связь
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка по умолчанию сохраняет пропорции, а подвинуть, изменить размер или повернуть её можно в любой момент в редакторе подложки.
- Вопросы и примеры планов: [Telegram @ha_houseplan](https://t.me/ha_houseplan).
- Баги и предложения: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues).
- Перед отчётом обновите House Plan, перезапустите HA и выполните жёсткое
обновление страницы (`Ctrl+F5`). Приложите версию, браузер, логи и шаги
воспроизведения; приватные entity ID можно заменить вымышленными.
![Диалог создания пространства](docs/images/03-space-dialog.png)
Скриншоты в документации получены воспроизводимой командой
`npm run build && node demo/docs/capture.mjs` только на синтетических данных. Версия сценариев,
fingerprint исходников и хеш каждого изображения находятся в
[индексе снимков](docs/images/screenshots.json).
> 💡 Подложку можно нарисовать в любом планировщике (например, РЕМПЛАННЕР) или сфотографировать бумажный план. Лучше всего SVG — он остаётся чётким при увеличении.
Позже можно добавить сколько угодно пространств (этажи, двор, гараж) кнопкой **+** рядом со вкладками.
### Шаг 2. Обведите комнаты
После добавления первого пространства карточка сама переходит в режим разметки. Кликайте по точкам сетки, соединяя их линиями, и замкните контур комнаты кликом по первой точке.
Как только контур замкнётся, появится окно сохранения комнаты. Здесь нужно **привязать комнату к зоне Home Assistant** — именно это включает автоматику. Для служебных помещений без устройств (холл, сауна) есть кнопка **«Без зоны»**.
![Разметка комнаты и её сохранение](docs/images/05-room-dialog.png)
Во время рисования у курсора показывается линейка с реальной длиной текущего отрезка (метры или футы+дюймы на имперской системе HA). Масштаб задаётся для каждого пространства — поле **«Масштаб (размер ячейки сетки)»** в диалоге пространства: сколько сантиметров в одной ячейке (по умолчанию 5 см).
Комнаты не могут пересекаться: клик строго внутри существующей комнаты или контур, охватывающий её, отклоняются. Ещё два инструмента помогают перекроить план позже:
- **Объединить** — кликните комнату, затем соседнюю с общей стеной; они сольются в одну. Диалог выбирает, чьё имя и зона останутся.
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
### Двери, окна, ворота и замки
В режиме разметки инструмент **«Проём»** ставит двери, окна и ворота: кликните рядом со стеной — проём
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
окно 120 см, ворота 300 см), датчик открытия и — для двери или ворот — **замок**.
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
подвижные части подсвечены акцентным цветом. Ворота не занимают полплана даже при ширине 3–4 м: две половинные створки показаны открытыми наружу всего на 10°, без большой дуги. Датчик, замок и пропуск света работают как у двери. У двери или ворот с замком рядом отображается замочек —
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
переключить — клик по проёму показывает карточку с обоими статусами.
Проёмы легко поправить позже: при наведении проём подсвечивается, его можно **перетащить вдоль
стен** (в том числе за угол), а **двойной клик открывает свойства**.
### Шаг 3. Устройства появляются сами
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти их, откройте редактор устройств и нажмите **«Скрытые и деактивированные»**: пользовательски скрытые устройства отображаются синими призраками. Деактивированное в HA устройство показывается серым служебным призраком и полностью исключается из данных и действий плана до повторной активации.
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
![Карточка устройства по клику](docs/images/08-info.png)
### Шаг 4. Масштаб
Колесо мыши или кнопки **- / ⊹ / +** приближают и отдаляют план; на сенсорном экране работает «щипок» двумя пальцами. При отдалении виден весь план целиком, при приближении — детали, и всё остаётся чётким. Масштаб запоминается отдельно для каждого пространства.
![Приближённый план — всё остаётся чётким](docs/images/09-zoom.png)
### Шаг 5. Расставьте значки по местам
Расставлять значки нужно на вкладке **«Устройства»**: там они перетаскиваются мышью, а клик открывает редактор. В режиме **«Просмотр»** ничего сдвинуть нельзя — панорамирование карты больше не сдвигает датчики (главная просьба пользователей). Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** возвращает автоматическую раскладку.
![Перетаскивание значков — доступно всегда](docs/images/06-edit.png)
### Управление с плана (tap actions)
По умолчанию тап по значку открывает инфо-карточку. В настройках карточки можно
переключить **«Тап по устройству»** на *Переключить* — тогда тап включает/выключает
свет, розетки, вентиляторы и увлажнители прямо с плана (режим настенного планшета).
Для безопасности общий toggle не действует на замки, сигнализации, шторы/ворота и
клапаны; для конкретного устройства toggle можно включить осознанно в его диалоге
(кроме замков и сигнализаций — они с плана не переключаются никогда). **Долгое
нажатие** всегда открывает инфо-карточку.
### Правила иконок
Какая MDI-иконка достанется устройству, решают **правила иконок** — редактируются
прямо в карточке (кнопка ⬡ в шапке): упорядоченный список «шаблон имени → иконка»
с живым тест-полем, двуязычные умолчания (EN/RU) и сброс одной кнопкой. Если ни одно
правило не подошло — решает *device class* сущности (термометр для датчиков
температуры и т.п.).
### Шаг 6. Добавление своих устройств вручную
Можно поставить и **отдельную сущность** (не только устройство целиком): начните печатать в поиске привязки — рядом с устройствами появятся отдельные сущности. Удобно, когда одно устройство отдаёт несколько значений (например, температуру и влажность), а вы хотите каждое своей иконкой.
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
В этом же диалоге настраивается вид устройства на плане. **Отображение** переключает значок,
анимированную **пульсацию присутствия** (расходящиеся кольца, пока сущность активна, и тусклая
точка в покое — идеально для датчиков движения) или то и другое сразу, с цветом и размером колец
на устройство. **Размер значка** (×0,5–3) и **поворот** — тоже индивидуальные: вентиль на стене
может быть маленьким и повёрнутым так, как он установлен.
![Добавление устройства вручную](docs/images/07-marker-dialog.png)
### Свои стили через card-mod (для продвинутых, без поддержки)
Карточка приезжает готовой, и поля для CSS у неё нет — но если у вас уже стоит [card-mod](https://github.com/thomasloven/lovelace-card-mod), у каждого объекта плана теперь есть стабильный «крючок», за который можно зацепиться: `data-hp="device"` (плюс `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. Мы обещаем их не переименовывать; сам card-mod мы не поставляем, не поддерживаем и за то, что ваш CSS сделает с карточкой, не отвечаем. Полная таблица, примеры и ограничения — в **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
---
## Удаление
1. Уберите карточку (или вкладку с планом) из дашборда.
2. **Настройки → Устройства и службы → House Plan → Удалить** запись интеграции.
3. Удалите интеграцию из **HACS** (или папку `custom_components/houseplan` при ручной установке) и перезапустите Home Assistant.
4. При желании удалите сохранённые данные плана: файлы `config/houseplan/` (подложки и вложения) и записи `houseplan.config` / `houseplan.layout` в каталоге `config/.storage`.
---
## Помощь и обмен опытом
- 💬 **[Чат в Telegram — @ha_houseplan](https://t.me/ha_houseplan)** — вопросы,
помощь с настройкой, идеи и скриншоты ваших планов. Самый быстрый способ
связаться с автором и другими пользователями.
- 🐞 [Issues на GitHub](https://github.com/Matysh/houseplan-card/issues) — баги
и запросы фич (пожалуйста, указывайте версию House Plan).
- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для
развёрнутых обсуждений.
- 📜 [История изменений](docs/CHANGELOG.ru.md) — что менялось в каждой версии.
Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в
**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно
быстрее.
---
## Часто задаваемые вопросы
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Откройте **«Скрытые и деактивированные»**: синий призрак можно показать в его диалоге, серый сначала нужно активировать в Home Assistant.
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
**Данные хранятся в облаке?** Нет. Всё хранится локально в вашем Home Assistant.
---
<p align="center"><sub>Скриншоты сделаны на реальной конфигурации Home Assistant.</sub></p>
Лицензия: [MIT](LICENSE).
+3 -3
View File
@@ -5,7 +5,7 @@ STORAGE_KEY = f"{DOMAIN}.layout"
STORAGE_CONFIG_KEY = f"{DOMAIN}.config"
STORAGE_VIRTUAL_LIGHTS_KEY = f"{DOMAIN}.virtual_lights"
STORAGE_VERSION = 1
STORAGE_MINOR_VERSION = 1
STORAGE_MINOR_VERSION = 2
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
PLANS_URL = "/houseplan_files/plans"
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
@@ -46,7 +46,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.64.0-beta.1"
VERSION = "1.65.0-beta.2"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
@@ -65,5 +65,5 @@ MAX_IMPORT_PREVIEWS_TOTAL = 3
DEFAULT_CONFIG: dict = {
"spaces": [],
"markers": [],
"settings": {},
"settings": {"bg_mode": "daynight"},
}
File diff suppressed because one or more lines are too long
+314 -33
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import copy
import hashlib
import json
import math
import re
import secrets
import time
@@ -47,12 +48,43 @@ from .validation import (
validate_marker_controls,
validate_marker_light_entities,
validate_marker_value_badges,
validate_opening_passages, validate_partition_opening_hosts,
MarkerControlError,
OpeningPassageError,
PartitionOpeningHostError,
)
FORMAT = "houseplan-export"
_PROTO_KEYS = {"__proto__", "prototype", "constructor"}
_SAFE_FILE = re.compile(r"[^A-Za-z0-9._-]+")
_LIVE_TEXT_TOKEN = re.compile(r"\{([^{}\r\n]+)\}")
_LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
_LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$")
_PLAN_ONLY_DASH = "—"
_SPACE_PLAN_FIELDS = (
"id", "title", "cell_cm", "plan_url", "plan_aspect", "plan_x", "plan_y",
"plan_scale", "plan_scale_x", "plan_scale_y", "plan_angle", "view_box",
)
_SPACE_DISPLAY_FIELDS = (
"show_borders", "show_names", "room_color", "bg_color", "room_opacity",
"fill_mode", "custom_fill", "glow_enabled", "temp_min", "temp_max",
"show_lqi", "hide_decor", "hide_openings", "label_temp", "label_hum",
"label_lqi", "label_light", "card_font_scale", "north_deg", "bg_mode",
"sun_rays",
)
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly")
_ROOM_DISPLAY_FIELDS = (
"fill_mode", "custom_fill", "glow", "name_scale", "label_scale",
)
_DECOR_COMMON_FIELDS = ("id", "kind", "color", "opacity", "width_cm", "width")
_DECOR_KIND_FIELDS = {
"line": ("x1", "y1", "x2", "y2", "line_style"),
"rect": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
"ellipse": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
"text": ("x", "y", "text", "size", "size_cm", "scale", "angle"),
"furniture": ("symbol", "x", "y", "w", "h", "angle"),
}
class ImportFailure(Exception):
@@ -73,6 +105,35 @@ def _json_copy(value: Any) -> Any:
return json.loads(json.dumps(value, ensure_ascii=False, allow_nan=False))
def _background_mode(settings: Any) -> str | None:
if not isinstance(settings, dict):
return None
mode = settings.get("bg_mode")
return mode if mode in ("static", "daynight") else None
def _materialize_global_background(config: dict[str, Any], fallback: str = "static") -> str:
"""Make the global mode portable instead of relying on a code default."""
settings = config.get("settings")
if not isinstance(settings, dict):
settings = {}
config["settings"] = settings
mode = _background_mode(settings) or fallback
settings["bg_mode"] = mode
return mode
def _materialize_space_background(space: dict[str, Any], fallback: str) -> str:
"""Make one space independent from the target installation's default."""
settings = space.get("settings")
if not isinstance(settings, dict):
settings = {}
space["settings"] = settings
mode = _background_mode(settings) or fallback
settings["bg_mode"] = mode
return mode
def _stored_model_version(config: dict[str, Any]) -> int:
"""Return the model actually stored without silently upgrading it."""
value = config.get("model_version", 0)
@@ -110,6 +171,136 @@ def live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any
}
def _pick_fields(source: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:
"""Copy only fields explicitly classified as portable plan data."""
return {key: _json_copy(source[key]) for key in fields if key in source}
def _is_live_text_reference(raw: str) -> bool:
"""Mirror ``liveTextReference`` without evaluating Home Assistant state."""
ref = raw.strip()
if not ref:
return False
entity = ref
attribute = ""
colon = ref.find(":")
if colon >= 0:
entity = ref[:colon].strip()
attribute = ref[colon + 1:].strip()
else:
parts = ref.split(".")
if len(parts) > 2:
entity = ".".join(parts[:2])
attribute = ".".join(parts[2:])
if _LIVE_TEXT_ENTITY.fullmatch(entity) is None:
return False
if colon >= 0 and not attribute:
return False
return not attribute or _LIVE_TEXT_ATTRIBUTE.fullmatch(attribute) is not None
def _plan_only_text(value: str) -> str:
"""Freeze every recognized live reference while preserving authored copy."""
replaced = _LIVE_TEXT_TOKEN.sub(
lambda match: _PLAN_ONLY_DASH
if _is_live_text_reference(match.group(1)) else match.group(0),
value,
)
return replaced.replace("{}", _PLAN_ONLY_DASH)
def _project_plan_only_room(room: dict[str, Any]) -> dict[str, Any]:
projected = _pick_fields(room, _ROOM_PLAN_FIELDS)
if "settings" in room:
settings = room.get("settings")
projected["settings"] = (
_pick_fields(settings, _ROOM_DISPLAY_FIELDS)
if isinstance(settings, dict) else None
)
return projected
def _project_plan_only_decor(shape: dict[str, Any]) -> dict[str, Any]:
kind = str(shape.get("kind", ""))
projected = _pick_fields(
shape, _DECOR_COMMON_FIELDS + _DECOR_KIND_FIELDS.get(kind, ()),
)
if kind == "text" and isinstance(projected.get("text"), str):
projected["text"] = _plan_only_text(projected["text"])
return projected
def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
"""Build the fail-closed geometry/presentation projection for #167."""
projected = _pick_fields(space, _SPACE_PLAN_FIELDS)
if "settings" in space:
projected["settings"] = _pick_fields(
space.get("settings") or {}, _SPACE_DISPLAY_FIELDS,
)
projected["rooms"] = [
_project_plan_only_room(room) for room in space.get("rooms") or []
]
collections: tuple[tuple[str, tuple[str, ...]], ...] = (
("walls", ("key", "cm", "a", "b")),
("room_drafts", ("id", "points", "segments")),
("partitions", ("id", "a", "b", "cm")),
("wall_columns", ("id", "shape", "center", "cm", "angle")),
("open_spans", ("a", "b")),
)
for name, fields in collections:
if name not in space:
continue
values = []
for item in space.get(name) or []:
selected = _pick_fields(item, fields)
if name == "room_drafts" and "segments" in selected:
selected["segments"] = [
_pick_fields(segment, ("cm",))
for segment in selected.get("segments") or []
]
values.append(selected)
projected[name] = values
if "openings" in space:
projected["openings"] = [
_pick_fields(
opening,
("id", "type", "x", "y", "angle", "length")
+ (() if opening.get("type") == "passage" else ("flip_h", "flip_v"))
+ (("host",) if opening.get("host") else ()),
)
for opening in space.get("openings") or []
]
if "decor" in space:
projected["decor"] = [
_project_plan_only_decor(shape) for shape in space.get("decor") or []
]
return projected
def _plan_only_room_label_layout(
layout: dict[str, Any], space: dict[str, Any],
) -> dict[str, Any]:
space_id = str(space.get("id", ""))
room_ids = {str(room.get("id", "")) for room in space.get("rooms") or []}
projected: dict[str, Any] = {}
for key, pos in layout.items():
if not (
isinstance(key, str)
and key.startswith("rl_")
and key[3:] in room_ids
and isinstance(pos, dict)
and str(pos.get("s", "")) == space_id
):
continue
value = _pick_fields(pos, ("x", "y", "s"))
scale = pos.get("k")
if isinstance(scale, (int, float)) and not isinstance(scale, bool) \
and math.isfinite(scale) and 0.5 <= scale <= 3:
value["k"] = scale
projected[key] = value
return projected
def _marker_owned(marker: dict[str, Any], space: dict[str, Any], layout: dict[str, Any]) -> bool:
marker_id = str(marker.get("id", ""))
room_ids = {str(room.get("id")) for room in space.get("rooms") or []}
@@ -225,9 +416,12 @@ def create_export(
*,
kind: str,
space_id: str | None,
plan_only: bool = False,
card_version: str,
config_root: Path,
) -> tuple[dict[str, Any], str]:
if not isinstance(plan_only, bool) or plan_only and kind != "space":
raise ImportFailure("invalid_format", "Plan-only export requires one space")
raw_config = _json_copy(
config_data.get("config") or {"spaces": [], "markers": [], "settings": {}}
)
@@ -239,6 +433,7 @@ def create_export(
# payload would either duplicate it or tempt an exporter to silently stamp
# the current version over an older/future stored model.
config.pop("model_version", None)
global_background = _materialize_global_background(config)
layout = live_layout(config, LAYOUT_SCHEMA(_json_copy(layout_data.get("layout") or {})))
stamp = datetime.now(UTC).strftime("%Y-%m-%d_%H-%M-%S")
title = ""
@@ -247,41 +442,47 @@ def create_export(
space = next((sp for sp in config.get("spaces") or [] if str(sp.get("id")) == space_id), None)
if not space:
raise ImportFailure("space_not_found", "Space was not found")
_materialize_space_background(space, global_background)
title = str(space.get("title") or space.get("id") or "space")
selected_layout = {
key: pos for key, pos in layout.items()
if isinstance(pos, dict) and str(pos.get("s")) == str(space_id)
}
selected_markers = [
m for m in config.get("markers") or []
if m.get("removed") is not True and _marker_owned(m, space, selected_layout)
]
selected_ids = {str(marker.get("id")) for marker in selected_markers}
for marker in selected_markers:
controls = marker.get("controls")
if isinstance(controls, list):
kept = []
for ref in controls:
if isinstance(ref, str) and ref.startswith("marker:") \
and ref[len("marker:"):] not in selected_ids:
dropped_marker_links += 1
continue
kept.append(ref)
marker["controls"] = kept or None
badge = marker.get("value_badge")
source = badge.get("source") if isinstance(badge, dict) else None
ref = source.get("ref") if isinstance(source, dict) \
and source.get("kind") == "derived_marker_state" else None
if isinstance(ref, str) and ref.startswith("marker:") \
and ref[len("marker:"):] not in selected_ids:
badge["enabled"] = False
badge["source"] = None
dropped_marker_links += 1
config = {
"spaces": [_json_copy(space)],
"markers": _json_copy(selected_markers),
}
layout = selected_layout
if plan_only:
projected_space = _project_plan_only_space(space)
config = {"spaces": [projected_space], "markers": []}
layout = _plan_only_room_label_layout(selected_layout, projected_space)
else:
selected_markers = [
m for m in config.get("markers") or []
if m.get("removed") is not True and _marker_owned(m, space, selected_layout)
]
selected_ids = {str(marker.get("id")) for marker in selected_markers}
for marker in selected_markers:
controls = marker.get("controls")
if isinstance(controls, list):
kept = []
for ref in controls:
if isinstance(ref, str) and ref.startswith("marker:") \
and ref[len("marker:"):] not in selected_ids:
dropped_marker_links += 1
continue
kept.append(ref)
marker["controls"] = kept or None
badge = marker.get("value_badge")
source = badge.get("source") if isinstance(badge, dict) else None
ref = source.get("ref") if isinstance(source, dict) \
and source.get("kind") == "derived_marker_state" else None
if isinstance(ref, str) and ref.startswith("marker:") \
and ref[len("marker:"):] not in selected_ids:
badge["enabled"] = False
badge["source"] = None
dropped_marker_links += 1
config = {
"spaces": [_json_copy(space)],
"markers": _json_copy(selected_markers),
}
layout = selected_layout
elif kind != "full":
raise ImportFailure("invalid_format", "Unknown export kind")
document = {
@@ -296,7 +497,10 @@ def create_export(
"payload": {"config": config, "layout": layout},
"placement_manifest": placement_manifest(config, layout),
"content_manifest": content_manifest(config, config_root),
"transfer": {"dropped_marker_links": dropped_marker_links},
"transfer": {
"dropped_marker_links": dropped_marker_links,
**({"plan_only": True} if plan_only else {}),
},
}
if len(json.dumps(
document, ensure_ascii=False, separators=(",", ":"), allow_nan=False
@@ -358,6 +562,12 @@ def parse_document(raw: bytes) -> dict[str, Any]:
config = CONFIG_SCHEMA(_json_copy(payload.get("config")))
except (vol.Invalid, TypeError, ValueError) as err:
raise ImportFailure("invalid_config", str(err)) from err
if document["kind"] == "full":
_materialize_global_background(config)
else:
for space in config.get("spaces") or []:
if isinstance(space, dict):
_materialize_space_background(space, "static")
try:
layout = LAYOUT_SCHEMA(_json_copy(payload.get("layout") or {}))
except (vol.Invalid, TypeError, ValueError) as err:
@@ -379,10 +589,13 @@ def parse_document(raw: bytes) -> dict[str, Any]:
if document["kind"] == "space" and placement_ids != set(layout):
raise ImportFailure("invalid_format", "Placement manifest does not match layout")
dropped_marker_links = _transfer_dropped_marker_links(document)
plan_only = _transfer_plan_only(document)
document["transfer"] = {
**(document.get("transfer") or {}),
"dropped_marker_links": dropped_marker_links,
}
if plan_only:
_validate_plan_only_document(document, config, layout, placement)
if len(json.dumps(
config, ensure_ascii=False, separators=(",", ":"), allow_nan=False
).encode("utf-8")) > MAX_CONFIG_BYTES:
@@ -453,6 +666,51 @@ def _transfer_dropped_marker_links(document: dict[str, Any]) -> int:
return value
def _transfer_plan_only(document: dict[str, Any]) -> bool:
"""Read strict additive plan-only metadata without widening old exports."""
transfer = document.get("transfer")
if transfer is None:
return False
if not isinstance(transfer, dict):
raise ImportFailure("invalid_format", "Transfer metadata must be an object")
value = transfer.get("plan_only", False)
if not isinstance(value, bool):
raise ImportFailure("invalid_format", "Plan-only metadata must be a boolean")
if value and document.get("kind") != "space":
raise ImportFailure("invalid_format", "Plan-only metadata requires one space")
return value
def _validate_plan_only_document(
document: dict[str, Any],
config: dict[str, Any],
layout: dict[str, Any],
placement: list[Any],
) -> None:
"""Reject forged plan-only flags unless every privacy invariant is true."""
spaces = config.get("spaces") or []
if len(spaces) != 1 or config.get("markers") != []:
raise ImportFailure("invalid_format", "Plan-only export has invalid owners")
expected_config = CONFIG_SCHEMA({
"spaces": [_project_plan_only_space(spaces[0])],
"markers": [],
})
if config != expected_config:
raise ImportFailure("invalid_format", "Plan-only export contains private fields")
expected_layout = _plan_only_room_label_layout(layout, spaces[0])
if layout != expected_layout:
raise ImportFailure("invalid_format", "Plan-only export contains device layout")
expected_placement = placement_manifest(config, layout)
if placement != expected_placement:
raise ImportFailure("invalid_format", "Plan-only placement manifest is not canonical")
content = document.get("content_manifest")
if not isinstance(content, list) or any(
not isinstance(item, dict) or item.get("owner") != "space"
for item in content
):
raise ImportFailure("invalid_format", "Plan-only export contains private content")
def _drop_invalid_import_marker_links(
config: dict[str, Any], *, clean_ids: set[str] | None = None,
) -> int:
@@ -560,6 +818,7 @@ def build_space_merge(
if len(spaces) != 1:
raise ImportFailure("invalid_config", "A space export must contain exactly one space")
space = spaces[0]
_materialize_space_background(space, "static")
used = {
str(value)
for sp in current_config.get("spaces") or []
@@ -583,6 +842,16 @@ def build_space_merge(
for room in space.get("rooms") or []:
if room.get("open_to"):
room["open_to"] = [old_room_ids.get(str(value), str(value)) for value in room["open_to"]]
# Opening ownership is part of the same space-local id graph. Remap the
# nested reference together with the partition itself; otherwise the
# invariant validator correctly rejects a copied space whose host.id still
# names the source partition.
for opening in space.get("openings") or []:
host = opening.get("host") if isinstance(opening, dict) else None
if isinstance(host, dict) and host.get("kind") == "partition":
old_host_id = str(host.get("id"))
if old_host_id in id_map:
host["id"] = id_map[old_host_id]
space["id"] = new_space_id
space["title"] = _unique_title(
str(space.get("title") or old_space_id), current_config.get("spaces") or []
@@ -637,7 +906,7 @@ def build_space_merge(
for field in (
"area", "controls", "tap_action", "tap_target", "tap_confirm",
"vacuum", "is_light", "use_climate_temp", "glow_color",
"glow_radius_cm", "light_entity", "hidden", "removed",
"glow_radius_cm", "light_entity", "toggle_entity", "hidden", "removed",
"value_badge",
):
marker.pop(field, None)
@@ -722,7 +991,9 @@ def build_space_merge(
validate_marker_controls(merged_config, current_config)
validate_marker_light_entities(merged_config, current_config)
validate_marker_value_badges(merged_config, current_config)
except MarkerControlError as err:
validate_opening_passages(merged_config, current_config)
validate_partition_opening_hosts(merged_config, current_config)
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
raise ImportFailure(err.code, str(err)) from err
try:
merged_layout = LAYOUT_SCHEMA(merged_layout)
@@ -881,6 +1152,13 @@ def create_preview(
current_config = current_config_data.get("config") or {"spaces": [], "markers": [], "settings": {}}
current_layout = current_layout_data.get("layout") or {}
details: dict[str, Any] = {}
try:
# Every imported opening is new to this installation. A forged full or
# one-space file must not use the broken-read exception reserved for an
# already stored legacy passage.
validate_opening_passages(incoming_config, validate_all=True)
except OpeningPassageError as err:
raise ImportFailure(err.code, str(err)) from err
if document["kind"] == "space":
_merged_config, _merged_layout, details = build_space_merge(
document, current_config, current_layout, duplicate_policy,
@@ -926,6 +1204,7 @@ def create_preview(
runtime.import_previews[token] = candidate
preview = {
"kind": document["kind"],
"plan_only": _transfer_plan_only(document),
"created_at": document.get("created_at"),
"card_version": document.get("card_version"),
"integration_version": document.get("integration_version"),
@@ -1005,6 +1284,7 @@ def revalidate_candidate(
return {
"preview": {
"kind": document["kind"],
"plan_only": _transfer_plan_only(document),
"counts": _counts(incoming["config"], incoming["layout"]),
"current_counts": _counts(current_config, current_layout),
"source": "same" if candidate.get("same_source") else "foreign",
@@ -1039,6 +1319,7 @@ def prepare_apply(
_detach_missing(imported_config, candidate.get("content") or [])
if document["kind"] == "full":
config = imported_config
_materialize_global_background(config)
# Full exports keep the data-model version in the portable envelope so
# the payload can be validated independently. Restore it before the
# configuration is persisted; otherwise every full round-trip silently
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.64.0-beta.1"
"version": "1.65.0-beta.2"
}
+30 -4
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import copy
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
@@ -22,6 +23,32 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
_BG_MODES = frozenset({"static", "daynight"})
def migrate_config_background_mode(old_data: dict[str, Any]) -> dict[str, Any]:
"""Materialize the legacy implicit background mode without changing its view.
Only the config-store document has a top-level ``config`` object. Layout
and virtual-light stores pass through this helper unchanged even though
they share the same Store subclass and minor version.
"""
config = old_data.get("config")
if not isinstance(config, dict):
return old_data
settings = config.get("settings")
mode = settings.get("bg_mode") if isinstance(settings, dict) else None
if mode in _BG_MODES:
return old_data
data = copy.deepcopy(old_data)
migrated_config = data["config"]
migrated_settings = migrated_config.get("settings")
if not isinstance(migrated_settings, dict):
migrated_settings = {}
migrated_config["settings"] = migrated_settings
migrated_settings["bg_mode"] = "static"
return data
class HouseplanStore(Store):
@@ -39,10 +66,9 @@ class HouseplanStore(Store):
old_minor_version: int,
old_data: dict[str, Any],
) -> dict[str, Any]:
data = old_data
# if old_major_version == 1 and old_minor_version < 2:
# ...migrate...
return data
if old_major_version == 1 and old_minor_version < 2:
return migrate_config_background_mode(old_data)
return old_data
@dataclass
+145 -12
View File
@@ -32,6 +32,93 @@ class MarkerControlError(ValueError):
self.code = code
class OpeningPassageError(ValueError):
"""Semantic open-passage error with a stable public code and payload."""
code = "invalid_passage_fields"
def __init__(self, space_id: str, opening_id: str, fields: list[str]) -> None:
self.space_id = space_id
self.opening_id = opening_id
self.fields = tuple(sorted(fields))
super().__init__(
f"space={space_id}; opening={opening_id}; fields={','.join(self.fields)}"
)
class PartitionOpeningHostError(ValueError):
"""A write tried to strip explicit host identity from a surviving opening."""
code = "invalid_partition_opening_host"
def validate_partition_opening_hosts(
config: dict, previous: dict | None = None
) -> None:
"""Prevent an older writer from silently downgrading hosted openings.
Deleting the opening together with its partition remains valid. Only a
surviving record that previously had an explicit host must keep one.
"""
old_spaces = {
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
}
for space in config.get("spaces") or []:
old_space = old_spaces.get(str(space.get("id", "")))
if not old_space:
continue
old_openings = {
str(opening.get("id")): opening
for opening in old_space.get("openings") or []
}
for opening in space.get("openings") or []:
old = old_openings.get(str(opening.get("id", "")))
if old and old.get("host") is not None and opening.get("host") is None:
raise PartitionOpeningHostError(
f"space={space.get('id', '')}; opening={opening.get('id', '')}; host removed"
)
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
def validate_opening_passages(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Reject new/changed inapplicable fields while preserving dormant bad data.
A passage read from an older/future writer may already contain door-only
keys. Unrelated writes must remain possible, but imports and any write that
introduces or changes such a key are fail-closed.
"""
old_spaces = {
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
}
for space in config.get("spaces") or []:
space_id = str(space.get("id", ""))
old_space = None if validate_all else old_spaces.get(space_id)
old_openings = {
str(opening.get("id")): opening
for opening in (old_space or {}).get("openings") or []
}
for opening in space.get("openings") or []:
if opening.get("type") != "passage":
continue
opening_id = str(opening.get("id", ""))
present = sorted(PASSAGE_FORBIDDEN_FIELDS & set(opening))
if not present:
continue
old_opening = None if validate_all else old_openings.get(opening_id)
if not old_opening or old_opening.get("type") != "passage":
raise OpeningPassageError(space_id, opening_id, present)
changed = [
field for field in present
if field not in old_opening or opening[field] != old_opening[field]
]
if changed:
raise OpeningPassageError(space_id, opening_id, changed)
VALUE_BADGE_ATTRIBUTES = {
"current_temperature", "temperature", "current_humidity", "humidity",
"current_position", "percentage", "brightness", "volume_level",
@@ -135,7 +222,7 @@ def validate_marker_value_badges(
def validate_marker_light_entities(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate new/changed leading-light choices without rejecting dormant data.
"""Validate new/changed light/switch choices without rejecting dormant data.
The top-level schema must stay lossless: an old or future literal that the
current frontend cannot edit may round-trip unchanged. Imports validate the
@@ -152,16 +239,26 @@ def validate_marker_light_entities(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
value = marker.get("light_entity")
old_value = None if validate_all else (old_marker or {}).get("light_entity")
if not validate_all and value == old_value:
continue
if value is None:
continue
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
raise MarkerControlError(
"invalid_light_entity", "Leading light entity must be light.* or switch.*"
)
for field, code, message in (
(
"light_entity",
"invalid_light_entity",
"Leading light entity must be light.* or switch.*",
),
(
"toggle_entity",
"invalid_toggle_entity",
"Toggle entity must be light.* or switch.*",
),
):
value = marker.get(field)
old_value = None if validate_all else (old_marker or {}).get(field)
if not validate_all and value == old_value:
continue
if value is None:
continue
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
raise MarkerControlError(code, message)
def validate_marker_controls(
@@ -703,6 +800,15 @@ WALL_COLUMN_SCHEMA = vol.All(
_strict_wall_column,
)
PARTITION_OPENING_HOST_SCHEMA = vol.Schema(
{
vol.Required("kind"): vol.Equal("partition"),
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("t"): vol.All(_finite, vol.Range(min=0, max=1)),
},
extra=vol.PREVENT_EXTRA,
)
def _space_geometry_invariants(value: dict) -> dict:
"""All stored geometry shares ids; draft segments also have a space cap."""
@@ -720,6 +826,29 @@ def _space_geometry_invariants(value: dict) -> dict:
)
if draft_segments > MAX_DRAFT_SEGMENTS:
raise vol.Invalid("too many saved room-draft segments")
partitions = {
item.get("id"): item for item in value.get("partitions", []) if item.get("id")
}
hosted_intervals: dict[str, list[tuple[float, float]]] = {}
for opening in value.get("openings", []):
host = opening.get("host")
if host is None:
continue
partition = partitions.get(host["id"])
if partition is None:
raise vol.Invalid("partition opening host must exist in the same space")
ax, ay = partition["a"]
bx, by = partition["b"]
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
length = float(opening["length"])
along = float(host["t"]) * span
if length > span or along - length / 2 < -1e-9 or along + length / 2 > span + 1e-9:
raise vol.Invalid("partition opening must fit inside its host")
lo, hi = along - length / 2, along + length / 2
occupied = hosted_intervals.setdefault(host["id"], [])
if any(max(lo, old_lo) < min(hi, old_hi) - 1e-9 for old_lo, old_hi in occupied):
raise vol.Invalid("partition openings must not overlap")
occupied.append((lo, hi))
return value
@@ -773,7 +902,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
vol.Schema(
{
vol.Required("id"): str,
vol.Required("type"): vol.Any("door", "window", "gate"),
vol.Required("type"): vol.Any("door", "window", "gate", "passage"),
vol.Required("x"): _GEOM,
vol.Required("y"): _GEOM,
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
@@ -785,6 +914,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
vol.Optional("invert"): bool,
vol.Optional("flip_h"): bool,
vol.Optional("flip_v"): bool,
vol.Optional("host"): PARTITION_OPENING_HOST_SCHEMA,
},
extra=vol.ALLOW_EXTRA,
)
@@ -885,6 +1015,9 @@ MARKER_SCHEMA = vol.Schema(
# Semantic delta validation below the schema preserves unknown/future
# literals until that exact field is edited (lossless config doctrine).
vol.Optional("light_entity"): object,
# Exact own entity selected for Toggle. Delta validation preserves an
# untouched future literal while bounding every new/changed value.
vol.Optional("toggle_entity"): object,
vol.Optional("value_badge"): vol.Any(
None,
vol.Schema(
+11 -3
View File
@@ -57,7 +57,9 @@ from .virtual_lights import (
from .registry_snapshot import import_registry_snapshot
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, sanitize_filename,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
PartitionOpeningHostError, sanitize_filename,
validate_opening_passages, validate_partition_opening_hosts,
validate_marker_controls, validate_marker_light_entities,
validate_marker_value_badges, valid_space_id,
)
@@ -263,6 +265,7 @@ async def _commit_import_pair(
vol.Required("type"): "houseplan/export/create",
vol.Required("kind"): vol.In(["full", "space"]),
vol.Optional("space_id"): str,
vol.Optional("plan_only", default=False): bool,
vol.Optional("card_version", default=""): str,
}
)
@@ -287,6 +290,7 @@ async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any])
layout_data,
kind=msg["kind"],
space_id=msg.get("space_id"),
plan_only=msg.get("plan_only", False),
card_version=msg.get("card_version", ""),
config_root=Path(hass.config.path("")),
)
@@ -1252,7 +1256,9 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
validate_marker_controls(msg["config"], data.get("config"))
validate_marker_light_entities(msg["config"], data.get("config"))
validate_marker_value_badges(msg["config"], data.get("config"))
except MarkerControlError as err:
validate_opening_passages(msg["config"], data.get("config"))
validate_partition_opening_hosts(msg["config"], data.get("config"))
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
connection.send_error(msg["id"], err.code, str(err))
return
# An internal plan url must name a file that exists. The card can pick a
@@ -1362,7 +1368,9 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
validate_marker_controls(msg["config"], config_data.get("config"))
validate_marker_light_entities(msg["config"], config_data.get("config"))
validate_marker_value_badges(msg["config"], config_data.get("config"))
except MarkerControlError as err:
validate_opening_passages(msg["config"], config_data.get("config"))
validate_partition_opening_hosts(msg["config"], config_data.get("config"))
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
connection.send_error(msg["id"], err.code, str(err))
return
+145 -3
View File
@@ -14,11 +14,27 @@ 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'].includes(profile))
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-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 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 fixture = makeLargeHouseFixture();
if (planSnap) {
for (const [floor, space] of fixture.config.spaces.entries()) {
space.room_drafts = [0, 1].map((draft) => {
const y = 0.985 + draft * 0.025;
return {
id: `perf-draft-${floor}-${draft}`,
points: [[0.10, y], [0.38, y], [0.46, y + 0.035]],
segments: [{ cm: 15 }, { cm: 20 }],
};
});
}
fixture.counts = { ...fixture.counts, drafts: 6, pointerMoves: 120 };
}
const viewport = { width: 1440, height: 1000 };
const { page, browser } = await launch(
@@ -48,7 +64,10 @@ const rows = [];
try {
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const measuredSample = iteration - warmups;
const row = await page.evaluate(async ({ fixture, sample, cardContract, isometric, requiresIsometric }) => {
const row = await page.evaluate(async ({
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
requiresWallFace,
}) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
const started = performance.now();
@@ -104,6 +123,8 @@ try {
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
isoGeometry: card._isoGeometryCache?.size ?? 0,
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
wallFaceGraph: card._wallFaceGraphCache?.length ?? 0,
});
window.__card?.remove?.();
@@ -120,6 +141,7 @@ try {
card.setConfig({
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
});
let wsCalls = 0;
const connection = {
subscribeEvents: async () => () => undefined,
subscribeMessage: async () => () => undefined,
@@ -134,6 +156,7 @@ try {
three: { floor_id: 'three', name: 'Three', level: 2 },
},
callWS: async (message) => {
wsCalls++;
if (message.type === 'houseplan/config/get')
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
if (message.type === 'houseplan/layout/get')
@@ -155,6 +178,13 @@ try {
const loadLongTasks = startLongTaskWindow();
const loadStarted = performance.now();
host.replaceChildren(card);
if (requiresIsometric) {
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: '' });
}
card.hass = hassFor(fixture.states);
window.__hpAssertCardContract(card, cardContract);
if (requiresIsometric && (typeof card._setProjection !== 'function'
@@ -196,6 +226,112 @@ try {
await card.updateComplete;
});
let planSnapDiagnostics = null;
const planSnapPointer = planSnap ? await duration(async () => {
card._setMode('plan');
card._tool = 'draw';
card._path = [];
card.requestUpdate();
await card.updateComplete;
await frame();
const stage = card.renderRoot.querySelector('.stage');
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
if (requiresPlanSnap && !overlay) throw new Error('plan-snap candidate has no overlay');
const staticLines = overlay?.querySelectorAll('.plan-snap-line').length ?? 0;
const staticNodes = overlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0;
const cacheValue = card._planSnapGeometryCache?.value ?? null;
const configBefore = JSON.stringify(card._serverCfg);
const callsBefore = wsCalls;
const wallFaceCacheBeforePointer = card._wallFaceGraphCache?.length ?? 0;
const view = card._viewOr(card._baseVb());
const rect = stage.getBoundingClientRect();
const fromPlan = (x, y) => ({
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
});
const firstEndpoint = overlay?.querySelector('.plan-snap-node[data-kind="endpoint"]');
const longLine = [...(overlay?.querySelectorAll('.plan-snap-line') || [])]
.map((line) => ({
line,
a: [+line.getAttribute('x1'), +line.getAttribute('y1')],
b: [+line.getAttribute('x2'), +line.getAttribute('y2')],
}))
.sort((a, b) => Math.hypot(b.b[0] - b.a[0], b.b[1] - b.a[1])
- Math.hypot(a.b[0] - a.a[0], a.b[1] - a.a[1]))[0];
const points = [
firstEndpoint
? [+firstEndpoint.getAttribute('cx'), +firstEndpoint.getAttribute('cy')]
: [40, 40],
longLine
? [(longLine.a[0] + longLine.b[0]) / 2, (longLine.a[1] + longLine.b[1]) / 2]
: [120, 40],
[10, 10],
];
const seenKinds = new Set();
for (let index = 0; index < 120; index++) {
const point = points[index % points.length];
stage.dispatchEvent(new PointerEvent('pointermove', {
...fromPlan(point[0], point[1]),
bubbles: true, composed: true, pointerId: 880, pointerType: 'mouse',
}));
await card.updateComplete;
const active = card.renderRoot.querySelector(
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
);
if (active) seenKinds.add(active.getAttribute('data-kind'));
if (requiresPlanSnap && card.renderRoot.querySelectorAll(
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
).length > 1) throw new Error('plan-snap rendered more than one active candidate');
}
const finalOverlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
planSnapDiagnostics = {
supported: requiresPlanSnap,
staticLines,
staticNodes,
activeKinds: [...seenKinds].sort(),
cacheStable: cacheValue != null && card._planSnapGeometryCache?.value === cacheValue,
domStable: (finalOverlay?.querySelectorAll('.plan-snap-line').length ?? 0) === staticLines
&& (finalOverlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0)
=== staticNodes,
configStable: JSON.stringify(card._serverCfg) === configBefore,
wsWrites: wsCalls - callsBefore,
wallFaceCacheStableOnPointer:
(card._wallFaceGraphCache?.length ?? 0) === wallFaceCacheBeforePointer,
};
if (requiresPlanSnap && (
staticLines < fixture.counts.rooms || staticNodes < fixture.counts.rooms
|| !planSnapDiagnostics.cacheStable || !planSnapDiagnostics.domStable
|| !planSnapDiagnostics.configStable || planSnapDiagnostics.wsWrites !== 0
|| !planSnapDiagnostics.wallFaceCacheStableOnPointer
|| !seenKinds.has('endpoint') || !seenKinds.has('line')
)) throw new Error(`plan-snap structural contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
if (requiresWallFace) {
const oldPath = card._path;
const oldDraftId = card._activeDraftId;
const oldCms = card._draftSegmentCms;
const beforePath = [[10, 10]];
card._path = [[10, 10], [20, 10]];
card._activeDraftId = 'perf-face-draft';
card._draftSegmentCms = [15];
const acceptedStarted = performance.now();
card._offerWallFaces(beforePath);
planSnapDiagnostics.wallFaceAcceptedClickMs = performance.now() - acceptedStarted;
planSnapDiagnostics.wallFaceCacheEntries = card._wallFaceGraphCache?.length ?? 0;
card._wallFaceBatch = null;
card._roomDialog = false;
card._path = oldPath;
card._activeDraftId = oldDraftId;
card._draftSegmentCms = oldCms;
if (planSnapDiagnostics.wallFaceAcceptedClickMs > 1000
|| planSnapDiagnostics.wallFaceCacheEntries < 1
|| planSnapDiagnostics.wallFaceCacheEntries > 4) {
throw new Error(`wall-face accepted-click contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
}
}
card._setMode('view');
await card.updateComplete;
}) : null;
const resizePreview = await duration(async () => {
card._setMode('plan');
card._tool = 'resize';
@@ -279,6 +415,10 @@ try {
modelReadyMs,
firstStableRenderMs,
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
...(planSnapPointer ? {
planSnapPointerMs: planSnapPointer.ms,
planSnapDiagnostics,
} : {}),
spaceSwitchMs: spaceSwitch.ms,
stateUpdateMs: stateUpdate.ms,
resizePreviewMs: resizePreview.ms,
@@ -288,6 +428,7 @@ try {
longTasks: {
load: loadLongTaskResult,
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
spaceSwitch: spaceSwitch.longTasks,
stateUpdate: stateUpdate.longTasks,
resizePreview: resizePreview.longTasks,
@@ -306,7 +447,7 @@ try {
return result;
}, {
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
isometric, requiresIsometric,
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
});
if (measuredSample >= 0) rows.push(row);
}
@@ -319,6 +460,7 @@ const metricNames = [
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
];
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
const report = {
schema: 2,
profile,
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
import { launch } from '../serve.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const OUTPUT = resolve(ROOT, 'docs/images');
const BUNDLE = resolve(ROOT, 'dist/houseplan-card.js');
const DEMO_BUNDLE = resolve(ROOT, 'demo/srv/assets/houseplan-card.js');
const INTEGRATION_BUNDLE = resolve(ROOT, 'custom_components/houseplan/frontend/houseplan-card.js');
const SCRIPT = fileURLToPath(import.meta.url);
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
export const DOC_SCREENSHOT_VERSION = 1;
export const DOC_SCREENSHOTS = Object.freeze([
{
id: 'view-desktop', file: '01-view-desktop.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'view-touch', file: '02-view-touch.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true, kiosk: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 390, height: 760 }, capture: 'page',
},
{
id: 'space-create', file: '03-space-create.png', fixture: 'empty', noFloors: true,
title: 'House Plan', language: 'en', theme: 'dark',
viewport: { width: 900, height: 850 }, capture: 'page', expectDialog: true,
},
{
id: 'room-contour-close', file: '04-room-contour-close.png', fixture: 'visual',
space: 'golden-geometry', mode: 'plan',
wallJunctionPreview: {
path: [[0.18, 0.18], [0.40, 0.18], [0.40, 0.40], [0.18, 0.40]],
pointer: [0.18, 0.18], cms: [440, 440, 440], cm: 15,
},
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'plan-context-tray', file: '05-plan-context-tray.png', fixture: 'visual',
space: 'golden-geometry', mode: 'plan', editorTray: 'plan-selection',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'device-editor', file: '06-device-editor.png', fixture: 'visual',
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
deviceName: 'Living-room ceiling light',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
},
{
id: 'device-display-preview', file: '06-device-display-preview.png', fixture: 'visual',
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
deviceName: 'Living-room ceiling light', devicePresentationPreview: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
},
{
id: 'background-editor', file: '07-background-editor.png', fixture: 'visual',
space: 'golden-geometry', mode: 'decor', editorTray: 'decor-selection',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'room-card', file: '08-room-card.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'room-card',
},
{
id: 'device-info', file: '09-device-info.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', dialog: 'device-info',
deviceId: 'golden-light-two', deviceName: 'Living-room ceiling light',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 900 }, capture: 'page', expectDialog: true,
},
]);
const roomCardClip = (page) => page.evaluate(() => {
const card = window.__goldenCard;
const roomCards = [...(card?.renderRoot?.querySelectorAll('.roomlabel') || [])];
const target = roomCards.find((item) => item.querySelector('.rlm')) || roomCards[0];
if (!target) throw new Error('documentation room card is missing');
const rect = target.getBoundingClientRect();
const marginX = 80;
const marginY = 70;
return {
x: Math.max(0, rect.left - marginX),
y: Math.max(0, rect.top - marginY),
width: Math.min(innerWidth, rect.right + marginX) - Math.max(0, rect.left - marginX),
height: Math.min(innerHeight, rect.bottom + marginY) - Math.max(0, rect.top - marginY),
};
});
/**
* Documentation-only presentation state. Keep these mutations out of the
* golden harness: changing that release fixture would invalidate every visual
* baseline even though the production component and golden matrix are intact.
*/
const applyDocumentationState = (page, scenario) => page.evaluate(async (current) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const card = window.__goldenCard;
if (!card) throw new Error(`documentation card is missing: ${current.id}`);
if (current.title) {
card.setConfig({ ...card._config, title: current.title });
}
if (current.roomMetrics) {
const space = card._serverCfg?.spaces?.find((item) => item.id === current.space);
if (!space) throw new Error(`documentation room metrics space is missing: ${current.space}`);
space.settings = {
...(space.settings || {}),
label_temp: true,
label_hum: true,
label_lqi: true,
label_light: true,
};
card._cfgEpoch += 1;
card._modelCache = null;
}
if (current.fixture === 'empty') {
card._serverCfg = { ...(card._serverCfg || {}), spaces: [] };
card._cfgEpoch += 1;
card._modelCache = null;
card._space = '';
card._onboardingShown = true;
card.hass = { ...card.hass, floors: {} };
card._openSpaceDialog('create');
}
if (current.dialog === 'device-info') {
const device = card._devices.find((item) => item.id === current.deviceId);
if (!device) throw new Error(`documentation device is missing: ${current.deviceId}`);
card._infoCard = device;
}
card.requestUpdate();
await card.updateComplete;
await frame();
if (current.devicePresentationPreview) {
const dialog = card.renderRoot.querySelector('hp-dialog');
const body = dialog?.querySelector('.body');
const preview = dialog?.querySelector('hp-device-preview');
await preview?.updateComplete;
if (!body || !preview)
throw new Error('documentation device presentation preview is missing');
const bodyRect = body.getBoundingClientRect();
const previewRect = preview.getBoundingClientRect();
body.scrollTop += previewRect.top - bodyRect.top - 180;
await frame();
const visibleBody = body.getBoundingClientRect();
const visiblePreview = preview.getBoundingClientRect();
if (visiblePreview.top < visibleBody.top - 1 || visiblePreview.bottom > visibleBody.bottom + 1)
throw new Error('documentation viewport does not show the device presentation preview');
}
return { dialog: !!card.renderRoot.querySelector('hp-dialog') };
}, scenario);
mkdirSync(OUTPUT, { recursive: true });
copyFileSync(BUNDLE, DEMO_BUNDLE);
copyFileSync(BUNDLE, INTEGRATION_BUNDLE);
const { page, browser } = await launch();
const browserErrors = [];
page.on('pageerror', (error) => browserErrors.push(error.message));
try {
const fingerprint = await assertFreshDemoBundle(page, ROOT);
const scenarios = {};
for (const scenario of DOC_SCREENSHOTS) {
await prepareGoldenScenario(page, scenario);
const runtime = await applyDocumentationState(page, scenario);
if (scenario.expectDialog && !runtime.dialog)
throw new Error(`documentation scenario did not open its dialog: ${scenario.id}`);
const clip = scenario.capture === 'room-card'
? await roomCardClip(page)
: await goldenClip(page, scenario.capture);
const image = await page.screenshot({
...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css',
});
writeFileSync(resolve(OUTPUT, scenario.file), image);
scenarios[scenario.id] = {
file: scenario.file,
viewport: scenario.viewport,
theme: scenario.theme,
language: scenario.language,
sourceSha256: fingerprint,
imageSha256: sha256(image),
};
console.log(`captured ${scenario.id} -> docs/images/${scenario.file}`);
}
if (browserErrors.length) throw new Error(`browser errors: ${browserErrors.join(' | ')}`);
const manifest = {
version: DOC_SCREENSHOT_VERSION,
fixture: 'synthetic-only',
sourceFingerprint: fingerprint,
captureScriptSha256: sha256(readFileSync(SCRIPT)),
command: 'npm run build && node demo/docs/capture.mjs',
scenarios,
};
writeFileSync(resolve(OUTPUT, 'screenshots.json'), `${JSON.stringify(manifest, null, 2)}\n`);
} finally {
await browser.close();
}
+59
View File
@@ -0,0 +1,59 @@
// AC-13 for #157. Usage:
// node demo/downgrade_open_passage.mjs --bundle=/absolute/v1.64.0/dist/houseplan-card.js
// The v1.64.0 frontend does not understand `passage`; this executable fixture
// pins its documented best-effort fallback (door symbol) and, critically,
// rejects any pageerror/unhandled exception while reading the newer literal.
import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { launch, checkAll, finish } from './serve.mjs';
const value = process.argv.find((arg) => arg.startsWith('--bundle='))?.slice('--bundle='.length);
if (!value) {
console.error('usage: node demo/downgrade_open_passage.mjs --bundle=/absolute/v1.64.0/houseplan-card.js');
process.exit(2);
}
const bundle = isAbsolute(value) ? value : resolve(value);
if (!existsSync(bundle)) {
console.error(`v1.64.0 bundle not found: ${bundle}`);
process.exit(2);
}
const currentDemo = fileURLToPath(new URL('./srv', import.meta.url));
const serveRoot = mkdtempSync(join(tmpdir(), 'hp-157-downgrade-'));
let browser;
try {
cpSync(currentDemo, serveRoot, { recursive: true });
cpSync(bundle, join(serveRoot, 'assets', 'houseplan-card.js'));
const launched = await launch(undefined, 1, [], {}, serveRoot);
browser = launched.browser;
const out = await launched.page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const space = card._serverCfg.spaces.find((item) => item.id === card._space);
space.openings = [{
id: 'future-passage', type: 'passage', x: 0.3, y: 0.14,
angle: 0, length: 0.09, future_material: 'stone',
}];
card._setMode('plan');
card._cfgEpoch++;
card.requestUpdate();
await card.updateComplete;
await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)));
const opening = root().querySelector('[data-hp="opening"][data-id="future-passage"]');
const stored = space.openings[0];
return {
newerLiteralLoads: !!opening,
documentedDoorFallback: !!opening?.querySelector('.op-leaf,.op-arc'),
readDoesNotRewriteConfig: stored.type === 'passage'
&& stored.future_material === 'stone' && space.openings.length === 1,
};
});
checkAll(out);
await finish(browser, out);
browser = undefined;
} finally {
await browser?.close?.();
rmSync(serveRoot, { recursive: true, force: true });
}
+75 -6
View File
@@ -57,6 +57,11 @@ const lightingRooms = [
poly: [[0.50, 0.10], [0.93, 0.10], [0.93, 0.88], [0.50, 0.88]] },
];
const applianceRooms = [
{ id: 'appliance-room', name: 'Laundry', area: 'golden_appliance',
poly: [[0.08, 0.10], [0.92, 0.10], [0.92, 0.90], [0.08, 0.90]] },
];
const geometrySpace = {
id: 'golden-geometry',
title: 'Geometry matrix',
@@ -122,7 +127,25 @@ const lightingSpace = {
decor: [],
};
const runtime = () => {
const applianceSpace = {
id: 'golden-appliance',
title: 'Appliance lifecycle',
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: {
fill_mode: 'none', glow_enabled: false, show_borders: true, show_names: true,
sun_rays: false, bg_mode: 'static',
},
rooms: applianceRooms,
walls: wallsFor('appliance', applianceRooms, 15),
openings: [],
partitions: [],
wall_columns: [],
decor: [],
};
const runtime = (includeAppliance = false) => {
const devices = {};
const entities = {};
const states = {
@@ -136,7 +159,8 @@ const runtime = () => {
// The production projection must preserve them.
const layout = {};
const areas = Object.fromEntries(
[...geometryRooms, ...lightingRooms].map((room) => [room.area, { area_id: room.area, name: room.name }]),
[...geometryRooms, ...lightingRooms, ...(includeAppliance ? applianceRooms : [])]
.map((room) => [room.area, { area_id: room.area, name: room.name }]),
);
const add = (id, domain, area, x, y, state, attributes = {}) => {
const entityId = `${domain}.${id.replaceAll('-', '_')}`;
@@ -171,6 +195,44 @@ const runtime = () => {
add('golden-right-linkquality', 'sensor', 'golden_light_right', 0.66, 0.70, '190', {
unit_of_measurement: 'lqi',
});
if (includeAppliance) {
const washerId = 'golden-washer';
devices[washerId] = {
id: washerId,
name: 'Golden washing machine',
model: 'GOLDEN-WASHER-COMPOSITE',
area_id: 'golden_appliance',
identifiers: [['houseplan_golden', washerId]],
config_entries: ['golden_entry'],
entry_type: null,
via_device_id: null,
disabled_by: null,
};
const addWasherEntity = (entityId, state, attributes = {}, registry = {}) => {
entities[entityId] = {
entity_id: entityId,
device_id: washerId,
platform: 'houseplan_golden',
config_entry_id: 'golden_entry',
disabled_by: null,
...registry,
};
states[entityId] = {
entity_id: entityId,
state,
attributes: { friendly_name: registry.original_name || entityId, ...attributes },
};
};
addWasherEntity('switch.golden_washer_power', 'on', {}, { original_name: 'Power' });
addWasherEntity('switch.golden_washer_child_lock', 'off', {}, { original_name: 'Child lock' });
addWasherEntity('sensor.golden_washer_status', 'done', {}, {
original_name: 'Status', translation_key: 'status',
});
addWasherEntity('sensor.golden_washer_stage', 'Rinse', {}, { original_name: 'Stage' });
addWasherEntity('sensor.golden_washer_program', 'mixed_wash', {}, { original_name: 'Program' });
layout[washerId] = { s: 'golden-appliance', x: 0.5, y: 0.5 };
}
return { devices, entities, states, layout, areas };
};
@@ -182,9 +244,12 @@ export const VISUAL_MATRIX_COUNTS = Object.freeze({
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
});
export const makeVisualMatrixFixture = () => ({
export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) => ({
config: {
spaces: [structuredClone(geometrySpace), structuredClone(lightingSpace)],
spaces: [
structuredClone(geometrySpace), structuredClone(lightingSpace),
...(applianceLifecycle ? [structuredClone(applianceSpace)] : []),
],
// A persisted marker is part of the fixture contract for scenarios that
// override per-source Glow controls. The device/layout alone are not a
// saved marker configuration and must not be silently treated as one.
@@ -201,6 +266,10 @@ export const makeVisualMatrixFixture = () => ({
},
},
},
...runtime(),
counts: VISUAL_MATRIX_COUNTS,
...runtime(applianceLifecycle),
counts: applianceLifecycle ? {
...VISUAL_MATRIX_COUNTS,
spaces: VISUAL_MATRIX_COUNTS.spaces + 1,
rooms: VISUAL_MATRIX_COUNTS.rooms + applianceRooms.length,
} : VISUAL_MATRIX_COUNTS,
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 59 KiB

+39 -23
View File
@@ -1,32 +1,45 @@
{
"schema": 1,
"matrixVersion": 19,
"acceptedAt": "2026-08-14T01:16:59.692Z",
"sourceFingerprint": "9c4070fd77873e165069512796ed2379a9abee8632afac520efbf42d0a0515ba",
"matrixVersion": 26,
"acceptedAt": "2026-08-18T19:24:52.977Z",
"sourceFingerprint": "5d50c30316c1e2ec9b9102c7c8f14512fedf610a1a5a43eaa065827ca6e60d13",
"chromium": "151.0.7922.34",
"scenarios": {
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
"isometric-geometry-view-dark": "4816ec4b22211c73765f9fac81741df685202d21df6b0fbc1649b23357086da3",
"isometric-geometry-view-light": "8ec81fc4f254ae6ebab55bc5f0ef5325a4e5d327cd1fb53bf5f293f06fbec671",
"split-zero-divider-taper-dark": "4c63083b1cb9e1079fa56e6991a035124d0ce22527489712a7a9161f1ccf2107",
"isometric-geometry-view-dark": "9bc8eb0da8746bddc0b5245b337eef7477d4ce6fb39ac576cab1e49057fd837c",
"isometric-geometry-view-light": "144f1cc3107562bc252cdcb54165f2c92569f4cf3484554148a7fdb78de372ec",
"isometric-live-layers-dark": "c841269f672c7ad8b208a549cc87592bd26f2a9e226f793a2b79592b65ee54c6",
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
"isometric-touch-kiosk-dark": "fd2e74c5966b4adcf4e66021e2cef781873f9a2b58f16ad19d57fb451ba45dc9",
"isometric-touch-kiosk-dark": "745482a797e236058e3ddd56cb5d85d700c9333a3aeb764333addeaba40cf6b0",
"isometric-large-warm-remount-dark": "0afc1069d334f10be2d0ed135ee0eb52e9272a1a1ad1224e00f05753b8789d0c",
"geometry-view-dark-fit": "3df272f6c3c3d20e9e375ea037f3dbb885657b94b0b29d0067505f4a73741237",
"geometry-view-light-fit": "a7f2c9667d9872dd84a37d5318fd238c9eabcc0f413017eb19e02204587b4e1a",
"geometry-plan-editor-dark": "19b5c84943b70074ba1aae51d1e54f9a59c44dde34fff89e32f0babb1e90a2ca",
"opening-placement-door-thick-wall-dark": "395c03bbf5d968e83664fd6621f0ac25902e718022f2e92ffbcddb8ce629cf9c",
"geometry-devices-editor-dark": "a9e4846ce5453400b87e6ad3d575882bc23a07b59dc3eecb612ed872b0c871ec",
"geometry-decor-editor-dark": "435b36096bbb2996d56ff0af262ddebff4a727edd841b0fad9b8d4f507b987ac",
"tray-wide-selection-en": "024ac666ac4dac01a36f7d1fdbf3bb41f76a627377f7c02dc373f429dfe96d43",
"tray-wide-tool-ru": "669fc1cf04433b936c0e9d05799ad19eda8bc003d5ab71937d0512c8f49ba921",
"tray-medium-group-en": "50cd3980b21f554bec2e76f4d2e8032f4bd30c0679477eb2f5cbe22f68935b7c",
"tray-medium-selection-ru": "4e5f235be8ed6296e136641d172e727a6a6b7a9d061f0928a96ccc1103c19f1f",
"tray-narrow-palette-en": "88b9846e4b451ed95b7ae7d2c3183a2ea191d7668768992a1364c6a7a53eb0c6",
"tray-narrow-tool-ru": "c4130715b3cb31c68619dfc706a3aa308e86edf272bfae6833b20666764ece2b",
"geometry-diagonal-45-opening-dark": "01206d25631c8fd09fa077932fbb5c1ee115b65ba76b38e6f7b09315dbbf6002",
"geometry-view-dark-fit": "438817c56cd8f91ef63778a3d3d22a4ee1fb675064bc24bf613fb752d6c503d9",
"geometry-view-light-fit": "667d38fd55a946ea6930b8674c4036e75c261ba4f111f893367ae72ed19500fc",
"washer-active-cycle-dark": "7597ffad91165f2f3cc9647e5081c03d4ffd722313bf74f8dcf5fe6dc52d1dcf",
"washer-idle-cycle-dark": "6b1de9786f4852659d2d453fa39340626a7944417f88c5a37355b62c458b6ac1",
"day-cycle-dawn-dark": "289e6edd6c206308d3257775b2948085a24698398995bc6ce72201745705c58c",
"day-cycle-day-dark": "df27409e83f4492d3f69dc6cebf2a6e7799e154698425b17fb3ab8cfddb426a6",
"day-cycle-dusk-dark": "9798e9dac27e69727adbe9c9a782a3bd3dc5dd0850e7a49852c069d54b382f9a",
"day-cycle-night-dark": "1abf9528ac05dc3a963eb19a8b3ed4b7bb9ce647f7b83dab4fd2e736756cd770",
"geometry-plan-editor-dark": "0e7b01e74c82ab5072bffbfaddfaa21b07089662b19f491a7c6783a4152af9fb",
"plan-snap-endpoint-light": "6187b35a780db09626bd9b8a06461dc133e4027722e965908811226f260d327a",
"plan-snap-line-gaps-dark": "5ae0634f63718be4b2c1fcabd2b474ecbc980fe96a3bdd2987ca8356472b1076",
"wall-junctions-plan-preview-light": "a8a04b5c611a880880b6b6d64d10eb50576a5a377c984a5b397075b225739bd5",
"wall-junctions-plan-t-dark": "479ab196fb52b332615182401e2680ccfbd4362f25b0a294b481b32195119d72",
"wall-junctions-view-dark": "73a64c65c8e77aa767bb7f401d68c61df5749e65e75273c85b0d6c72cb430766",
"isometric-wall-junctions-dark": "cb1e28f484b304ecda3e35f66e0c0429016d10a5a3022dcc9315a92eb537300f",
"opening-placement-door-thick-wall-dark": "4393770bb97cb051d76f5b2aba9d314a8789a20278ccafb31c048300ccb4d8cf",
"geometry-devices-editor-dark": "2ec2647797c71f6c6406f6e33fcbf70d5cb6d36a01a65c70ecabc7ad11e86183",
"geometry-decor-editor-dark": "46798a59d99982c4e73596861e20b6e466f6e7aa1d70a26bf917e1bd3bf73cb1",
"tray-wide-selection-en": "995477b887a302c25d8a0fe540a1b8ee56369e21100cdb367a3bf37c8dcc99c1",
"tray-wide-tool-ru": "0fa205ba48803255f8e0dc51286b7fc151ccfd319dfb932fd4927fff124d0396",
"tray-medium-group-en": "b6170877b12de6ff7b03e4c3a0099141a7fa010448bac914ce6dafe321594b83",
"tray-medium-selection-ru": "e40dcdadf3ebdbec200175dc6648382daa91a232d081c8add1f6646c7d1a0ee5",
"tray-narrow-palette-en": "861adc403fbf0aff1e45b27fc07c4856f3ca409b58ef87cc4a81d870a09dbb9a",
"tray-narrow-tool-ru": "a144a1927ab60a45630f95d36d163c9db1e5ff8714558b0703678be0695ec19e",
"geometry-diagonal-45-opening-dark": "dd93866af62313806a4444b707943c693e2b7709367156ebdc3b80168233bbe8",
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
@@ -45,15 +58,18 @@
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
"hover-nested-room-dark": "6c09526ad885c4555063def5b43287b41124a81d90972c425084dba6e622d055",
"hover-nested-room-dark": "2db78e53a76fa9b7cdc4597f23fc2c8ae439e5a56d82895109ca968d73075640",
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
"device-dialog-desktop-en": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
"toggle-entity-dialog-desktop-en": "013e2d01382435d995e8deece19e07e9bcced4fe80952cbab765e642b3d641d6",
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
"decor-color-popover-mobile-ru": "46d4c2e4dd20c3a38e90efe3db59b3e878bdbcf273fbc1aa23de4b230723fa6e",
"backup-full-preview-desktop-en": "cc42a621f55f043b272014b9c32127823ca3573520e502966c71642027f7fdaf",
"backup-space-preview-mobile-ru": "16d06859ea6aac6cbed586f0d6918da972e9c95206fffd771fe43b6084f79877"
"decor-color-popover-mobile-ru": "91837c71c52eb5447d9d1754038b559838af595d41f44e6587ea8f4c40f96ed9",
"backup-full-preview-desktop-en": "6f0cfecf587b73f38088d414f489b67cc68ed71c37c96d4e307e66f6d97bda9f",
"backup-plan-only-export-desktop-en": "1e1c8a9cc5383394b91e24af8342d92103a5e85b3e5c9b53c45ab0e9b0d57da4",
"backup-space-preview-mobile-ru": "a4719cbe29b378ef7baa63bb7ff23e201b2943025008d939f2c08cee63bb9038"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+173 -8
View File
@@ -1,7 +1,9 @@
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
const fixtureFor = (scenario) => scenario.fixture === 'large'
? makeLargeHouseFixture()
: makeVisualMatrixFixture({ applianceLifecycle: !!scenario.applianceLifecycle });
const themeVars = {
dark: {
@@ -50,11 +52,39 @@ async function stableEnvironment(page, scenario) {
/** Apply every data-only scenario override before the fixture crosses into the browser. */
export function prepareGoldenFixture(scenario) {
const fixture = fixtureFor(scenario.fixture);
const fixture = fixtureFor(scenario);
if (scenario.cornerSplitWall) {
const stage = scenario.cornerSplitWall;
if (!['before', 'thin', 'thick'].includes(stage))
if (!['before', 'thin', 'thick', 'zero-taper'].includes(stage))
throw new Error(`unknown cornerSplitWall stage: ${stage}`);
if (stage === 'zero-taper') {
const a = [0.10, 0.10], tr = [0.90, 0.10], split = [0.90, 0.405];
const br = [0.90, 0.80], notchBottom = [0.60, 0.80];
const notch = [0.60, 0.40], bl = [0.10, 0.40];
const entry = (from, to, cm) => ({
key: fixtureWallKey(from, to), a: [...from], b: [...to], cm,
});
fixture.config.spaces.push({
id: scenario.space,
name: 'Zero-depth angled Split',
rooms: [
{ id: 'zero-divider-main', name: 'Main room', area: null,
poly: [a, tr, split, notch, bl] },
{ id: 'zero-divider-child', name: 'New room', area: null,
poly: [split, br, notchBottom, notch] },
],
walls: [
entry(a, tr, 15), entry(tr, split, 15), entry(split, br, 15),
entry(br, notchBottom, 15), entry(notchBottom, notch, 15),
entry(notch, bl, 15), entry(bl, a, 15),
],
settings: {
show_borders: true, show_names: false,
fill_mode: 'custom', custom_fill: { c: '#536b82', a: 0.42 },
},
});
return fixture;
}
const a = [0.10, 0.10], tr = [0.90, 0.10], split = [0.90, 0.50];
const br = [0.90, 0.90], bl = [0.10, 0.90];
const entry = (from, to, cm) => ({
@@ -79,6 +109,36 @@ export function prepareGoldenFixture(scenario) {
settings: { show_borders: true, fill_mode: 'custom', custom_fill: { c: '#536b82', a: 0.42 } },
});
}
if (scenario.wallJunctions) {
const a = [0.06, 0.06], tr = [0.94, 0.06], br = [0.94, 0.94], bl = [0.06, 0.94];
const entry = (from, to, cm) => ({
key: fixtureWallKey(from, to), a: [...from], b: [...to], cm,
});
fixture.config.spaces.push({
id: scenario.space,
title: 'Wall junctions',
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: { fill_mode: 'none', show_borders: true, show_names: false },
rooms: [{ id: 'junction-room', name: 'Room', area: null, poly: [a, tr, br, bl] }],
walls: [entry(a, tr, 10), entry(tr, br, 10), entry(br, bl, 10), entry(bl, a, 10)],
partitions: [
{ id: 'junction-l-a', a: [0.16, 0.25], b: [0.38, 0.25], cm: 18 },
{ id: 'junction-l-b', a: [0.38, 0.25], b: [0.38, 0.46], cm: 30 },
{ id: 'junction-oblique-a', a: [0.58, 0.22], b: [0.78, 0.38], cm: 22 },
{ id: 'junction-oblique-b', a: [0.78, 0.38], b: [0.62, 0.52], cm: 14 },
{ id: 'junction-t-through', a: [0.18, 0.70], b: [0.78, 0.70], cm: 24 },
{ id: 'junction-t-branch', a: [0.50, 0.54], b: [0.50, 0.70], cm: 16 },
{ id: 'junction-room-branch', a: [0.30, 0.82], b: [0.30, 0.94], cm: 18 },
],
room_drafts: [{
id: 'junction-draft', points: [[0.16, 0.54], [0.30, 0.54], [0.30, 0.64]],
segments: [{ cm: 12 }, { cm: 20 }],
}],
wall_columns: [],
});
}
const requireSpace = () => {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
@@ -89,15 +149,18 @@ export function prepareGoldenFixture(scenario) {
throw new Error(`golden deviceName references missing device: ${scenario.deviceId || '<empty>'}`);
fixture.devices[scenario.deviceId].name = scenario.deviceName;
}
if (scenario.fillMode || typeof scenario.glowEnabled === 'boolean'
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean') {
if (scenario.fillMode || scenario.bgMode || typeof scenario.glowEnabled === 'boolean'
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean'
|| typeof scenario.northDeg === 'number') {
const space = requireSpace();
space.settings = {
...(space.settings || {}),
...(scenario.fillMode ? { fill_mode: scenario.fillMode } : {}),
...(scenario.bgMode ? { bg_mode: scenario.bgMode } : {}),
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
...(typeof scenario.showBorders === 'boolean' ? { show_borders: scenario.showBorders } : {}),
...(typeof scenario.northDeg === 'number' ? { north_deg: scenario.northDeg } : {}),
...(scenario.customFill ? { custom_fill: scenario.customFill } : {}),
};
}
@@ -282,6 +345,15 @@ 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;
@@ -312,12 +384,67 @@ export async function prepareGoldenScenario(page, scenario) {
card._setProjection('iso');
await card.updateComplete;
await frame();
await until(() => card._renderProjection === 'iso');
}
if (Number.isFinite(scenario.zoom)) {
card._applyView(scenario.zoom, 500, 500);
card.requestUpdate();
await card.updateComplete;
}
if (scenario.wallJunctionPreview) {
const { path, pointer, cms, cm } = scenario.wallJunctionPreview;
const validPoint = (point) => Array.isArray(point) && point.length === 2
&& point.every(Number.isFinite);
if (!Array.isArray(path) || !path.length || !path.every(validPoint)
|| !validPoint(pointer) || !Array.isArray(cms) || !cms.every(Number.isFinite)
|| !(Number(cm) > 0)) {
throw new Error(`invalid golden wallJunctionPreview contract: ${scenario.id}`);
}
card._tool = 'draw';
card._activeDraftId = null;
card._path = path.map((point) => [point[0] * 1000, point[1] * card._spaceH]);
card._draftSegmentCms = [...cms];
card._drawWallField = String(cm);
card._cursorPt = [pointer[0] * 1000, pointer[1] * card._spaceH];
card.requestUpdate();
await card.updateComplete;
await frame();
if (!card.renderRoot.querySelector('.drawwall-preview'))
throw new Error(`golden wall junction preview did not render: ${scenario.id}`);
}
if (scenario.planSnap) {
const { tool, anchor, pointer, expectedKind } = scenario.planSnap;
const validPoint = (point) => Array.isArray(point) && point.length === 2
&& point.every(Number.isFinite);
if (!['draw', 'partition'].includes(tool) || !validPoint(pointer)
|| (anchor != null && !validPoint(anchor))
|| !['endpoint', 'line'].includes(expectedKind)) {
throw new Error(`invalid golden planSnap contract: ${scenario.id}`);
}
card._tool = tool;
card._activeDraftId = null;
card._path = anchor ? [[anchor[0] * 1000, anchor[1] * card._spaceH]] : [];
card._clearPlanSnapHover();
card.requestUpdate();
await card.updateComplete;
await frame();
const svgRoot = card.renderRoot.querySelector('.stage svg');
const stage = card.renderRoot.querySelector('.stage');
const screen = new DOMPoint(pointer[0] * 1000, pointer[1] * card._spaceH)
.matrixTransform(svgRoot.getScreenCTM());
stage.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, composed: true, pointerId: 992, pointerType: 'mouse',
clientX: screen.x, clientY: screen.y,
}));
await card.updateComplete;
await frame();
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
const active = overlay?.querySelector('.plan-snap-node[data-active="true"]');
if (!overlay || active?.getAttribute('data-kind') !== expectedKind
|| overlay.querySelectorAll('.plan-snap-node[data-active="true"]').length !== 1) {
throw new Error(`golden plan snap candidate did not render: ${scenario.id}`);
}
}
if (scenario.openingPreview) {
const { type, pointer } = scenario.openingPreview;
if (!['window', 'door', 'gate'].includes(type)
@@ -453,6 +580,25 @@ export async function prepareGoldenScenario(page, scenario) {
if (visibleRole.top < visibleBody.top - 1 || visibleRadius.bottom > visibleBody.bottom + 1)
throw new Error('golden viewport does not show the complete device light-source controls');
}
if (scenario.deviceToggleEntity) {
const dialog = card.renderRoot.querySelector('hp-dialog');
const body = dialog?.querySelector('.body');
const select = dialog?.querySelector('#marker-toggle-entity');
const warning = dialog?.querySelector('.markertoggleentity [role="status"]');
const childLock = 'switch.golden_washer_child_lock';
if (!body || !select || select.options.length !== 3)
throw new Error('golden toggle-entity selector is incomplete');
if (scenario.deviceToggleEntity === 'selected'
&& (select.value !== childLock || warning))
throw new Error('golden selected toggle entity is not projected');
if (scenario.deviceToggleEntity === 'stale'
&& (select.value !== '' || !warning?.textContent?.includes('switch.golden_washer_removed')))
throw new Error('golden stale toggle entity warning is missing');
const bodyRect = body.getBoundingClientRect();
const selectRect = select.getBoundingClientRect();
body.scrollTop += selectRect.top - bodyRect.top - 12;
await frame();
}
if (scenario.openHelp) {
const help = card.renderRoot.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`);
await help?.updateComplete;
@@ -472,6 +618,13 @@ export async function prepareGoldenScenario(page, scenario) {
await dialog?.updateComplete;
dialog?.renderRoot?.querySelector('.close')?.focus();
}
} else if (scenario.dialog === 'backup-export-plan-only') {
card._openBackupExport();
card._backupExportDialog = {
...card._backupExportDialog, kind: 'space', planOnly: true,
};
card.requestUpdate();
await card.updateComplete;
} else if (scenario.dialog === 'backup-full' || scenario.dialog === 'backup-space') {
const full = scenario.dialog === 'backup-full';
card._backupImportDialog = {
@@ -479,10 +632,11 @@ export async function prepareGoldenScenario(page, scenario) {
size: 12345,
token: 'golden-token',
preview: {
kind: full ? 'full' : 'space', source: full ? 'foreign' : 'same',
kind: full ? 'full' : 'space', plan_only: !full,
source: full ? 'foreign' : 'same',
created_at: '2026-08-11T10:00:00Z', space_title: 'Ground (2)',
counts: { spaces: 1, rooms: 4, markers: 12, layout: 15 },
duplicates: full ? 0 : 2,
counts: { spaces: 1, rooms: 4, markers: full ? 12 : 0, layout: full ? 15 : 4 },
duplicates: 0,
confirmation_required: full,
content: full
? [{ url: '/api/houseplan/content/plans/_/ground.svg', state: 'detach_required' }]
@@ -510,6 +664,17 @@ export async function prepareGoldenScenario(page, scenario) {
trigger.click();
await picker.updateComplete;
}
if (scenario.dayCycle) {
const environment = card.renderRoot.querySelector('.hp-day-cycle-env');
const active = card.renderRoot.querySelector('.hp-day-cycle-bg.active');
const paper = card.renderRoot.querySelector('.hp-paperg');
if (environment?.dataset.dayCyclePhase !== scenario.dayCycle.phase
|| active?.dataset.dayCycleLayer !== scenario.dayCycle.phase
|| !active?.getAttribute('style')?.includes(scenario.dayCycle.top)
|| !paper) {
throw new Error(`golden day-cycle contract did not render: ${scenario.id}`);
}
}
await document.fonts?.ready;
await frame();
return {
+86 -9
View File
@@ -1,11 +1,15 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 19;
export const GOLDEN_MATRIX_VERSION = 26;
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 };
export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'split-corner-wall-before-dark', fixture: 'visual', space: 'golden-corner-split',
@@ -14,29 +18,85 @@ export const GOLDEN_SCENARIOS = Object.freeze([
cornerSplitWall: 'thin', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'split-corner-wall-thick-dark', fixture: 'visual', space: 'golden-corner-split',
cornerSplitWall: 'thick', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'split-zero-divider-taper-dark', fixture: 'visual', space: 'golden-zero-divider',
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', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
labs: ['iso'], projection: 'iso', theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-live-layers-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-no-borders-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
labs: ['iso'], projection: 'iso', showBorders: false, fillMode: 'custom',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, 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', kiosk: true,
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, 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', warmRemount: true,
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, 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 },
{ id: 'geometry-view-light-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'washer-active-cycle-dark', fixture: 'visual', applianceLifecycle: true,
space: 'golden-appliance', mode: 'view',
stateOverrides: { 'sensor.golden_washer_status': { state: 'start' } },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'washer-idle-cycle-dark', fixture: 'visual', applianceLifecycle: true,
space: 'golden-appliance', mode: 'view',
stateOverrides: { 'sensor.golden_washer_status': { state: 'done' } },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'day-cycle-dawn-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
bgMode: 'daynight', dayCycle: { phase: 'dawn', top: '#aabdd1' },
stateOverrides: { 'sun.sun': { attributes: { azimuth: 95, elevation: -2, rising: true } } },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'day-cycle-day-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
bgMode: 'daynight', dayCycle: { phase: 'day', top: '#dce9ef' },
stateOverrides: { 'sun.sun': { attributes: { azimuth: 180, elevation: 42, rising: false } } },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'day-cycle-dusk-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
bgMode: 'daynight', dayCycle: { phase: 'dusk', top: '#48536c' },
stateOverrides: { 'sun.sun': { attributes: { azimuth: 265, elevation: -2, rising: false } } },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'day-cycle-night-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
bgMode: 'daynight', dayCycle: { phase: 'night', top: '#111a27' },
stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'plan-snap-endpoint-light', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
planSnap: { tool: 'draw', pointer: [0.48, 0.48], expectedKind: 'endpoint' },
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'plan-snap-line-gaps-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
planSnap: {
tool: 'draw', anchor: [0.70, 0.455], pointer: [0.482, 0.455], expectedKind: 'line',
},
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'wall-junctions-plan-preview-light', fixture: 'visual',
space: 'golden-wall-junctions', wallJunctions: true, mode: 'plan',
wallJunctionPreview: {
path: [[0.16, 0.15], [0.38, 0.15]], pointer: [0.38, 0.25], cms: [12], cm: 24,
},
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'wall-junctions-plan-t-dark', fixture: 'visual',
space: 'golden-wall-junctions', wallJunctions: true, mode: 'plan',
wallJunctionPreview: {
path: [[0.50, 0.54]], pointer: [0.50, 0.70], cms: [], cm: 24,
},
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'wall-junctions-view-dark', fixture: 'visual',
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
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,
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
// complete 90 cm door preset while still proving rotation, inner-face
@@ -119,8 +179,8 @@ export const GOLDEN_SCENARIOS = Object.freeze([
// The golden screenshot is backed by a second, sun-layer-hidden capture.
// A real painted ray must account for enough changed pixels; DOM-only
// presence or an accidentally accepted empty baseline is not sufficient.
glowEnabled: false, allLightsOff: true,
stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: 24 } } },
glowEnabled: false, allLightsOff: true, northDeg: 90,
stateOverrides: { 'sun.sun': { attributes: { azimuth: 270, elevation: 24 } } },
sunRayPixels: { minPixels: 500, minChannelDelta: 4 },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...sunWindow },
{ id: 'lighting-fill-light-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
@@ -188,6 +248,20 @@ export const GOLDEN_SCENARIOS = Object.freeze([
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
deviceName: 'Контроллер освещения гостиной с намеренно очень длинным названием',
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
{ id: 'toggle-entity-dialog-desktop-en', fixture: 'visual', applianceLifecycle: true,
space: 'golden-appliance', dialog: 'device', deviceId: 'golden-washer',
deviceToggleEntity: 'selected', markerOverrides: [{
id: 'golden-washer', binding: 'device:golden-washer', tap_action: 'toggle',
toggle_entity: 'switch.golden_washer_child_lock',
}],
language: 'en', theme: 'light', viewport: { width: 1180, height: 1100 }, ...page },
{ id: 'toggle-entity-dialog-mobile-ru', fixture: 'visual', applianceLifecycle: true,
space: 'golden-appliance', dialog: 'device', deviceId: 'golden-washer',
deviceToggleEntity: 'stale', markerOverrides: [{
id: 'golden-washer', binding: 'device:golden-washer', tap_action: 'toggle',
toggle_entity: 'switch.golden_washer_removed',
}],
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
{ id: 'device-help-popover-light-ru', fixture: 'visual', space: 'golden-lighting',
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
openHelp: 'marker.glow_mode.help', helpTextRegion: { key: 'marker.glow_mode.help', minPixels: 30 },
@@ -198,6 +272,9 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'backup-full-preview-desktop-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'backup-full', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 900 }, ...page },
{ id: 'backup-plan-only-export-desktop-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'backup-export-plan-only', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 900 }, ...page },
{ id: 'backup-space-preview-mobile-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'backup-space', language: 'ru', theme: 'light',
viewport: { width: 390, height: 820 }, ...page },
+16 -4
View File
@@ -4,14 +4,25 @@
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. It enables
`hp-labs=iso`, 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. 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
`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.
Issue #137 adds `large-house-plan-snap-v1` without changing the meaning or
budgets of the original profile. The same 60-room/60-partition fixture gains
six saved open outlines, renders the Plan snap overlay, and sends 120 real
pointer moves across endpoint, line and miss targets. Candidate bundles fail
inside the runner if the static DOM or geometry cache grows, more than one
active node appears, endpoint/line paths are not both exercised, or config and
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.
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
@@ -110,6 +121,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
```
A local report is diagnostic only; it cannot replace the CI comparison.
@@ -0,0 +1,97 @@
{
"schema": 1,
"profile": "large-house-plan-snap-v1",
"minimumSamples": 7,
"timings": {
"modelReadyMs": {
"stat": "median",
"maxRegressionRatio": 0.3,
"noiseAllowanceMs": 200,
"hardMaxMs": 2500
},
"firstStableRenderMs": {
"stat": "median",
"maxRegressionRatio": 0.3,
"noiseAllowanceMs": 250,
"hardMaxMs": 3000
},
"planSnapPointerMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 250,
"hardMaxMs": 5000
},
"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
},
"heap": {
"required": true,
"hardMaxGrowthBytes": 67108864,
"maxRegressionRatio": 0.75,
"noiseAllowanceBytes": 16777216
},
"cacheEntries": {
"cleanFloor": 160,
"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
}
+16 -2
View File
@@ -33,24 +33,38 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
'_gridPitch',
'_loadOk',
'_model',
'_path',
'_rszDrag',
'_serverCfg',
'_settingsDialog',
'_tool',
]),
// A comparison SHA before #89 is intentionally flat; the isometric runner
// checks these two members only when the target source tree supports Stage 1.
optionalFields: Object.freeze(['_isoGeometryCache', '_setProjection']),
// checks these members only when the target source tree supports Stage 1.
optionalFields: Object.freeze([
'_activeDraftId', '_draftSegmentCms', '_isoGeometryCache', '_offerWallFaces',
'_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
'_wallFaceBatch', '_wallFaceGraphCache',
]),
fieldTypes: Object.freeze({
_booting: 'boolean',
_cleanFloorCache: 'map',
_devices: 'array',
_draftSegmentCms: 'array',
_glowClipCache: 'map',
_gridPitch: 'number',
_loadOk: 'boolean',
_model: 'array',
_onLabsSnapshot: 'function',
_offerWallFaces: 'function',
_path: 'array',
_isoGeometryCache: 'map',
_planSnapGeometryCache: 'object',
_roomDialog: 'boolean',
_setProjection: 'function',
_serverCfg: 'object',
_tool: 'string',
_wallFaceGraphCache: 'array',
}),
});
+9 -8
View File
@@ -1,4 +1,4 @@
// Three stills of the daynight scale (noon/sunset/night) for the white-day review.
// Four stills of the #146 day-cycle environment.
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const { page, browser } = await launch({ width: 700, height: 700 }, 1);
@@ -16,21 +16,22 @@ await page.evaluate(async () => {
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
c._cfgRev = (c._cfgRev || 0) + 1;
});
const shot = async (az, el, name) => {
await page.evaluate(async ([az, el]) => {
const shot = async (az, el, rising, name) => {
await page.evaluate(async ([az, el, rising]) => {
const c = window.__card;
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
attributes: { azimuth: az, elevation: el },
attributes: { azimuth: az, elevation: el, rising },
} } };
c.requestUpdate(); await c.updateComplete;
}, [az, el]);
}, [az, el, rising]);
await page.waitForTimeout(300);
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/${name}.png` });
};
await shot(180, 60, 'dn_noon');
await shot(265, 3, 'dn_sunset');
await shot(0, -20, 'dn_night');
await shot(95, -2, true, 'dn_dawn');
await shot(180, 60, false, 'dn_noon');
await shot(265, -2, false, 'dn_sunset');
await shot(0, -20, false, 'dn_night');
await browser.close();
console.log('shots done');
+64 -1
View File
@@ -19,6 +19,40 @@ const result = await page.evaluate(async () => {
await card.updateComplete;
const exportRadios = root().querySelectorAll('input[name="backup-kind"]').length;
const exportWarning = !!root().querySelector('.backupwarn');
const planOnlyHiddenForFull = !root().querySelector('.backupplanonly');
root().querySelector('input[name="backup-kind"][value="space"]')?.click();
await card.updateComplete;
const planOnly = root().querySelector('.backupplanonly input[type="checkbox"]');
planOnly?.click();
await card.updateComplete;
const planOnlySpace = {
visible: !!planOnly,
checked: card._backupExportDialog?.planOnly === true,
labelled: root().querySelector('.backupplanonly')?.textContent
.includes(card._t('backup.plan_only')) === true,
};
root().querySelector('input[name="backup-kind"][value="full"]')?.click();
await card.updateComplete;
const planOnlyResetForFull = card._backupExportDialog?.planOnly === false
&& !root().querySelector('.backupplanonly');
let sentExport = null;
card.hass = {
...card.hass,
callWS: async (message) => {
sentExport = message;
return { document: { smoke: true }, filename: 'plan-only-smoke.json' };
},
};
card._backupExportDialog = {
kind: 'space', planOnly: true, busy: false, error: '',
};
await card._runBackupExport();
const planOnlyRequest = {
kind: sentExport?.kind,
planOnly: sentExport?.plan_only,
space: sentExport?.space_id,
};
card._backupExportDialog = null;
card._backupImportDialog = {
@@ -40,7 +74,31 @@ const result = await page.evaluate(async () => {
noHorizontalOverflow: root().querySelector('hp-dialog').scrollWidth
<= root().querySelector('hp-dialog').clientWidth,
};
return { group, actions, keyboardImport, exportRadios, exportWarning, importSafe };
card._backupImportDialog = {
filename: 'houseplan-space-plan-only.json', size: 2048, token: 'plan-only',
preview: {
kind: 'space', plan_only: true, source: 'same',
created_at: '2026-08-17T00:00:00Z', space_title: 'Ground (2)',
counts: { spaces: 1, rooms: 2, markers: 0, layout: 2 },
bindings: { device: 0, entity: 0, virtual: 0, active: 0, disabled: 0, missing: 0 },
duplicates: 0, confirmation_required: false, content: [],
},
expectedConfigRev: 1, expectedLayoutRev: 2, duplicatePolicy: 'skip',
confirmMissing: false, busy: false, error: '',
};
await card.updateComplete;
const planOnlyPreview = {
visible: root().querySelector('.backupplanonlystatus')?.textContent
=== card._t('backup.plan_only_preview'),
noDuplicatePolicy: !root().querySelector('.backupchoices'),
noHorizontalOverflow: root().querySelector('hp-dialog').scrollWidth
<= root().querySelector('hp-dialog').clientWidth,
};
return {
group, actions, keyboardImport, exportRadios, exportWarning,
planOnlyHiddenForFull, planOnlySpace, planOnlyResetForFull, planOnlyRequest,
importSafe, planOnlyPreview,
};
});
checkAll(result, {
@@ -49,6 +107,11 @@ checkAll(result, {
keyboardImport: true,
exportRadios: 2,
exportWarning: true,
planOnlyHiddenForFull: true,
planOnlySpace: { visible: true, checked: true, labelled: true },
planOnlyResetForFull: true,
planOnlyRequest: { kind: 'space', planOnly: true, space: 'f1' },
importSafe: { danger: true, disabledUntilConfirmed: true, noHorizontalOverflow: true },
planOnlyPreview: { visible: true, noDuplicatePolicy: true, noHorizontalOverflow: true },
});
await finish(browser, result);
+44 -10
View File
@@ -103,6 +103,25 @@ const res = await page.evaluate(async () => {
await settleMode();
out.viewPaintedAgain = (await waitStageBg((value) => value === rgb('#0a2a4a'))) === rgb('#0a2a4a');
// The environment, plan and zoom badge own explicit stacking levels. DOM
// order alone let an opaque cross-fade layer cover the plan, while raising
// only the plan hid the later percentage badge behind it.
const previousBgMode = c._serverCfg.settings.bg_mode;
c._serverCfg.settings.bg_mode = 'daynight';
c._zoom = 2.5;
c.requestUpdate(); await c.updateComplete;
const zoomWrap = sr().querySelector('.zoomwrap');
const zoomBadge = sr().querySelector('.zoombadge');
const environment = sr().querySelector('.hp-day-cycle-env');
out.dayCycleStaysBehindPlan = getComputedStyle(environment).zIndex === '0'
&& getComputedStyle(zoomWrap).zIndex === '1';
out.zoomBadgeStaysAbovePlan = !!zoomBadge
&& getComputedStyle(zoomBadge).zIndex === '12';
if (previousBgMode === undefined) delete c._serverCfg.settings.bg_mode;
else c._serverCfg.settings.bg_mode = previousBgMode;
c._zoom = 1;
c.requestUpdate(); await c.updateComplete;
// 9) kiosk instance respects the setting
const k = document.createElement('houseplan-card');
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
@@ -112,19 +131,27 @@ const res = await page.evaluate(async () => {
await new Promise((r) => setTimeout(r, 300));
// the kiosk instance re-reads the pristine demo config — inject the color
// into ITS copy; what matters here is the kiosk rendering path
k._serverCfg.settings = { ...(k._serverCfg.settings || {}), bg_color: '#0a2a4a' };
k.hass = { ...c.hass };
k._serverCfg.settings = { ...(k._serverCfg.settings || {}), bg_color: '#0a2a4a', bg_mode: 'daynight' };
k.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 180, elevation: 42, rising: false },
} } };
k.requestUpdate();
await k.updateComplete;
const kStage = (k.shadowRoot || k.renderRoot).querySelector('.stage');
out.kioskApplies = !!kStage && getComputedStyle(kStage).backgroundColor === rgb('#0a2a4a');
out.kioskSharesDay = kStage?.classList.contains('phase-day')
&& kStage.querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'day';
k.remove();
// 10) the static space-card paints the same background
await customElements.whenDefined('houseplan-space-card');
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.settings = { ...(cfg.settings || {}), bg_color: '#123456' };
const hass2 = { ...c.hass, callWS: async (m) => {
cfg.settings = { ...(cfg.settings || {}), bg_color: '#123456', bg_mode: 'daynight' };
const hass2 = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'below_horizon',
attributes: { azimuth: 95, elevation: -2, rising: true },
} }, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {} };
return origWS.call(c.hass, m);
@@ -140,6 +167,11 @@ const res = await page.evaluate(async () => {
await sc.updateComplete;
const st = sc.renderRoot.querySelector('.hp-static-stage');
out.staticCardApplies = !!st && getComputedStyle(st).backgroundColor === rgb('#123456');
out.staticCardSharesDawn = st?.classList.contains('phase-dawn')
&& st.querySelector('.hp-day-cycle-env')?.dataset.dayCycleSource === 'sun'
&& st.querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'dawn';
out.staticCardLayersStayOrdered = getComputedStyle(st.querySelector('svg')).zIndex === '1'
&& getComputedStyle(st.querySelector('.devlayer')).zIndex === '2';
// ...and the space override beats the global there too
cfg.spaces.find((s) => s.id === c._space).settings = { bg_color: '#654321' };
sc.hass = { ...hass2 };
@@ -166,8 +198,8 @@ checkAll(res);
// bounding box (section 12), and never the backdrop image rect either
// (docs/BACKDROP.md §3). The demo's f1 IS an image plan, so this section now
// asserts the new rule on exactly the case that used to be the exception.
// The scene colour is visible ONLY around the paper; the 'daynight' night
// dims the paper via brightness only.
// The scene colour is visible ONLY around the paper. The four-phase
// environment changes only outside it and adds an alpha-aware outer outline.
const paper = await page.evaluate(async () => {
const out = {};
const c = window.__card;
@@ -210,16 +242,18 @@ const paper = await page.evaluate(async () => {
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
c._setMode('view');
await upd(); await settleMode();
// daynight night: the paper dims via the brightness filter ONLY, never alpha
// daynight night: the paper stays pixel-identical and opaque; only its
// grouped outer outline and the environment may change.
c._serverCfg.settings.bg_mode = 'daynight';
c._serverCfg.settings.north_deg = 0;
c._cfgRev++;
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'below_horizon',
attributes: { azimuth: 180, elevation: -20 },
attributes: { azimuth: 180, elevation: -20, rising: false },
} } };
await upd();
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
out.nightDoesNotDimPlan = !(sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.');
out.nightEnvironment = sr().querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'night';
const pn = sr().querySelector('.stage svg .hp-paper');
out.nightPaperStaysOpaque = !!pn && getComputedStyle(pn).fillOpacity === '1' && getComputedStyle(pn).opacity === '1';
delete c._serverCfg.settings.bg_mode;
@@ -268,7 +302,7 @@ const drawn = await page.evaluate(async () => {
return cs.stroke === 'none' || cs.strokeOpacity === '0' || cs.strokeWidth === '0px';
});
// paper first, everything else on top of it (one .hp-paperg group wraps
// all paper shapes so the daynight drop shadow composites without seams)
// all paper shapes so the day-cycle outline composites without seams)
const noplanSvg = sr().querySelector('.stage.noplan svg');
const noplanLayers = [...noplanSvg.children].find((node) => node.querySelector?.('.hp-paperg')) || noplanSvg;
const first = [...noplanLayers.children].find((node) => node.tagName.toLowerCase() !== 'defs');
+215
View File
@@ -0,0 +1,215 @@
// #140: opening and physical-object properties share the existing medium
// dialog shell. Measure real localized buttons rather than merely checking
// the `wide` attribute, because the contract is usable spare width plus safe
// wrapping when the viewport becomes narrow.
import { launch, checkAll, finish } from './serve.mjs';
const DESKTOP_WIDTH = 1000;
const NARROW_WIDTH = 320;
const { page, browser } = await launch({ width: DESKTOP_WIDTH, height: 820 }, 1);
const desktop = await page.evaluate(async () => {
const card = window.__card;
const settle = async () => {
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
card._serverStorage = true;
card._serverCfg = {
spaces: [{
id: 'dialog-layout', title: 'Dialog layout', cell_cm: 5,
view_box: [0, 0, 1, 1], rooms: [], settings: {},
}],
markers: [], settings: {},
};
card._space = 'dialog-layout';
card._mode = 'plan';
card._modelCache = null;
card._saveConfig = () => {};
await settle();
const layout = (kind, language) => {
const root = card.shadowRoot || card.renderRoot;
const dialog = root.querySelector('hp-dialog');
const surface = dialog?.shadowRoot?.querySelector('.surface');
const footer = dialog?.querySelector('.dialog-action-footer');
const danger = footer?.querySelector('.dialog-action-danger');
const commit = footer?.querySelector('.dialog-action-commit');
const surfaceRect = surface?.getBoundingClientRect();
const footerRect = footer?.getBoundingClientRect();
const footerStyle = footer ? getComputedStyle(footer) : null;
const dangerRect = danger?.getBoundingClientRect();
const commitRect = commit?.getBoundingClientRect();
const buttons = [...(footer?.querySelectorAll('button') || [])];
const innerWidth = footerRect && footerStyle
? footerRect.width - parseFloat(footerStyle.paddingLeft) - parseFloat(footerStyle.paddingRight)
: 0;
const contentWidth = (dangerRect?.width || 0) + (commitRect?.width || 0);
return {
kind,
language,
widePreset: dialog?.wide === true && dialog.hasAttribute('wide'),
surfaceWidth: Number((surfaceRect?.width || 0).toFixed(2)),
innerWidth: Number(innerWidth.toFixed(2)),
contentWidth: Number(contentWidth.toFixed(2)),
spareWidth: Number((innerWidth - contentWidth).toFixed(2)),
buttons: buttons.length,
oneRow: !!dangerRect && !!commitRect && Math.abs(dangerRect.top - commitRect.top) <= 1,
noHorizontalOverflow: !!surface && !!footer
&& surface.scrollWidth <= surface.clientWidth + 1
&& footer.scrollWidth <= footer.clientWidth + 1,
};
};
const open = async (kind, language) => {
card._config = { ...(card._config || {}), language };
card._openingDialog = null;
card._physicalDialog = null;
card._spaceDialog = null;
if (kind === 'opening') {
card._openingDialog = {
id: 'opening-1', type: 'door', lengthCm: 90,
contact: '', lock: '', invert: false, flipH: false, flipV: false,
x: 200, y: 200, angle: 0,
};
} else if (kind === 'physical') {
card._physicalDialog = {
kind: 'partition', id: 'partition-1', cm: '12', length: '1 m',
};
} else {
card._openSpaceDialog('edit', 'dialog-layout');
}
await settle();
const result = layout(kind, language);
card._openingDialog = null;
card._physicalDialog = null;
card._spaceDialog = null;
await settle();
return result;
};
const metrics = [];
for (const language of ['en', 'ru']) {
metrics.push(await open('opening', language));
metrics.push(await open('physical', language));
}
const space = await open('space', 'ru');
return { metrics, space };
});
await page.setViewportSize({ width: NARROW_WIDTH, height: 820 });
const narrow = await page.evaluate(async () => {
const card = window.__card;
const settle = async () => {
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const measure = async (kind) => {
card._config = { ...(card._config || {}), language: 'ru' };
card._openingDialog = null;
card._physicalDialog = null;
card._spaceDialog = null;
if (kind === 'opening') {
card._openingDialog = {
id: 'opening-1', type: 'door', lengthCm: 90,
contact: '', lock: '', invert: false, flipH: false, flipV: false,
x: 200, y: 200, angle: 0,
};
} else if (kind === 'physical') {
card._physicalDialog = {
kind: 'partition', id: 'partition-1', cm: '12', length: '1 m',
};
} else if (kind === 'draft') {
card._physicalDialog = {
kind: 'draft', id: 'draft-1', segment: 0, cm: '12', length: '1 m',
};
} else {
card._openSpaceDialog('edit', 'dialog-layout');
}
await settle();
const root = card.shadowRoot || card.renderRoot;
const dialog = root.querySelector('hp-dialog');
const surface = dialog?.shadowRoot?.querySelector('.surface');
const footer = dialog?.querySelector('.dialog-action-footer');
const danger = footer?.querySelector('.dialog-action-danger');
const commit = footer?.querySelector('.dialog-action-commit');
const surfaceRect = surface?.getBoundingClientRect();
const footerRect = footer?.getBoundingClientRect();
const footerStyle = footer ? getComputedStyle(footer) : null;
const dangerRect = danger?.getBoundingClientRect();
const commitRect = commit?.getBoundingClientRect();
const innerLeft = footerRect && footerStyle
? footerRect.left + parseFloat(footerStyle.paddingLeft) : 0;
const innerRight = footerRect && footerStyle
? footerRect.right - parseFloat(footerStyle.paddingRight) : 0;
const buttons = [...(footer?.querySelectorAll('button') || [])];
const result = {
kind,
surfaceWidth: Number((surfaceRect?.width || 0).toFixed(2)),
insideViewport: !!surfaceRect && surfaceRect.left >= -1
&& surfaceRect.right <= innerWidth + 1,
noHorizontalOverflow: !!surface && !!footer
&& surface.scrollWidth <= surface.clientWidth + 1
&& footer.scrollWidth <= footer.clientWidth + 1,
buttonsContained: !!footerRect && buttons.every((button) => {
const rect = button.getBoundingClientRect();
return rect.left >= innerLeft - 1 && rect.right <= innerRight + 1
&& rect.top >= footerRect.top - 1 && rect.bottom <= footerRect.bottom + 1;
}),
wrapped: !!dangerRect && !!commitRect && commitRect.top >= dangerRect.bottom - 1,
buttons: buttons.length,
};
card._openingDialog = null;
card._physicalDialog = null;
card._spaceDialog = null;
await settle();
return result;
};
return {
opening: await measure('opening'),
physical: await measure('physical'),
draft: await measure('draft'),
space: await measure('space'),
};
});
const checks = {};
for (const metric of desktop.metrics) {
const key = `${metric.kind}_${metric.language}`;
checks[`${key}_medium_shell`] = metric.widePreset
&& metric.surfaceWidth >= 499 && metric.surfaceWidth <= 501;
checks[`${key}_three_actions_one_row`] = metric.buttons === 3 && metric.oneRow;
checks[`${key}_positive_localization_headroom`] = metric.spareWidth > 0;
checks[`${key}_no_horizontal_overflow`] = metric.noHorizontalOverflow;
}
checks.space_dialog_still_uses_medium_shell = desktop.space.widePreset
&& desktop.space.surfaceWidth >= 499 && desktop.space.surfaceWidth <= 501;
checks.space_dialog_footer_not_regressed = desktop.space.buttons === 3
&& desktop.space.oneRow && desktop.space.noHorizontalOverflow;
for (const kind of ['opening', 'physical']) {
const metric = narrow[kind];
checks[`${kind}_narrow_fits_viewport`] = metric.surfaceWidth <= NARROW_WIDTH * 0.94 + 1
&& metric.insideViewport && metric.noHorizontalOverflow && metric.buttonsContained;
checks[`${kind}_narrow_keeps_responsive_wrap`] = metric.buttons === 3 && metric.wrapped;
}
checks.draft_four_actions_remain_contained = narrow.draft.buttons === 4
&& narrow.draft.insideViewport && narrow.draft.noHorizontalOverflow
&& narrow.draft.buttonsContained;
checks.space_narrow_not_regressed = narrow.space.buttons === 3
&& narrow.space.insideViewport && narrow.space.noHorizontalOverflow
&& narrow.space.buttonsContained;
checkAll(checks);
await finish(browser, {
checks,
desktop: {
dialogs: desktop.metrics,
longest: desktop.metrics.reduce((longest, metric) => (
metric.contentWidth > longest.contentWidth ? metric : longest
)),
space: desktop.space,
},
narrow,
});
+1 -1
View File
@@ -22,7 +22,7 @@ const res = await page.evaluate(async () => {
out.fieldTrayIsStageOverlay = field?.closest('.editor-secondary-host')?.closest('.stage')
=== sr().querySelector('.stage');
const wallsButton = sr().querySelector('.editbar button.on');
out.drawButtonNamedRoomOutline = ['Room outline', 'Контур комнаты']
out.drawButtonNamedWalls = ['Walls', 'Стены']
.includes((wallsButton?.textContent || '').trim());
// draw a small room away from existing rooms (garden/demo f1 may have rooms)
+9
View File
@@ -5,6 +5,13 @@ const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c._openSpaceDialog('create');
out.newSpaceUsesDaynight = c._spaceDialog?.bgMode === 'daynight';
c._spaceDialog = null;
c._importQueue = ['Imported floor'];
c._openNextImport();
out.floorImportUsesDaynight = c._spaceDialog?.bgMode === 'daynight';
c._spaceDialog = null;
// 1) диалог общих настроек: инвентарь строк и групп (обновлён фичей «Солнце»)
c._openSettingsDialog(); await c.updateComplete;
out.rows = sr().querySelectorAll('.gsrow').length;
@@ -67,6 +74,8 @@ checkAll(res, {
{ "href": "https://t.me/ha_houseplan", "target": "_blank", "rel": "noopener" },
],
"saved": {"c": "#ff00ff", "a": 0.5},
"newSpaceUsesDaynight": true,
"floorImportUsesDaynight": true,
"lqiBefore": 7,
"lqiAfter": 0,
});
+35 -9
View File
@@ -431,20 +431,44 @@ const rasterFixture = await page.evaluate(async () => {
const away = Math.hypot(centre[0] - sourcePos.x, centre[1] - sourcePos.y);
const base = Math.atan2(centre[1] - sourcePos.y, centre[0] - sourcePos.x);
const half = Math.asin(Math.max(0.02, Math.min(0.9, radius / away)));
const at = (angle, distance) => toStage([
const atPlan = (angle, distance) => [
sourcePos.x + Math.cos(angle) * distance,
sourcePos.y + Math.sin(angle) * distance,
]);
];
const far = away + radius + Math.max(10, c._gridPitch);
const insidePlan = [atPlan(base, far), atPlan(base, away + radius * 2.2)];
const besidePlan = [atPlan(base + half * 4, far), atPlan(base - half * 4, far)];
const edgePlan = Array.from({ length: 41 }, (_, index) => {
const offset = (index - 20) * 1.0;
const angle = base + half + Math.atan2(offset, far);
return { offset, point: atPlan(angle, far) };
});
const distanceToSegment = (point, a, b) => {
const dx = b[0] - a[0], dy = b[1] - a[1];
const length2 = dx * dx + dy * dy;
const share = length2 > 0
? Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / length2))
: 0;
return Math.hypot(point[0] - (a[0] + dx * share), point[1] - (a[1] + dy * share));
};
const rooms = c._spaceModel().rooms;
const renderedWalls = c._spaceWalls || [];
const sampleClearance = Math.max(2, c._gridPitch * 0.5);
const liesOnCleanFloor = (point) => {
const insideRoom = rooms.some((room) => c._pointInRoom(point, room));
const outsideColumn = Math.hypot(point[0] - centre[0], point[1] - centre[1])
>= radius + sampleClearance;
const outsideWalls = renderedWalls.every((wall) =>
distanceToSegment(point, wall.a, wall.b) >= wallDepth / 2 + sampleClearance);
return insideRoom && outsideColumn && outsideWalls;
};
const samplePoints = [...insidePlan, ...besidePlan, ...edgePlan.map((sample) => sample.point)];
shadow = {
inside: [at(base, far), at(base, away + radius * 2.2)],
beside: [at(base + half * 4, far), at(base - half * 4, far)],
inside: insidePlan.map(toStage),
beside: besidePlan.map(toStage),
samplesAreValid: samplePoints.every(liesOnCleanFloor),
// Across the shadow edge, one plan unit at a time.
edge: Array.from({ length: 41 }, (_, index) => {
const offset = (index - 20) * 1.0;
const angle = base + half + Math.atan2(offset, far);
return { offset, point: at(angle, far) };
}),
edge: edgePlan.map(({ offset, point }) => ({ offset, point: toStage(point) })),
};
}
return { ready: true, litParts, sourceEid, sourceId: source.id, wallSource, doors, shadow };
@@ -546,6 +570,7 @@ if (rasterFixture.ready) {
&& metrics.doors.every((door) => door.visibleWidth <= door.openingLength * 2);
res.apertureItselfLit = metrics.doors.length === 2
&& metrics.doors.every((door) => door.tunnelDelta >= 8);
res.shadowSamplesAreValid = rasterFixture.shadow?.samplesAreValid === true;
// An opaque body leaves no light behind it, keeps the floor beside it lit,
// and the border between the two is a line rather than a soft ramp.
res.occluderCastsShadow = !!metrics.shadow
@@ -591,6 +616,7 @@ if (rasterFixture.ready) {
res.doorwayCarriesLight = false;
res.beamWidthBounded = false;
res.apertureItselfLit = false;
res.shadowSamplesAreValid = false;
res.occluderCastsShadow = false;
res.shadowEdgeIsCrisp = false;
res.sourceInsideWallSuppressed = false;
+6
View File
@@ -13,6 +13,12 @@ const out = await page.evaluate(async () => {
const enable = async () => {
history.replaceState(null, '', '#space=f1&hp-labs=iso');
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();
};
+7
View File
@@ -11,6 +11,12 @@ const out = await page.evaluate(async () => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
history.replaceState(null, '', '?hp-labs=iso#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 = {
@@ -293,6 +299,7 @@ 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);
+332
View File
@@ -0,0 +1,332 @@
// #174: a manual-eligible virtual lamp becomes a real, HA-owned light while
// another marker controls it. State, actions and touch gestures must all use
// the same incoming controller drivers; unlink restores the dormant #107 bit.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1200, height: 900 });
const result = await page.evaluate(async () => {
const card = window.__card;
const sourceId = 'linked_virtual_lamp';
const controllerId = 'linked_wall_relay';
const driverId = 'switch.kettle';
const spaceId = 'f1';
const roomId = 'r2';
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const frame = () => new Promise((resolve) => requestAnimationFrame(
() => requestAnimationFrame(resolve),
));
const until = async (predicate, timeout = 6000) => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error('linked-light smoke timed out');
await wait(25);
}
};
const serverConfig = structuredClone(card._serverCfg);
const floor = serverConfig.spaces.find((space) => space.id === spaceId);
floor.settings = {
...(floor.settings || {}),
show_names: true,
fill_mode: 'light',
glow_enabled: true,
label_light: true,
};
serverConfig.markers = [...(serverConfig.markers || []), {
id: controllerId,
name: 'Linked wall relay',
binding: 'device:d_kettle',
tap_action: 'toggle',
tap_confirm: false,
controls: [`marker:${sourceId}`],
space: spaceId,
area: 'kitchen',
room_id: roomId,
}, {
id: sourceId,
name: 'Linked virtual lamp',
binding: 'virtual',
is_light: true,
tap_action: 'toggle',
tap_confirm: false,
space: spaceId,
area: 'kitchen',
room_id: roomId,
}];
const serverLayout = {
...card._layout,
[controllerId]: { s: spaceId, x: 0.64, y: 0.28 },
[sourceId]: { s: spaceId, x: 0.82, y: 0.28 },
};
const virtual = {
rev: 4,
config_rev: card._cfgRev,
off: [sourceId],
};
const serviceCalls = [];
let operationalToggleCalls = 0;
const baseHass = card.hass;
const baseCallWS = baseHass.callWS.bind(baseHass);
let testHass;
const callWS = async (message) => {
if (message.type === 'houseplan/config/get') return {
config: serverConfig,
rev: virtual.config_rev,
can_write: true,
virtual_lights: structuredClone(virtual),
};
if (message.type === 'houseplan/layout/get') return {
layout: serverLayout,
rev: card._layoutRev,
};
if (message.type === 'houseplan/virtual_light/toggle') {
operationalToggleCalls++;
const off = new Set(virtual.off);
if (off.has(message.marker_id)) off.delete(message.marker_id);
else off.add(message.marker_id);
virtual.off = [...off].sort();
virtual.rev++;
return {
marker_id: message.marker_id,
on: !off.has(message.marker_id),
rev: virtual.rev,
};
}
return baseCallWS(message);
};
testHass = {
...baseHass,
states: {
...baseHass.states,
[driverId]: { ...baseHass.states[driverId], state: 'on' },
},
callService: async (domain, service, data) => {
serviceCalls.push({ domain, service, data: structuredClone(data) });
},
callWS,
};
card._unsubVirtual?.();
card._unsubVirtual = null;
card.hass = testHass;
await card._reloadConfigOnly(true);
card._layout = serverLayout;
card._space = spaceId;
card._regSignature = '';
card._maybeRebuildDevices();
card._setMode('view');
await until(() => card._devices.some((device) => device.id === sourceId)
&& card._devices.some((device) => device.id === controllerId));
await card.updateComplete;
await frame();
const item = (id) => card._devices.find((device) => device.id === id);
const node = (id) => card.renderRoot.querySelector(`.dev[data-id="${id}"]`);
const isOn = (id) => card._stateClass(item(id)).includes('on');
const roomVisual = () => {
const fill = card._resolvedRoomFills(
card._spaceModel(), card._spaceDisplayForRender(),
).byId.get(roomId);
const label = card.renderRoot.querySelector(`.roomlabel[data-id="${roomId}"] .rlm`);
return {
fill: fill?.color || null,
labelExists: !!label,
labelLit: !!label?.classList.contains('lit'),
labelText: label?.textContent?.trim() || '',
glow: card._glowRenderedSources.has(`${spaceId}|${sourceId}`),
};
};
const setDriver = async (state) => {
testHass = {
...testHass,
states: {
...testHass.states,
[driverId]: { ...testHass.states[driverId], state },
},
callService: testHass.callService,
callWS,
};
card.hass = testHass;
await card.updateComplete;
await frame();
try {
await until(() => isOn(sourceId) === (state === 'on'));
} catch (error) {
throw new Error(`linked driver state did not settle: ${JSON.stringify({
expected: state,
live: card.hass?.states?.[driverId]?.state,
rendered: card._renderPlanHass?.states?.[driverId]?.state,
sourceClass: card._stateClass(item(sourceId)),
controllerClass: card._stateClass(item(controllerId)),
continuity: card._continuity?.state,
})}`, { cause: error });
}
};
const click = async (id) => {
node(id).dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await wait(0);
};
const targetsOf = (call) => {
const value = call?.data?.entity_id;
return Array.isArray(value) ? value : value ? [value] : [];
};
const initial = roomVisual();
const displayHasLightFill = card._spaceDisplayForRender().fill === 'light';
const displayHasLightLabel = card._spaceDisplayForRender().labelLight === true;
const initialSourceOn = isOn(sourceId);
const initialControllerOn = isOn(controllerId);
const linkedManualOffFollowsDriver = isOn(sourceId) && isOn(controllerId)
&& initial.glow && initial.labelLit && !!initial.fill;
// Source click issues a real HA command. Resolving that promise is not an
// optimistic state change: every visual waits for the next HA snapshot.
await click(sourceId);
const sourceCall = serviceCalls.at(-1);
const sourceCallsRelay = sourceCall?.service === 'turn_off'
&& JSON.stringify(targetsOf(sourceCall)) === JSON.stringify([driverId]);
const noOptimisticVisual = isOn(sourceId) && roomVisual().glow;
await setDriver('off');
await wait(560); // Glow keeps its DOM node for the deliberate fade-out.
await card.updateComplete;
const off = roomVisual();
const offSource = !isOn(sourceId);
const offController = !isOn(controllerId);
const offTickUpdatesAll = !isOn(sourceId) && !isOn(controllerId)
&& !off.glow && !off.labelLit && off.labelText !== initial.labelText
&& off.fill !== initial.fill;
await click(controllerId);
const controllerCall = serviceCalls.at(-1);
const controllerCallsRelay = controllerCall?.service === 'turn_on'
&& JSON.stringify(targetsOf(controllerCall)) === JSON.stringify([driverId]);
await setDriver('on');
const relit = roomVisual();
const relitSource = isOn(sourceId);
const relitController = isOn(controllerId);
const controllerTickUpdatesAll = relitSource && relitController
&& relit.glow && relit.labelLit;
const beforeAutomation = serviceCalls.length;
await setDriver('off');
const externalStateUpdateWorks = serviceCalls.length === beforeAutomation
&& !isOn(sourceId) && !isOn(controllerId) && !roomVisual().labelLit;
// One short touch tap is one service call and still waits for HA state.
const sourceNode = node(sourceId);
const rect = sourceNode.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const pointer = (type, id, target, px = x, py = y, buttons = 0) => target.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
pointerId: id,
pointerType: 'touch',
clientX: px,
clientY: py,
button: type === 'pointerdown' ? 0 : -1,
buttons,
}),
);
const beforeTap = serviceCalls.length;
pointer('pointerdown', 17401, sourceNode, x, y, 1);
pointer('pointerup', 17401, sourceNode, x, y, 0);
sourceNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await wait(0);
const touchTapCallsOnce = serviceCalls.length === beforeTap + 1
&& serviceCalls.at(-1)?.service === 'turn_on' && !isOn(sourceId);
// Long-press opens the existing card; its synthetic click is swallowed.
const beforeLongPress = serviceCalls.length;
pointer('pointerdown', 17402, sourceNode, x, y, 1);
await wait(680);
const longPressOpenedInfo = !!card._infoCard;
pointer('pointerup', 17402, sourceNode, x, y, 0);
sourceNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
const longPressNoService = serviceCalls.length === beforeLongPress;
card._closeInfoCard();
const beforeCancel = serviceCalls.length;
pointer('pointerdown', 17403, sourceNode, x, y, 1);
pointer('pointercancel', 17403, sourceNode, x, y, 0);
const pointerCancelNoService = serviceCalls.length === beforeCancel && !isOn(sourceId);
// A moved one-finger gesture and a two-finger sequence both suppress the
// immediately following synthetic click on the marker.
const beforePan = serviceCalls.length;
pointer('pointerdown', 17404, sourceNode, x, y, 1);
pointer('pointermove', 17404, sourceNode, x + 70, y + 45, 1);
pointer('pointerup', 17404, sourceNode, x + 70, y + 45, 0);
sourceNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
const panNoService = serviceCalls.length === beforePan;
await wait(20);
const beforePinch = serviceCalls.length;
const stage = card._stageEl;
pointer('pointerdown', 17405, sourceNode, x, y, 1);
pointer('pointerdown', 17406, stage, x + 100, y, 1);
pointer('pointermove', 17405, sourceNode, x - 35, y, 1);
pointer('pointermove', 17406, stage, x + 135, y, 1);
pointer('pointerup', 17405, sourceNode, x - 35, y, 0);
pointer('pointerup', 17406, stage, x + 135, y, 0);
sourceNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
const pinchNoService = serviceCalls.length === beforePinch;
await wait(520);
// Removing the last relation exposes the preserved manual off-bit. The
// same lamp then returns to #107 and uses operational WS, not HA service.
const controllerCfg = serverConfig.markers.find((marker) => marker.id === controllerId);
controllerCfg.controls = [];
await card._reloadConfigOnly(true);
card._layout = serverLayout;
card._regSignature = '';
card._maybeRebuildDevices();
await card.updateComplete;
await until(() => !isOn(sourceId));
const unlinkRestoresManualOff = !isOn(sourceId) && virtual.off.includes(sourceId);
const beforeManualService = serviceCalls.length;
await click(sourceId);
await until(() => isOn(sourceId));
const manualToggleRestored = operationalToggleCalls === 1
&& serviceCalls.length === beforeManualService && !virtual.off.includes(sourceId);
return {
linkedManualOffFollowsDriver,
initialSourceOn,
initialControllerOn,
initialGlow: initial.glow,
initialRoomLabelLit: initial.labelLit,
initialRoomLabelExists: initial.labelExists,
initialRoomFill: !!initial.fill,
displayHasLightFill,
displayHasLightLabel,
sourceCallsRelay,
noOptimisticVisual,
offTickUpdatesAll,
offSource,
offController,
offGlowGone: !off.glow,
offRoomLabel: !off.labelLit,
offRoomFillChanged: off.fill !== initial.fill,
controllerCallsRelay,
controllerTickUpdatesAll,
relitSource,
relitController,
relitGlow: relit.glow,
relitRoomLabel: relit.labelLit,
externalStateUpdateWorks,
touchTapCallsOnce,
longPressOpenedInfo,
longPressNoService,
pointerCancelNoService,
panNoService,
pinchNoService,
noOperationalToggleWhileLinked: operationalToggleCalls === 1,
unlinkRestoresManualOff,
manualToggleRestored,
};
});
checkAll(result);
await finish(browser, result);
+172
View File
@@ -0,0 +1,172 @@
// #157: open passage is negative architecture across editor, Full/Static and
// light transport. The pure symbol/static/iso guards have dedicated mutants;
// this browser scenario proves the assembled user flow and rendered geometry.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const c = window.__card;
const root = () => c.shadowRoot || c.renderRoot;
const settle = async () => {
c._cfgEpoch++;
c._lightBarrierCache = null;
c.requestUpdate();
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await settle();
};
const pitch = 1 / 240;
const wallKey = (a, b) => {
const q = (value) => Math.round(value / pitch) * pitch;
let dx = b[0] - a[0]; let dy = b[1] - a[1];
const length = Math.hypot(dx, dy) || 1;
dx /= length; dy /= length;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
angle = Math.round(angle * 1800) / 1800;
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
};
const sp = c._serverCfg.spaces.find((space) => space.id === c._space);
sp.rooms = [
{ id: 'passage-left', name: 'Left', area: 'living',
poly: [[0.1, 0.2], [0.5, 0.2], [0.5, 0.7], [0.1, 0.7]],
settings: { fill_mode: 'none' } },
{ id: 'passage-right', name: 'Right', area: 'kitchen',
poly: [[0.5, 0.2], [0.9, 0.2], [0.9, 0.7], [0.5, 0.7]],
settings: { fill_mode: 'none' } },
];
sp.walls = [{
key: wallKey([0.5, 0.2], [0.5, 0.7]),
a: [0.5, 0.2], b: [0.5, 0.7], cm: 20,
}];
sp.openings = [{
id: 'passage-main', type: 'passage', x: 0.5, y: 0.45,
angle: 90, length: 0.1, future_material: 'stone',
}];
sp.settings = { ...(sp.settings || {}), show_borders: true, hide_openings: false,
fill_mode: 'glow', glow_enabled: true };
c._serverCfg.settings = {
...(c._serverCfg.settings || {}), glow_radius_cm: 800,
};
const light = c._devices.find((device) => device.space === c._space
&& device.entities.some((entity) => entity.startsWith('light.') && c.hass.states[entity]?.state === 'on'));
if (light) c._layout = { ...c._layout, [light.id]: { s: c._space, x: 0.3, y: 0.45 } };
const litRings = () => [...root().querySelectorAll('defs clipPath[id^="hp-glowclip"] path.glow-lit')]
.flatMap((path) => (path.getAttribute('d') || '').split('M').filter(Boolean).map((subpath) => {
const values = (subpath.match(/-?\d+(?:\.\d+)?/g) || []).map(Number);
const ring = [];
for (let index = 0; index + 1 < values.length; index += 2) ring.push([values[index], values[index + 1]]);
return ring;
}));
const isLit = (point) => litRings().some((ring) => {
let inside = false;
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
const [x1, y1] = ring[index]; const [x2, y2] = ring[previous];
if ((y1 > point[1]) !== (y2 > point[1])
&& point[0] < ((x2 - x1) * (point[1] - y1)) / ((y2 - y1) || 1e-12) + x1) inside = !inside;
}
return inside;
});
c._setMode('view');
await settleMode();
const result = {};
result.interiorPassageTransmitsLight = !!light && isLit([650, 450]);
result.wallOutsidePassageStaysDark = !!light && !isLit([650, 260]);
sp.openings[0].type = 'future-opening';
await settle();
await new Promise((resolve) => setTimeout(resolve, 650));
await settle();
result.unknownOpeningFailsDark = !isLit([650, 450]);
sp.openings[0].type = 'passage';
await settle();
c._setMode('plan');
await settleMode();
c._activateOpeningPlacement('passage');
await settle();
result.passagePresetIs90cm = c._openingPreset?.type === 'passage'
&& c._openingPreset?.lengthCm === 90;
const committed = root().querySelector('.opening[data-kind="passage"]');
result.committedHasHitboxButNoSymbol = !!committed?.querySelector('.op-hit')
&& committed.querySelectorAll('.op-leaf,.op-arc,.op-glass,line').length === 0;
// Cancel is a pure draft operation, including stale values hidden by the
// passage radio. Save canonicalises those known fields but keeps extensions.
Object.assign(sp.openings[0], {
type: 'door', contact: 'binary_sensor.window', lock: 'lock.front_door',
invert: true, flip_h: true, flip_v: true,
});
await settle();
c._editOpening(c._openingsR.find((opening) => opening.id === 'passage-main'));
c._openingDialog = { ...c._openingDialog, type: 'passage' };
await settle();
const beforeCancel = JSON.stringify(sp.openings[0]);
result.warningIsVisibleAndAccessible = !!root().querySelector(
'.habindingbanner[role="status"][aria-live="polite"]',
);
result.passageHidesBindingAndFlipControls = root().querySelectorAll(
'hp-dialog .body select, hp-dialog .body input[type="checkbox"]',
).length === 0;
c._openingDialog = null;
await settle();
result.cancelKeepsOriginalOpening = JSON.stringify(sp.openings[0]) === beforeCancel;
c._editOpening(c._openingsR.find((opening) => opening.id === 'passage-main'));
c._openingDialog = { ...c._openingDialog, type: 'passage' };
c._saveOpening();
await settle();
const saved = sp.openings.find((opening) => opening.id === 'passage-main');
result.saveIsCanonicalAndLossless = saved?.type === 'passage'
&& saved.future_material === 'stone'
&& ['contact', 'lock', 'invert', 'flip_h', 'flip_v']
.every((field) => !Object.prototype.hasOwnProperty.call(saved, field));
result.noPassageLockBadge = root().querySelectorAll('.oplock').length === 0;
c._openingInfo = c._openingsR.find((opening) => opening.id === 'passage-main');
await settle();
result.stalePassageInfoIsInert = root().querySelectorAll('.oprow,.lockact').length === 0;
c._openingInfo = null;
// Exercise the actual static custom element with the same immutable snapshot.
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
staticCard.hass = c.hass;
document.body.append(staticCard);
await staticCard.updateComplete;
const baseSnap = staticCard._snap || {};
staticCard._snap = {
...baseSnap,
config: c._serverCfg,
rev: 157,
configFingerprint: 'open-passage-smoke',
layout: c._layout,
layoutRev: 157,
layoutFingerprint: 'open-passage-layout',
virtualLights: baseSnap.virtualLights || c._virtualLights,
};
staticCard._refreshDevices();
staticCard.requestUpdate();
await staticCard.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 350));
staticCard.requestUpdate();
await staticCard.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const staticRoot = staticCard.shadowRoot || staticCard.renderRoot;
result.staticCutsAndFillsPassage = !!staticRoot.querySelector('.wallbodies')
&& !!staticRoot.querySelector('.static-opening-tunnels [data-kind="passage"]');
result.staticDoesNotInventPassageSymbol = staticRoot.querySelectorAll('[data-hp="opening"]').length === 0;
staticCard.remove();
return result;
});
checkAll(out);
await finish(browser, out);
+15 -5
View File
@@ -51,7 +51,7 @@ const res = await page.evaluate(async () => {
const epochBeforeMenu = card._cfgEpoch;
launcher().click();
await settle();
out.menuHasThreeTypes = root().querySelectorAll('.editor-group-item').length === 3;
out.menuHasFourTypes = root().querySelectorAll('.editor-group-item').length === 4;
out.launcherStillDoesNotArm = card._tool !== 'opening' && card._openingPreset == null;
out.menuDoesNotResizeStage = Math.abs(
root().querySelector('.stage').getBoundingClientRect().height - stageHeightBeforeMenu,
@@ -64,6 +64,9 @@ const res = await page.evaluate(async () => {
&& card._openingPreset?.type === 'window' && card._openingPreset?.lengthCm === 120;
out.doorChoice = await choose('door');
out.doorDefault = card._openingPreset?.type === 'door' && card._openingPreset?.lengthCm === 90;
out.passageChoice = await choose('passage');
out.passageDefault = card._openingPreset?.type === 'passage'
&& card._openingPreset?.lengthCm === 90;
out.launcherReflectsActivePreset = launcher()?.getAttribute('aria-pressed') === 'true';
out.gateChoice = await choose('gate');
out.gateDefault = card._openingPreset?.type === 'gate' && card._openingPreset?.lengthCm === 300;
@@ -84,6 +87,12 @@ const res = await page.evaluate(async () => {
const windowPreview = root().querySelector('.opening-preview[data-kind="window"]');
out.windowPreviewGeometry = windowPreview?.querySelectorAll('.op-leaf').length === 2
&& windowPreview?.querySelectorAll('.op-arc').length === 2;
await choose('passage');
card._cursorPt = wallMid;
await settle();
const passagePreview = root().querySelector('.opening-preview[data-kind="passage"]');
out.passagePreviewHasNoSymbol = !!passagePreview
&& passagePreview.querySelectorAll('.op-leaf,.op-arc,.op-glass,line').length === 0;
await choose('gate');
card._cursorPt = wallMid;
await settle();
@@ -196,8 +205,7 @@ const res = await page.evaluate(async () => {
await settle();
out.noPreviewAwayFromWalls = !root().querySelector('.opening-preview');
// Real independent bodies are occluders, not room-owned wall intervals.
// They must never become accidental opening-placement targets.
// #132: saved independent wall segments are first-class opening hosts.
const bodyCentre = card._roomCenter(room);
const bodyX = bodyCentre[0] / 1000;
const bodyY = bodyCentre[1] / card._spaceH;
@@ -208,8 +216,10 @@ const res = await page.evaluate(async () => {
card._openingPlacementIntervalsCache = null;
card._cursorPt = bodyCentre;
await settle();
out.partitionIsNotOpeningTarget = !root().querySelector('.opening-preview')
&& card._resolveOpeningPlacement(bodyCentre) == null;
const partitionCandidate = card._resolveOpeningPlacement(bodyCentre);
out.partitionIsOpeningTarget = !!root().querySelector('.opening-preview')
&& partitionCandidate?.host?.kind === 'partition'
&& partitionCandidate.host.id === 'opening-smoke-partition';
card._curSpaceCfg.partitions = [];
card._curSpaceCfg.wall_columns = [{
id: 'opening-smoke-column', shape: 'square', cm: 30, center: [bodyX, bodyY], angle: 0,
+90
View File
@@ -0,0 +1,90 @@
// #113: deleting the final space and receiving an empty plan over WS must be
// a supported lifecycle state, not a synthetic SpaceModel or an exception.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const result = await page.evaluate(async () => {
const out = {};
const card = window.__card;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const root = () => card.shadowRoot || card.renderRoot;
const settle = async () => {
await card.updateComplete;
await sleep(40);
await card.updateComplete;
};
const original = structuredClone(card._serverCfg);
const first = structuredClone(original.spaces[0]);
// Exercise the real delete command with exactly one remaining space.
card._serverCfg = { ...structuredClone(original), spaces: [first] };
card._space = first.id;
card._cfgEpoch++;
card.requestUpdate();
await settle();
card._openSpaceDialog('edit', first.id);
card._mode = 'plan';
card._path = [[100, 100], [200, 100]];
card._resumeDraftBySpace = { [first.id]: 'stale-draft' };
card._pointers.set(113, { x: 10, y: 10 });
card._drag = { id: 'stale-device', sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
card._saveConfigDebounced();
const confirmBefore = window.confirm;
window.confirm = () => true;
try {
await card._deleteSpace();
} finally {
window.confirm = confirmBefore;
}
await settle();
out.deleteLastRendersEmpty = !!root().querySelector('.empty') && !root().querySelector('.stage');
out.deleteLastClearsSelection = card._space === '' && card._spaceModel() === undefined;
out.deleteLastAbortsEditorState = card._mode === 'view'
&& card._path.length === 0 && card._pointers.size === 0 && card._drag === null
&& Object.keys(card._resumeDraftBySpace).length === 0;
out.deleteLastClosesEditDialog = card._spaceDialog === null;
out.deleteLastCancelsPendingWrite = card._saveConfigDebounced.pending() === false;
// The empty state still owns global recovery flows.
const add = root().querySelector('.empty button.btn.on');
add?.click();
await settle();
out.createFlowSurvivesEmpty = card._spaceDialog?.mode === 'create';
// Recreate a plan, then reproduce the same transition as a config-updated WS
// event. This also arms the once-per-empty cleanup for a second cycle.
card._spaceDialog = null;
card._serverCfg = structuredClone(original);
card._space = original.spaces[0].id;
card._cfgEpoch++;
card.requestUpdate();
await settle();
out.recreateRestoresPlan = !!root().querySelector('.stage') && card._spaceModel()?.id === card._space;
card._mode = 'devices';
card._pointers.set(114, { x: 20, y: 20 });
card._serverCfg = { ...structuredClone(original), spaces: [] };
card._cfgEpoch++;
card.requestUpdate();
await settle();
out.wsEmptyAbortsLiveGesture = card._mode === 'view'
&& card._pointers.size === 0 && card._spaceModel() === undefined;
// Empty plans must remain inert under unrelated HA/render ticks.
card._serverCanWrite = false;
card.hass = {
...card.hass,
themes: { ...(card.hass.themes || {}), darkMode: !card.hass.themes?.darkMode },
};
window.dispatchEvent(new Event('resize'));
await settle();
out.emptySurvivesThemeResizeReadonly = !!root().querySelector('.empty')
&& !root().querySelector('.stage') && card._mode === 'view';
return out;
});
checkAll(result);
await finish(browser, result);
+147
View File
@@ -0,0 +1,147 @@
/** Issues #132/#185: independent-wall host lifecycle and structural wall continuity. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 960, height: 820 }, 1);
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const update = async () => { card.requestUpdate(); await card.updateComplete; };
const pointIn = (point, body) => {
let inside = false;
for (let i = 0, j = body.length - 1; i < body.length; j = i++) {
const a = body[i], b = body[j];
if (((a[1] > point[1]) !== (b[1] > point[1]))
&& point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / ((b[1] - a[1]) || 1e-12) + a[0])
inside = !inside;
}
return inside;
};
card._serverCfg = {
spaces: [{
id: 'partition-openings', title: 'Partition openings', cell_cm: 5,
view_box: [0, 0, 1, 1],
rooms: [{
id: 'room', name: 'Room', area: null,
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]],
}],
partitions: [{ id: 'host', a: [0.25, 0.5], b: [0.75, 0.5], cm: 15 }],
}],
markers: [], settings: {},
};
card._layout = {};
card._space = 'partition-openings';
card._modelCache = null;
card._cfgEpoch++;
card._saveConfig = () => {
card._cfgEpoch++;
card._modelCache = null;
card._physicalBodiesCache = null;
card._planSnapGeometryCache = null;
card.requestUpdate();
};
card._setMode('plan');
card._activateOpeningPlacement('door');
await update();
const centre = [500, 500];
const candidate = card._resolveOpeningPlacement(centre);
result.partitionCandidateCarriesStableHost = candidate?.host?.kind === 'partition'
&& candidate.host.id === 'host' && Math.abs(candidate.host.t - 0.5) < 1e-9;
card._openingClick(centre);
card._saveOpening();
await update();
let space = card._curSpaceCfg;
const saved = space.openings?.[0];
result.saveMaterializesHostAndCompatibilityGeometry = saved?.host?.id === 'host'
&& Math.abs(saved.host.t - 0.5) < 1e-9
&& Math.abs(saved.x - 0.5) < 1e-9
&& Math.abs(saved.y - 0.5) < 1e-9
&& !!root().querySelector(`[data-hp="opening"][data-id="${saved.id}"]`);
card._physicalBodiesCache = null;
const bodies = card._physicalBodiesR();
result.hostBodyHasFullDepthOpeningGap = !bodies.some((body) => pointIn(centre, body));
const base = card._spaceModel().partitions.find((partition) => partition.id === 'host');
card._physicalDrag = {
pid: 132, kind: 'partition', id: 'host', start: [...base.a],
startClient: [0, 0], before: card._geometrySnapshot(), moved: true,
base: JSON.parse(JSON.stringify(base)), delta: [50, 25],
};
card._physicalUp(new PointerEvent('pointerup', { pointerId: 132, bubbles: true }));
await update();
space = card._curSpaceCfg;
const movedOpening = space.openings?.[0];
result.rigidHostMoveKeepsTAndMovesProjectionAtomically = !!movedOpening
&& Math.abs(movedOpening.host.t - 0.5) < 1e-9
&& Math.abs(movedOpening.x - 0.55) < 1e-9
&& Math.abs(movedOpening.y - 0.525) < 1e-9;
card._physicalSel = { kind: 'partition', id: 'host' };
card._deletePhysicalSelection();
await update();
result.deleteRequiresAccessibleListDialog = !!card._partitionDeleteDialog
&& root().querySelectorAll('hp-dialog li').length === 1
&& !!space.partitions?.length && !!space.openings?.length;
card._partitionDeleteDialog = null;
await update();
result.deleteCancelIsMutationFree = !!space.partitions?.length && !!space.openings?.length;
card._physicalSel = { kind: 'partition', id: 'host' };
card._deletePhysicalSelection();
card._confirmPartitionDelete();
await update();
result.deleteConfirmCascadesHostAndOpening = !space.partitions && !space.openings;
card._undoGeometry();
await update();
space = card._curSpaceCfg;
result.deleteUndoRestoresHostAndOpening = space.partitions?.[0]?.id === 'host'
&& space.openings?.[0]?.host?.id === 'host';
card._redoGeometry();
await update();
space = card._curSpaceCfg;
result.deleteRedoCascadesAgain = !space.partitions && !space.openings;
space.partitions = [{ id: 'replacement', a: [0.3, 0.6], b: [0.7, 0.6], cm: 15 }];
space.openings = [{
id: 'orphan', type: 'door', x: 0.5, y: 0.6, angle: 0, length: 0.1,
host: { kind: 'partition', id: 'missing', t: 0.5 },
}];
card._saveConfig();
await update();
card._editOpening(card._openingsR[0]);
card._rebindPartitionOpening();
result.orphanRebindSessionKeepsOriginalIdentity = card._openingRebindId === 'orphan';
card._openingClick([500, 600]);
card._saveOpening();
await update();
result.orphanRebindReplacesHostWithoutDuplicate = space.openings.length === 1
&& space.openings[0].id === 'orphan'
&& space.openings[0].host?.id === 'replacement';
// Production-bundle regression for #185: a legacy room-wall opening does
// not cut the structural segment consumed by #173 room-face detection.
space.rooms = [{
id: 'room', name: 'Room', area: null,
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]],
}];
delete space.partitions;
space.openings = [{
id: 'legacy-door', type: 'door', x: 0.5, y: 0.1, angle: 0, length: 0.2,
}];
card._saveConfig();
await update();
const structural = card._planStructuralGeometrySnapshot().value;
result.openingKeepsRoomFaceAxisContinuous = structural.segments.some((segment) =>
segment.sourceKind === 'room'
&& Math.min(segment.a[0], segment.b[0]) <= 100 + 1e-6
&& Math.max(segment.a[0], segment.b[0]) >= 900 - 1e-6
&& Math.abs(segment.a[1] - 100) < 1e-6
&& Math.abs(segment.b[1] - 100) < 1e-6);
return result;
});
await finish(browser, checkAll(out));
+245
View File
@@ -0,0 +1,245 @@
/** Issue #137: architectural endpoint/line overlay and exact snap in Plan. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const update = async () => { card.requestUpdate(); await card.updateComplete; };
const cfg = {
spaces: [{
id: 'snap', title: 'Snap', cell_cm: 5, view_box: [0, 0, 1, 0.7],
rooms: [
{ id: 'left', name: 'Left', area: null,
poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] },
{ id: 'right', name: 'Right', area: null,
poly: [[0.5, 0.1], [0.9, 0.1], [0.9, 0.5], [0.5, 0.5]] },
],
openings: [{ id: 'door', type: 'door', x: 0.3, y: 0.1, angle: 0, length: 0.1 }],
open_spans: [{ a: [0.5, 0.2], b: [0.5, 0.3] }],
room_drafts: [{ id: 'saved', points: [[0.1, 0.6], [0.3, 0.6], [0.3, 0.7]],
segments: [{ cm: 15 }, { cm: 15 }] }],
partitions: [{ id: 'base-partition', a: [0.6, 0.6], b: [0.9, 0.6], cm: 15 }],
wall_columns: [{ id: 'ignored-column', shape: 'square', center: [0.8, 0.35], cm: 25 }],
}],
markers: [], settings: {},
};
card._serverCfg = JSON.parse(JSON.stringify(cfg));
card._layout = {};
card._space = 'snap';
card._modelCache = null;
card._frame = null;
card._cfgEpoch++;
card._setMode('plan');
card._tool = 'draw';
card._path = [];
card._activeDraftId = null;
card._clearPlanSnapHover();
await update();
const stage = root().querySelector('.stage');
const eventAt = (x, y, type = 'pointermove', extra = {}) => {
const rect = stage.getBoundingClientRect();
const view = card._viewOr(card._baseVb());
const EventType = type.startsWith('pointer') ? PointerEvent : MouseEvent;
return new EventType(type, {
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
bubbles: true,
pointerId: 41,
pointerType: 'mouse',
...extra,
});
};
const overlay = () => root().querySelector('[data-hp="plan-snap-overlay"]');
const active = () => root().querySelector('.plan-snap-node[data-active="true"]');
const close = (a, b, epsilon = 1e-5) => Math.abs(a - b) <= epsilon;
result.overlayBeforeFirstClick = !!overlay();
result.overlayIsPointerTransparent = overlay()?.getAttribute('pointer-events') === 'none'
&& getComputedStyle(overlay()).pointerEvents === 'none';
const wallBodies = root().querySelector('.wallbodies');
result.overlayAfterWallBodies = !!wallBodies && !!overlay()
&& !!(wallBodies.compareDocumentPosition(overlay()) & Node.DOCUMENT_POSITION_FOLLOWING);
result.oneLinePerSolidInterval = overlay()?.querySelectorAll('.plan-snap-line').length === 12;
result.uniqueSourceEndpointsOnly = overlay()?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length === 11;
result.columnIsNotACandidate = ![...overlay().querySelectorAll('.plan-snap-node')].some((node) =>
close(+node.getAttribute('cx'), 800) && close(+node.getAttribute('cy'), 350));
const crosses = (x1, y1, x2, y2, px, py) => {
const dx = x2 - x1, dy = y2 - y1;
const length2 = dx * dx + dy * dy;
if (!length2) return false;
const t = ((px - x1) * dx + (py - y1) * dy) / length2;
if (t <= 0 || t >= 1) return false;
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)) < 0.01;
};
const lines = () => [...overlay().querySelectorAll('.plan-snap-line')].map((line) => [
+line.getAttribute('x1'), +line.getAttribute('y1'),
+line.getAttribute('x2'), +line.getAttribute('y2'),
]);
result.openingGapHasNoLine = !lines().some((line) => crosses(...line, 300, 100));
result.openSpanHasNoLine = !lines().some((line) => crosses(...line, 500, 250));
result.cutBoundariesAreNotEndpoints = ![...overlay().querySelectorAll('[data-kind="endpoint"]')].some((node) => {
const x = +node.getAttribute('cx'), y = +node.getAttribute('cy');
return (close(y, 100) && (close(x, 250) || close(x, 350)))
|| (close(x, 500) && (close(y, 200) || close(y, 300)));
});
const cachedGeometry = card._planSnapGeometryCache?.value;
const staticNodesBefore = [...overlay().querySelectorAll(
'.plan-snap-line, .plan-snap-node[data-kind="endpoint"]',
)];
const originalRequestUpdate = card.requestUpdate;
let initialHoverUpdates = 0;
card.requestUpdate = function (...args) {
initialHoverUpdates++;
return originalRequestUpdate.apply(this, args);
};
stage.dispatchEvent(eventAt(104, 104));
await card.updateComplete;
result.endpointHoverIsActive = active()?.getAttribute('data-kind') === 'endpoint'
&& close(+active().getAttribute('cx'), 100) && close(+active().getAttribute('cy'), 100)
&& close(+active().getAttribute('r'), card._cmToUnits(10));
stage.dispatchEvent(eventAt(735, 606));
await card.updateComplete;
const initialLineActive = active()?.getAttribute('data-kind') === 'line';
stage.dispatchEvent(eventAt(300, 100));
await card.updateComplete;
const initialMissInactive = !active();
stage.dispatchEvent(eventAt(104, 104));
await card.updateComplete;
card.requestUpdate = originalRequestUpdate;
result.initialHoverAvoidsFullCardUpdate = initialHoverUpdates === 0;
result.initialHoverCyclesCandidateKinds = initialLineActive && initialMissInactive
&& active()?.getAttribute('data-kind') === 'endpoint';
result.initialHoverKeepsStaticNodeIdentity = staticNodesBefore.every(
(node, index) => node === overlay().querySelectorAll(
'.plan-snap-line, .plan-snap-node[data-kind="endpoint"]',
)[index],
);
card._markupClick(eventAt(104, 104, 'click', { shiftKey: true }));
await card.updateComplete;
result.endpointOverridesGridAndShift = card._path.length === 1
&& close(card._path[0][0], 100) && close(card._path[0][1], 100);
stage.dispatchEvent(eventAt(735, 606));
await card.updateComplete;
const lineNode = active();
result.lineHoverShowsOneDynamicNode = !!lineNode
&& lineNode.getAttribute('data-kind') === 'line'
&& root().querySelectorAll('.plan-snap-node.dynamic[data-active="true"]').length === 1;
const linePoint = lineNode ? [+lineNode.getAttribute('cx'), +lineNode.getAttribute('cy')] : [NaN, NaN];
result.lineNodeStaysWallBound = close(linePoint[1], 600);
result.lineNodeQuantizesAlongWall = close((linePoint[0] - 600) / card._gridPitch,
Math.round((linePoint[0] - 600) / card._gridPitch));
result.hoverKeepsStaticGeometryCache = !!cachedGeometry
&& card._planSnapGeometryCache?.value === cachedGeometry;
const originalPartition = JSON.stringify(card._curSpaceCfg.partitions[0]);
card._markupClick(eventAt(735, 606, 'click'));
await card.updateComplete;
result.drawCommitUsesExactLineNode = card._path.length === 2
&& close(card._path[1][0], linePoint[0]) && close(card._path[1][1], linePoint[1]);
result.existingPartitionWasNotSplit = card._curSpaceCfg.partitions.length === 1
&& JSON.stringify(card._curSpaceCfg.partitions[0]) === originalPartition;
stage.dispatchEvent(eventAt(300, 100));
await card.updateComplete;
result.openingGapDoesNotActivateSnap = !active();
stage.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true, pointerId: 41 }));
await card.updateComplete;
result.pointerLeaveClearsActiveNode = !active();
card._cancelPath();
card._activeDraftId = 'saved';
card._path = [[100, 600], [300, 600], [300, 700]];
await update();
result.activeDraftExcluded = !lines().some((line) => (
close(line[1], 600) && close(line[3], 600)
&& Math.min(line[0], line[2]) < 300 && Math.max(line[0], line[2]) > 100
) || (
close(line[0], 300) && close(line[2], 300)
&& Math.min(line[1], line[3]) < 700 && Math.max(line[1], line[3]) > 600
));
stage.dispatchEvent(eventAt(302, 602));
await card.updateComplete;
result.intermediateCurrentPointDoesNotSelfSnap = !active();
stage.dispatchEvent(eventAt(102, 602));
await card.updateComplete;
result.firstCurrentPointRemainsClosureTarget = active()?.getAttribute('data-kind') === 'endpoint'
&& close(+active().getAttribute('cx'), 100) && close(+active().getAttribute('cy'), 600);
stage.dispatchEvent(eventAt(302, 698));
await card.updateComplete;
result.currentAnchorDoesNotCreateZeroSegment = !active();
card._cancelPath();
card._tool = 'partition';
await update();
card._markupClick(eventAt(735, 606, 'click'));
await card.updateComplete;
result.tapWithoutHoverSnapsFirstPoint = card._path.length === 1
&& close(card._path[0][1], 600)
&& close((card._path[0][0] - 600) / card._gridPitch,
Math.round((card._path[0][0] - 600) / card._gridPitch));
card._markupClick(eventAt(896, 496, 'click'));
await card.updateComplete;
result.secondPartitionClickSnapsEndpoint = card._path.length === 0
&& card._curSpaceCfg.partitions.length === 2
&& close(card._curSpaceCfg.partitions[1].b[0] * 1000, 900)
&& close(card._curSpaceCfg.partitions[1].b[1] * 1000, 500);
result.originalSegmentStillUnchanged = JSON.stringify(card._curSpaceCfg.partitions[0]) === originalPartition;
const gestureGeometry = JSON.stringify({
drafts: card._curSpaceCfg.room_drafts,
partitions: card._curSpaceCfg.partitions,
});
card._suppressClick = true;
card._markupClick(eventAt(700, 600, 'click'));
result.suppressedClickDoesNotCommit = card._path.length === 0;
card._suppressClick = false;
stage.dispatchEvent(eventAt(700, 600, 'pointerdown', { pointerId: 51, buttons: 1 }));
stage.dispatchEvent(eventAt(760, 640, 'pointermove', { pointerId: 51, buttons: 1 }));
stage.dispatchEvent(eventAt(760, 640, 'pointerup', { pointerId: 51 }));
stage.dispatchEvent(eventAt(650, 560, 'pointerdown', { pointerId: 61, buttons: 1 }));
stage.dispatchEvent(eventAt(800, 650, 'pointerdown', { pointerId: 62, buttons: 1 }));
stage.dispatchEvent(eventAt(620, 540, 'pointermove', { pointerId: 61, buttons: 1 }));
stage.dispatchEvent(eventAt(830, 670, 'pointermove', { pointerId: 62, buttons: 1 }));
stage.dispatchEvent(eventAt(620, 540, 'pointercancel', { pointerId: 61 }));
stage.dispatchEvent(eventAt(830, 670, 'pointerup', { pointerId: 62 }));
await card.updateComplete;
result.panPinchCancelDoNotCommit = card._path.length === 0
&& JSON.stringify({
drafts: card._curSpaceCfg.room_drafts,
partitions: card._curSpaceCfg.partitions,
}) === gestureGeometry;
card._tool = 'select';
await update();
result.otherPlanToolsHaveNoOverlay = !overlay();
card._setMode('view');
await update();
result.viewHasNoOverlay = !overlay();
return result;
});
await page.emulateMedia({ forcedColors: 'active' });
out.forcedColorsStayReadable = await page.evaluate(async () => {
const card = window.__card;
card._setMode('plan');
card._tool = 'draw';
card._path = [];
card.requestUpdate();
await card.updateComplete;
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
const line = overlay?.querySelector('.plan-snap-line');
const node = overlay?.querySelector('.plan-snap-node');
const lineStyle = line ? getComputedStyle(line) : null;
const nodeStyle = node ? getComputedStyle(node) : null;
return matchMedia('(forced-colors: active)').matches
&& lineStyle?.stroke !== 'none' && nodeStyle?.stroke !== 'none'
&& nodeStyle?.fill !== 'none';
});
await finish(browser, checkAll(out));
+163
View File
@@ -0,0 +1,163 @@
// #117: exact YAML contact/lock references without Entity Registry rows must
// survive the immutable render projection without weakening disabled/security rules.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const settle = async () => {
card.requestUpdate();
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const contactId = 'binary_sensor.hp117_yaml_contact';
const lockId = 'lock.hp117_yaml_lock';
const space = card._serverCfg.spaces.find((item) => item.id === 'f1');
space.openings = [
{
id: 'hp117-window', type: 'window', x: 0.22, y: 0.14,
angle: 0, length: 0.09, contact: contactId,
},
{
id: 'hp117-door', type: 'door', x: 0.40, y: 0.14,
angle: 0, length: 0.09, contact: contactId, lock: lockId,
},
];
card._serverCfg = {
...card._serverCfg,
markers: [
{ id: 'hp117-contact-gone', binding: `entity:${contactId}`, removed: true, hidden: true },
{ id: 'hp117-lock-gone', binding: `entity:${lockId}`, removed: true, hidden: true },
],
};
card._cfgEpoch++;
card._setMode('view');
const serviceCalls = [];
card.hass = {
...card.hass,
states: {
...card.hass.states,
[contactId]: {
entity_id: contactId, state: 'off',
attributes: { friendly_name: 'YAML opening contact', device_class: 'window' },
},
[lockId]: {
entity_id: lockId, state: 'locked',
attributes: { friendly_name: 'YAML opening lock' },
},
},
callService: async (domain, service, data) => {
serviceCalls.push({ domain, service, entityId: data.entity_id });
},
};
await settle();
const rendered = () => card._openingsR;
const yamlWindow = () => rendered().find((opening) => opening.id === 'hp117-window');
const yamlDoor = () => rendered().find((opening) => opening.id === 'hp117-door');
result.noRegistryRowsExist = card.hass.entities?.[contactId] == null
&& card.hass.entities?.[lockId] == null;
result.pickerOffersExactYamlEntities = card._contactCandidates()
.some((item) => item.value === contactId)
&& card._lockCandidates().some((item) => item.value === lockId);
result.frozenProjectionKeepsYamlStates = card._renderPlanHass.entities?.[contactId] == null
&& card._renderPlanHass.entities?.[lockId] == null
&& card._renderPlanHass.states?.[contactId]?.state === 'off'
&& card._renderPlanHass.states?.[lockId]?.state === 'locked';
result.markerTombstonesDoNotBlockOpening = card._renderOpeningEntityAvailable(contactId)
&& card._renderOpeningEntityAvailable(lockId)
&& !card._planEntityAvailable(contactId)
&& !card._planEntityAvailable(lockId);
result.closedContactControlsPresentation = card._openingAmt(yamlWindow()) === 0
&& card._openingAmt(yamlDoor()) === 0;
result.yamlLockBadgeRendersLocked = root().querySelectorAll('.oplock.locked').length === 1;
const callsBeforePlanTap = serviceCalls.length;
root().querySelector('[data-hp="opening"][data-id="hp117-door"] .op-hit')
?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
root().querySelector('.oplock')?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
await settle();
result.planOpeningAndBadgeNeverActuate = serviceCalls.length === callsBeforePlanTap;
result.badgeOpensInfoWithBothRows = card._openingInfo?.id === 'hp117-door'
&& root().querySelectorAll('.oprow').length === 2
&& root().querySelectorAll('.lockact').length === 1;
window.confirm = () => true;
card._lockAction(lockId, 'unlock');
result.explicitInfoActionStillWorks = serviceCalls.at(-1)?.domain === 'lock'
&& serviceCalls.at(-1)?.service === 'unlock'
&& serviceCalls.at(-1)?.entityId === lockId;
card._openingInfo = null;
await settle();
card._physicalBodiesR();
const frameBefore = card._renderPlanHass;
const physicalBefore = card._physicalBodiesCache;
const epochBefore = card._cfgEpoch;
const configBefore = JSON.stringify(card._serverCfg);
card.hass = {
...card.hass,
states: {
...card.hass.states,
[contactId]: { ...card.hass.states[contactId], state: 'on' },
[lockId]: { ...card.hass.states[lockId], state: 'unlocked' },
},
};
const oldFrameHeldUntilUpdate = card._renderPlanHass === frameBefore
&& card._openingAmt(yamlWindow()) === 0;
await settle();
result.stateTickSwapsOneAtomicFrame = oldFrameHeldUntilUpdate
&& card._renderPlanHass !== frameBefore
&& card._openingAmt(yamlWindow()) === 1
&& card._openingAmt(yamlDoor()) === 1
&& root().querySelectorAll('.oplock.unlocked').length === 1;
result.stateTickDoesNotRebuildGeometryOrConfig = card._physicalBodiesCache === physicalBefore
&& card._cfgEpoch === epochBefore
&& JSON.stringify(card._serverCfg) === configBefore;
card.hass = {
...card.hass,
states: {
...card.hass.states,
[contactId]: { ...card.hass.states[contactId], state: 'unknown' },
[lockId]: { ...card.hass.states[lockId], state: 'unknown' },
},
};
await settle();
result.unknownKeepsExistingTypeSemantics = card._renderOpeningEntityAvailable(contactId)
&& card._openingAmt(yamlWindow()) === 0
&& card._openingAmt(yamlDoor()) === 1
&& root().querySelectorAll('.oplock.unknown').length === 1;
space.openings = [
{
id: 'hp117-disabled-window', type: 'window', x: 0.22, y: 0.14,
angle: 0, length: 0.09, contact: 'binary_sensor.window',
},
{
id: 'hp117-disabled-lock', type: 'door', x: 0.40, y: 0.14,
angle: 0, length: 0.09, lock: 'lock.front_door',
},
];
card._cfgEpoch++;
window.__setRegistryDisabled('entity', 'binary_sensor.window', 'user');
window.__setRegistryDisabled('entity', 'lock.front_door', 'user');
await new Promise((resolve) => setTimeout(resolve, 220));
await settle();
const disabledWindow = card._openingsR.find((opening) => opening.id === 'hp117-disabled-window');
result.explicitDisabledRowsRemoveStaleStates = card._renderPlanHass.states?.['binary_sensor.window'] == null
&& card._renderPlanHass.states?.['lock.front_door'] == null
&& !card._renderOpeningEntityAvailable('binary_sensor.window')
&& !card._renderOpeningEntityAvailable('lock.front_door')
&& card._openingAmt(disabledWindow) === 0
&& root().querySelectorAll('.oplock').length === 0;
return result;
});
checkAll(out);
await finish(browser, out);
+30
View File
@@ -17,12 +17,41 @@ const res = await page.evaluate(async () => {
r1.open_to = [r2.id]; r2.open_to = [r1.id];
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
pairsCalls = 0; buildCalls = 0;
const devicesBeforeHassTicks = c._devices;
for (let i = 0; i < 10; i++) {
c.hass = { ...c.hass, states: { ...c.hass.states } };
await c.updateComplete;
}
out.pairsBounded = pairsCalls <= 20;
out.modelBuildsPer10Renders = buildCalls;
out.devicesStableAcrossHassTicks = c._devices === devicesBeforeHassTicks;
// #146: the browser-clock fallback repaints only the environment. A 30 s
// tick must not rebuild the plan, devices, wall union, or sun-ray geometry.
c._serverCfg = {
...c._serverCfg,
settings: { ...(c._serverCfg?.settings || {}), bg_mode: 'daynight' },
};
const statesWithoutSun = { ...c.hass.states };
delete statesWithoutSun['sun.sun'];
c.hass = { ...c.hass, states: statesWithoutSun };
c.requestUpdate(); await c.updateComplete;
const clockModel = c._model;
const clockDevices = c._devices;
const clockWallUnion = c._wallUnionCache;
const clockSunRays = c._sunRaysCache;
buildCalls = 0;
c._dayCycleClockKey = 'force-next-clock-tick';
c._dayCycleTick();
await c.updateComplete;
out.fallbackClockActive = (c.shadowRoot || c.renderRoot)
.querySelector('.hp-day-cycle-env')?.dataset.dayCycleSource === 'clock';
out.clockTickModelBuilds = buildCalls;
out.clockTickKeepsModel = c._model === clockModel;
out.clockTickKeepsDevices = c._devices === clockDevices;
out.clockTickKeepsWallUnion = c._wallUnionCache === clockWallUnion;
out.clockTickKeepsSunRays = c._sunRaysCache === clockSunRays;
const before = pairsCalls;
const r3 = sp.rooms[2] || sp.rooms[0];
r3.open_to = [r1.id]; r1.open_to = [r2.id, r3.id];
@@ -35,5 +64,6 @@ const res = await page.evaluate(async () => {
});
checkAll(res, {
modelBuildsPer10Renders: 0,
clockTickModelBuilds: 0,
});
await finish(browser, res);
+153
View File
@@ -0,0 +1,153 @@
/** Issue #138: a new room may close along one existing continuous solid wall interval. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const update = async () => { card.requestUpdate(); await card.updateComplete; };
const clone = (value) => JSON.parse(JSON.stringify(value));
const baseSpace = {
id: 'autoclose', title: 'Autoclose', cell_cm: 5, view_box: [0, 0, 1, 0.7],
rooms: [{
id: 'existing', name: 'Existing', area: null,
poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]],
}],
walls: [{
key: '0.500000,0.300000@1.5708',
a: [0.5, 0.1], b: [0.5, 0.5], cm: 40,
}],
};
const reset = async (spacePatch = {}) => {
card._serverCfg = { spaces: [{ ...clone(baseSpace), ...clone(spacePatch) }], markers: [], settings: {} };
card._layout = {};
card._space = 'autoclose';
card._modelCache = null;
card._frame = null;
card._cfgEpoch++;
card._planSnapGeometryCache = null;
card._path = [];
card._activeDraftId = null;
card._draftSegmentCms = [];
card._closingWallCm = null;
card._roomDialog = false;
card._wallFaceBatch = null;
card._roomEditId = null;
card._pendingSplit = null;
card._toast = '';
card._drawWallField = '15';
card._setMode('plan');
card._tool = 'draw';
card._clearPlanSnapHover();
await update();
};
await reset();
const stage = root().querySelector('.stage');
const eventAt = (x, y, extra = {}) => {
const rect = stage.getBoundingClientRect();
const view = card._viewOr(card._baseVb());
return new MouseEvent('click', {
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
bubbles: true,
...extra,
});
};
const click = async (x, y, extra = {}) => {
card._markupClick(eventAt(x, y, extra));
await update();
};
const close = (a, b, epsilon = 1e-5) => Math.abs(a - b) <= epsilon;
const currentDraft = () => card._curSpaceCfg.room_drafts?.find((draft) =>
draft.id === card._activeDraftId);
// r1 regression guard: two points on the common wall are still only an open line.
await click(500, 100);
await click(500, 500);
result.secondCommonWallPointStaysOpen = card._path.length === 2
&& !card._roomDialog && !card._toast
&& currentDraft()?.points.length === 2
&& currentDraft()?.segments.length === 1;
// Endpoint-to-endpoint auto-close after the new room has enough vertices.
await reset();
await click(500, 100);
await click(800, 100);
await click(800, 500);
await click(500, 500);
const successfulDraft = clone(currentDraft());
result.endpointAutoCloseOpensDialog = card._roomDialog
&& card._wallFaceBatch?.candidates.length === 1
&& card._path.length === 4
&& close(card._path.at(-1)[0], 500)
&& close(card._path.at(-1)[1], 500);
result.terminalSegmentPersistsBeforeDialog = successfulDraft?.points.length === 4
&& successfulDraft?.segments.length === 3
&& close(successfulDraft.points.at(-1)[0], 0.5)
&& close(successfulDraft.points.at(-1)[1], 0.5)
&& successfulDraft.segments.every((segment) => close(segment.cm, 15));
result.roomIsNotCommittedBeforeSave = card._curSpaceCfg.rooms.length === 1;
card._roomDialogCancel();
await update();
result.cancelKeepsTerminalOpenDraft = !card._roomDialog
&& card._path.length === 4
&& !card._contourClosed
&& JSON.stringify(currentDraft()) === JSON.stringify(successfulDraft)
&& close(card._path.at(-1)[0], 500) && close(card._path.at(-1)[1], 500);
// Save promotes the same draft, keeps the existing shared thickness and creates no partition.
await reset();
await click(500, 100);
await click(800, 100);
await click(800, 500);
await click(500, 500);
card._nameSel = 'Adjacent';
card._saveRoom();
await update();
const sharedWall = (card._curSpaceCfg.walls || []).find((wall) => wall.cm === 40
&& wall.a && wall.b
&& close(wall.a[0], 0.5) && close(wall.b[0], 0.5)
&& close(Math.min(wall.a[1], wall.b[1]), 0.1)
&& close(Math.max(wall.a[1], wall.b[1]), 0.5));
result.savePromotesDraftWithoutPartition = card._curSpaceCfg.rooms.length === 2
&& card._curSpaceCfg.rooms.some((room) => room.name === 'Adjacent')
&& !card._curSpaceCfg.room_drafts?.length
&& !card._curSpaceCfg.partitions?.length
&& card._path.length === 0;
result.sharedWallKeepsNeighbourThickness = !!sharedWall
&& (card._curSpaceCfg.walls || []).filter((wall) => wall.cm === 15).length >= 3;
// #185: an architectural opening cuts masonry, not the structural wall axis.
await reset({ openings: [{
id: 'door', type: 'door', x: 0.5, y: 0.3, angle: 90, length: 0.1,
}] });
await click(500, 100);
await click(800, 100);
await click(800, 500);
await click(500, 500);
result.openingKeepsStructuralAutoClose = card._roomDialog
&& card._wallFaceBatch?.candidates.length === 1
&& card._path.length === 4
&& currentDraft()?.points.length === 4;
// #173: a proper X is a derived graph node. The two bounded faces are
// offered, while the terminal draft remains the only persisted mutation.
await reset();
await click(500, 100);
await click(400, 300);
await click(700, 300);
await click(500, 500);
result.xIntersectionOffersFacesWithoutPartialRooms = card._roomDialog
&& card._wallFaceBatch?.candidates.length === 2
&& card._path.length === 4
&& currentDraft()?.segments.length === 3
&& card._curSpaceCfg.rooms.length === 1;
return result;
});
await finish(browser, checkAll(out));
+1 -1
View File
@@ -84,6 +84,6 @@ checkAll(res, {
"atticSquare": true,
// New spaces keep the product default (Glow on), but store it independently
// from the ordinary data fill.
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#55606c", "room_opacity": 0.55, "fill_mode": "custom", "custom_fill": {"c": "#607d8b", "a": 0}, "glow_enabled": true, "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#55606c", "room_opacity": 0.55, "bg_mode": "daynight", "fill_mode": "custom", "custom_fill": {"c": "#607d8b", "a": 0}, "glow_enabled": true, "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
});
await finish(browser, res);
+2
View File
@@ -112,6 +112,8 @@ const res = await page.evaluate(async () => {
history.replaceState(null, '', `?hp-labs=iso#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();
+49
View File
@@ -6,6 +6,11 @@ const res = await page.evaluate(async () => {
// комната без зоны на f1
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
...s, rooms: [...s.rooms, { id: 'rc', name: 'Cupboard', area: null, poly: [[0.05,0.62],[0.2,0.62],[0.2,0.9],[0.05,0.9]] }],
})).map((s) => s.id !== 'garden' ? s : ({
...s, rooms: [...s.rooms,
{ id: 'garden-shed-a', name: 'Shed A', area: null, poly: [[0.60,0.55],[0.80,0.55],[0.80,0.75],[0.60,0.75]] },
{ id: 'garden-shed-b', name: 'Shed B', area: null, poly: [[0.25,0.55],[0.45,0.55],[0.45,0.75],[0.25,0.75]] },
],
})) };
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
// список комнат в диалоге содержит подзону
@@ -29,6 +34,50 @@ const res = await page.evaluate(async () => {
c._openMarkerDialog(dev);
out.reopenRoom = c._markerDialog?.room;
c._markerDialog = null;
// Реальное HA-устройство с registry Area вручную переносится в комнату без
// Area другого пространства. Registry metadata не должна вернуть его в f1.
const source = c._devices.find((d) => d.id === 'd_light1');
c._openMarkerDialog(source); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, room: 'garden#@garden-shed-a' };
await c._saveMarker(); await c.updateComplete;
const haMarker = c._serverCfg.markers.find((x) => x.binding === 'device:d_light1');
const moved = c._devices.find((d) => d.id === haMarker?.id);
const crossPos = c._layout[haMarker?.id];
out.haMarkerSaved = !!haMarker
&& haMarker.space === 'garden' && haMarker.area === null && haMarker.room_id === 'garden-shed-a';
out.haRuntimePlacement = !!moved
&& moved.space === 'garden' && moved.area === '' && moved.marker?.room_id === 'garden-shed-a';
out.crossSpaceCentered = !!crossPos && crossPos.s === 'garden'
&& Math.abs(crossPos.x - 0.70) < 0.03 && Math.abs(crossPos.y - 0.65) < 0.03;
c._openMarkerDialog(moved); await c.updateComplete;
out.haReopenRoom = c._markerDialog?.room === 'garden#@garden-shed-a';
c._markerDialog = null;
// Защитный негативный кейс: transient runtime-пара с room_id из другого
// пространства не роняет диалог и не переписывает сохранённый marker.
const persistedBeforeInvalidOpen = JSON.stringify(haMarker);
c._openMarkerDialog({ ...moved, space: 'f1' }); await c.updateComplete;
const invalidSelect = c.shadowRoot?.querySelector('#marker-room');
out.invalidPairPlaceholder = invalidSelect?.value === '';
out.invalidPairReadOnly = JSON.stringify(
c._serverCfg.markers.find((x) => x.binding === 'device:d_light1'),
) === persistedBeforeInvalidOpen;
c._markerDialog = null; await c.updateComplete;
// Смена комнаты внутри того же пространства сохраняет уже закреплённую
// позицию, хотя room_id меняется.
c._openMarkerDialog(moved); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, room: 'garden#@garden-shed-b' };
await c._saveMarker(); await c.updateComplete;
const sameSpaceMarker = c._serverCfg.markers.find((x) => x.binding === 'device:d_light1');
const sameSpacePos = c._layout[sameSpaceMarker?.id];
const sameSpaceDevice = c._devices.find((d) => d.id === sameSpaceMarker?.id);
out.sameSpaceRoomSaved = sameSpaceMarker?.room_id === 'garden-shed-b'
&& sameSpaceDevice?.space === 'garden' && sameSpaceDevice?.area === '';
out.sameSpacePositionKept = !!sameSpacePos
&& sameSpacePos.s === crossPos.s && sameSpacePos.x === crossPos.x && sameSpacePos.y === crossPos.y;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
+16 -15
View File
@@ -1,6 +1,6 @@
// Sun on the plan (docs/SUN.md): compass gating, window wedges on all four
// walls, direction follows north_deg, night is empty, wedges clip to rooms,
// day/night background vs static, per-space inheritance, weather independence,
// four-phase background vs static, per-space inheritance, weather independence,
// memoisation across hass ticks, editors stay clean.
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
@@ -19,7 +19,7 @@ const res = await page.evaluate(async () => {
const setSun = async (azimuth, elevation) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
attributes: { azimuth, elevation },
attributes: { azimuth, elevation, rising: azimuth <= 180 },
} } };
await upd();
};
@@ -51,12 +51,12 @@ const res = await page.evaluate(async () => {
];
touchCfg();
// 1) sun present + rays enabled, but NO north_deg anywhere → dead silence
// 1) sun present + rays enabled, but NO north_deg anywhere → no window rays
cfg().settings = { ...(cfg().settings || {}), sun_rays: true };
touchCfg();
await setSun(90, 5);
out.silentWithoutNorth = domPolys().length === 0;
out.noDaynightWithoutNorth = !sr().querySelector('.stage.daynight');
out.noBackgroundWhenStatic = !sr().querySelector('.stage.daycycle');
// 2) compass set → morning east sun lights ONLY the east window
cfg().settings.north_deg = 0;
@@ -82,11 +82,12 @@ const res = await page.evaluate(async () => {
await setSun(90, -10);
out.nightEmpty = domPolys().length === 0;
// 6) rotated compass: the same morning east sun now lights the NORTH window
// 6) N points right: the same morning east sun is down on the canvas and
// therefore lights the SOUTH window
cfg().settings.north_deg = 90;
touchCfg();
await setSun(90, 5);
out.rotatedCompass = JSON.stringify(litIds()) === '["wN"]';
out.rotatedCompass = JSON.stringify(litIds()) === '["wS"]';
cfg().settings.north_deg = 0;
touchCfg();
@@ -99,21 +100,21 @@ const res = await page.evaluate(async () => {
await setSun(91, 5);
out.memoFollowsSun = c._sunRaysCache !== cacheBefore;
// 8) day/night background: daynight breathes, static does not
// 8) four-phase background: daynight paints environment, static does not
await setSun(180, 60);
out.staticBgAtNoon = !stageStyle().includes('background:#ffffff');
cfg().settings.bg_mode = 'daynight';
touchCfg();
await setSun(180, 60);
out.daynightNoonBg = stageStyle().includes('background:#ffffff');
out.daynightClass = !!sr().querySelector('.stage.daynight');
out.daynightNoonBg = sr().querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'day';
out.daynightClass = !!sr().querySelector('.stage.daycycle');
await setSun(180, -20);
out.daynightNightBg = stageStyle().includes('background:#070c14');
out.nightPlanDimmed = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
out.daynightNightBg = sr().querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'night';
out.nightPlanUnchanged = !(sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.');
cfg().settings.bg_mode = 'static';
touchCfg();
await setSun(180, -20);
out.staticNightKeepsTheme = !stageStyle().includes('background:#070c14');
out.staticNightKeepsTheme = !sr().querySelector('.hp-day-cycle-env');
delete cfg().settings.bg_mode;
touchCfg();
@@ -127,7 +128,7 @@ const res = await page.evaluate(async () => {
sp.settings.north_deg = 90;
touchCfg();
await setSun(90, 5);
out.spaceNorthOverride = JSON.stringify(litIds()) === '["wN"]';
out.spaceNorthOverride = JSON.stringify(litIds()) === '["wS"]';
delete sp.settings.north_deg;
touchCfg();
@@ -153,13 +154,13 @@ const res = await page.evaluate(async () => {
cfg().settings.bg_mode = 'daynight';
touchCfg();
// 12) editors stay clean: no wedges, no day/night canvas
// 12) editors stay clean: no wedges, no day-cycle environment
await setSun(90, 5);
c._setMode('plan');
await upd();
await settleMode();
out.editorNoRays = domPolys().length === 0;
out.editorNoDaynight = !sr().querySelector('.stage.daynight');
out.editorNoDaynight = !sr().querySelector('.stage.daycycle');
c._setMode('view');
await upd();
await settleMode();
+50 -57
View File
@@ -1,9 +1,7 @@
// Owner 2026-08-04: «цвет фона не меняется сам с течением времени суток, только
// после обновления страницы». The day/night sky must follow sun.sun on a plain
// hass tick — no reload, no requestUpdate() from the outside — and the COMPUTED
// background of the stage (what the eye sees, not the style attribute) must
// actually arrive at the sky colour (docs/SUN.md).
import { launch, check, checkAll, finish } from './serve.mjs';
// Four-phase Sun background (#146): plain hass ticks update one constant
// environment tree, never dim the plan, and clock fallback owns a visible-only
// 30-second lifecycle. Window rays retain their separate north/sun gates.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
@@ -11,82 +9,77 @@ const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const cfg = c._serverCfg;
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'daynight', sun_rays: true };
cfg.spaces.find((s) => s.id === 'f1').openings = [
cfg.settings = { ...(cfg.settings || {}), bg_mode: 'daynight', sun_rays: true };
cfg.spaces.find((space) => space.id === 'f1').openings = [
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
];
c._cfgEpoch++;
// NO requestUpdate anywhere below: production only ever assigns `hass`.
const setSun = async (az, el) => {
// No external requestUpdate: production advances through the hass setter.
const setSun = async (azimuth, elevation, rising) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
attributes: { azimuth: az, elevation: el },
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
attributes: { azimuth, elevation, rising },
} } };
await c.updateComplete;
// one paint so the sky lands on screen, not only in the attribute
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
};
const stage = () => sr().querySelector('.stage');
const comp = () => getComputedStyle(stage()).backgroundColor;
const dim = () => (sr().querySelector('.zoomwrap').getAttribute('style') || '');
const polys = () => sr().querySelectorAll('.sunlayer polygon').length;
const hexToRgb = (h) => 'rgb(' + [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(', ') + ')';
const env = () => sr().querySelector('.hp-day-cycle-env');
const active = () => sr().querySelector('.hp-day-cycle-bg.active');
const zoomStyle = () => sr().querySelector('.zoomwrap').getAttribute('style') || '';
const rayCount = () => sr().querySelectorAll('.sunlayer polygon').length;
await setSun(180, 40);
out.dayComputedWhite = comp() === 'rgb(255, 255, 255)';
out.dayNoDim = !dim().includes('brightness(') || dim().includes('brightness(1.000)');
out.dayRays = polys() > 0;
// Background needs neither compass nor window-ray geometry.
delete cfg.settings.north_deg;
await setSun(90, -2, true);
out.dawnOnPlainTick = stage().classList.contains('phase-dawn')
&& env()?.dataset.dayCycleSource === 'sun'
&& active()?.dataset.dayCycleLayer === 'dawn';
out.constantFourLayers = sr().querySelectorAll('.hp-day-cycle-bg').length === 4;
out.backgroundIndependentOfNorth = !!stage().classList.contains('daycycle');
out.raysStillNeedNorth = rayCount() === 0;
// the whole point: a NEW hass, nothing else. No reload, no requestUpdate.
await setSun(180, -10);
out.nightComputedDark = comp() === 'rgb(10, 16, 25)';
out.nightDimmed = dim().includes('brightness(0.900');
out.nightNoRays = polys() === 0;
cfg.settings.north_deg = 0;
c._cfgEpoch++;
await setSun(180, 40, false);
out.dayOnPlainTick = stage().classList.contains('phase-day')
&& active()?.getAttribute('style').includes('#dce9ef');
out.dayRays = rayCount() > 0;
out.dayPlanUnfiltered = !zoomStyle().includes('brightness(0.');
// ...and back, again on a plain tick
await setSun(180, 40);
out.backToDayComputed = comp() === 'rgb(255, 255, 255)';
out.backToDayRays = polys() > 0;
await setSun(180, 0, false);
out.duskOnPlainTick = stage().classList.contains('phase-dusk')
&& active()?.dataset.dayCycleLayer === 'dusk';
await setSun(180, -6, false);
out.exactMinusSixIsNight = stage().classList.contains('phase-night');
out.nightLightHidden = env()?.style.getPropertyValue('--hp-day-cycle-sun-opacity') === '0.000';
out.nightPlanUnfiltered = !zoomStyle().includes('brightness(0.');
out.nightNoRays = rayCount() === 0;
// a small step still moves the sky (0.1° granularity of the model)...
await setSun(180, 20);
const twenty = comp();
await setSun(180, 12);
out.smallStepMovesSky = comp() !== twenty;
await setSun(180, 6, true);
out.exactPlusSixIsDay = stage().classList.contains('phase-day');
out.phaseTransitionIs1100ms = getComputedStyle(active()).transitionDuration === '1.1s';
// ...but a step the size of a REAL sun update (~1° per 4 minutes) must still
// GLIDE: the catch-up jump is for a card that was not watching, never for the
// day/night breathing itself (docs/SUN.md).
await setSun(180, 12.6);
const target = stage().getAttribute('style').split('background:')[1];
out.realStepNoSnapClass = !sr().querySelector('.stage.skysnap');
out.realStepStillGlides = comp() !== hexToRgb(target); // 45 s transition, not a jump
// One invalid required field switches the whole environment atomically to
// browser-local time and arms the fallback timer only while visible.
await setSun(180, 6, undefined);
out.invalidSampleUsesClock = env()?.dataset.dayCycleSource === 'clock';
out.clockTimerArmed = c._dayCycleTimer !== 0;
// A quick tab switch is not a suspended renderer: keep the current painted
// sky and hover in place. The old visibility handler cleared both on every
// return, producing a one-frame whole-plan flash even after two seconds.
const ownVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState');
const room = c._spaceModel().rooms[0];
c._hoverRoom = { space: c._space, room };
c.requestUpdate();
await c.updateComplete;
const skyBeforeQuickReturn = c._skyElev;
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
document.dispatchEvent(new Event('visibilitychange'));
out.hiddenStopsClock = c._dayCycleTimer === 0;
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
document.dispatchEvent(new Event('visibilitychange'));
await c.updateComplete;
out.quickReturnKeepsSky = c._skyElev === skyBeforeQuickReturn;
out.quickReturnKeepsHover = c._hoverRoom?.room === room;
out.quickReturnHasNoSnapFrame = !stage().classList.contains('skysnap')
&& !stage().classList.contains('hpresume');
out.visibleRestartsClock = c._dayCycleTimer !== 0;
out.visibilityKeepsHover = c._hoverRoom?.room === room;
if (ownVisibilityState) Object.defineProperty(document, 'visibilityState', ownVisibilityState);
else delete document.visibilityState;
// the tab came back from the background: the sky catches up at once
await setSun(180, 25); // 12° of sun happened while we were not painting
out.returnFromHiddenSnaps = comp() === hexToRgb(stage().getAttribute('style').split('background:')[1]);
return out;
});
await finish(browser, checkAll(res));
+172
View File
@@ -0,0 +1,172 @@
// #103: Toggle confirmation explains the current snapshot and the exact
// nextEffect while execution still resolves the current intent again.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 390, height: 760 });
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const calls = [];
const coverState = (state, entityId = 'cover.gate', deviceClass = 'curtain') => ({
entity_id: entityId,
state,
attributes: {
friendly_name: entityId === 'cover.gate'
? 'Very long living-room curtain name that must wrap on a phone'
: 'Other curtain',
device_class: deviceClass,
supported_features: 11,
},
});
const setCover = async (state, extraStates = {}, deviceClass = 'curtain') => {
card.hass = {
...card.hass,
states: { ...card.hass.states, 'cover.gate': coverState(state, 'cover.gate', deviceClass), ...extraStates },
services: {
...card.hass.services,
cover: {
...card.hass.services?.cover,
open_cover: {}, close_cover: {}, stop_cover: {}, toggle: {},
},
},
// The demo fallback returns raw states; #103 must still localize the
// known vocabulary instead of exposing those tokens in confirmation.
formatEntityState: (entity) => entity.state,
callService: async (domain, service, data) => {
calls.push([domain, service, data]);
return {};
},
};
card.requestUpdate();
await card.updateComplete;
};
const setLanguage = async (language) => {
card._config = { ...card._config, language };
card.requestUpdate();
await card.updateComplete;
};
const rebuildGate = async () => {
card._serverCfg = {
...card._serverCfg,
markers: [{
id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover',
tap_confirm: true, display: 'icon_ripple',
}],
};
card._cfgEpoch++;
card._regSignature = '';
card._maybeRebuildDevices();
card._space = 'garden';
card._setMode('view');
card.requestUpdate();
await card.updateComplete;
};
const gate = () => card._devices.find((item) => item.bindingRef === 'd_gate');
const tap = async () => {
card._clickDevice({ stopPropagation() {} }, gate());
await card.updateComplete;
return card._tapConfirm;
};
const renderedLines = () => [...root().querySelectorAll('.tapconfirm-line')]
.map((node) => node.textContent.trim());
await setLanguage('en');
await setCover('closed');
await rebuildGate();
const english = await tap();
result.englishSnapshotIsStructured = english?.kind === 'toggle'
&& english.initialIntent?.nextEffect === 'open'
&& english.deviceId === gate()?.id;
result.englishCurrentExpected = JSON.stringify(renderedLines()) === JSON.stringify([
'Current state: Closed', 'After switching: Open',
]);
const groupLines = card._toggleConfirmationLines({
origin: 'explicit-toggle', kind: 'group', semantics: 'group-power',
targets: [
{ entityId: 'switch.one', name: 'One', state: 'on', via: 'control-entity' },
{ entityId: 'light.two', name: 'Two', state: 'off', via: 'control-entity' },
],
skippedTargets: [{
ref: 'switch.missing', entityId: 'switch.missing', name: 'Missing', reason: 'unavailable',
}],
noneReason: null, nextEffect: 'turn-off',
command: {
domain: 'homeassistant', service: 'turn_off',
data: { entity_id: ['switch.one', 'light.two'] },
},
});
const virtualLines = card._toggleConfirmationLines({
origin: 'explicit-toggle', kind: 'single', semantics: 'power',
targets: [{ entityId: '', name: 'Virtual lamp', state: 'on', via: 'virtual-light' }],
skippedTargets: [], noneReason: null, nextEffect: 'turn-off', command: null,
operation: { kind: 'virtual-light', markerId: 'virtual-lamp' },
});
result.englishGroupAndVirtualCopy = JSON.stringify(groupLines) === JSON.stringify([
'Current state: on 1 of 2', 'After switching: all are off', 'Unavailable: 1',
]) && JSON.stringify(virtualLines) === JSON.stringify([
'Current state: On', 'After switching: Off',
]);
const body = root().querySelector('.tapconfirm-body');
const dialog = root().querySelector('hp-dialog');
const titleText = dialog?.shadowRoot?.querySelector('.title-text');
const footer = root().querySelector('hp-dialog [slot="footer"]');
const buttons = [...(footer?.querySelectorAll('button') || [])];
result.accessibleDomOrder = dialog?.title === english.text
&& body?.children[0]?.getAttribute('data-line') === '0'
&& body?.children[1]?.getAttribute('data-line') === '1'
&& buttons.length === 2;
result.narrowDialogDoesNotScrollHorizontally = !!body
&& body.scrollWidth <= body.clientWidth + 1
&& !!titleText && titleText.scrollWidth <= titleText.clientWidth + 1
&& buttons.every((button) => {
const rect = button.getBoundingClientRect();
return rect.left >= 0 && rect.right <= innerWidth + 1;
});
// Same target, changed state: execute the newly resolved direction.
calls.length = 0;
await setCover('open');
english.exec();
await new Promise((resolve) => setTimeout(resolve, 10));
result.sameTargetUsesCurrentDirection = calls.length === 1
&& calls[0][0] === 'cover' && calls[0][1] === 'close_cover';
// Different operation target: preserve #94's cancellation/toast contract.
await setCover('closed');
const changed = await tap();
const beforeChange = gate();
const other = coverState('closed', 'cover.other');
card.hass = { ...card.hass, states: { ...card.hass.states, 'cover.other': other } };
card._devices = card._devices.map((item) => item.id === beforeChange.id ? {
...item,
primary: 'cover.other', entities: ['cover.other'], allEntities: ['cover.other'],
bindingKind: 'entity', bindingRef: 'cover.other',
marker: { ...item.marker, binding: 'entity:cover.other' },
} : item);
calls.length = 0;
changed.exec();
await card.updateComplete;
result.changedTargetCancels = calls.length === 0
&& card._toast === card._t('toast.tap_target_changed');
await setLanguage('ru');
await setCover('closed');
await rebuildGate();
await tap();
result.russianCurrentExpected = JSON.stringify(renderedLines()) === JSON.stringify([
'Текущее состояние: Закрыто', 'После переключения: Открыто',
]);
// A secure/no-operation resolver result must not open a confirmation.
card._tapConfirm = null;
await setCover('closed', {}, 'garage');
await tap();
result.noOperationDoesNotOpen = card._tapConfirm == null && calls.length === 0;
return result;
});
checkAll(out);
await finish(browser, out);
+133
View File
@@ -0,0 +1,133 @@
// #178: production-bundle contract for the exact Toggle-entity selector.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1180, height: 1000 });
const result = await page.evaluate(async () => {
const out = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const base = card._devices.find((device) =>
device.bindingKind === 'device' && device.primary?.startsWith('light.'));
const primary = base.primary;
const secondary = 'switch.issue178_child_lock';
card.hass = {
...card.hass,
states: {
...card.hass.states,
[secondary]: {
entity_id: secondary,
state: 'off',
attributes: { friendly_name: 'Child lock' },
},
},
entities: {
...card.hass.entities,
[secondary]: {
entity_id: secondary,
device_id: base.bindingRef,
platform: 'issue178',
disabled_by: null,
},
},
};
await card.updateComplete;
// Keep the browser smoke independent from registry loading order while the
// production resolver, dialog and persistence helpers remain unmodified.
const realPreview = card._markerPreviewDevice.bind(card);
card._markerPreviewDevice = (draft) => draft.devId === base.id ? {
...base,
tapAction: draft.tapActionTouched ? draft.tapAction : 'toggle',
entities: [primary, secondary],
marker: {
...(base.marker || {}),
id: base.id,
binding: `device:${base.bindingRef}`,
tap_action: 'toggle',
toggle_entity: draft.toggleEntity || null,
},
} : realPreview(draft);
card._openMarkerDialog({
...base,
tapAction: 'toggle',
entities: [primary, secondary],
marker: {
...(base.marker || {}), id: base.id, binding: `device:${base.bindingRef}`,
tap_action: 'toggle', toggle_entity: primary,
},
});
await card.updateComplete;
let select = root().querySelector('#marker-toggle-entity');
if (!select) throw new Error(JSON.stringify({
dialog: card._markerDialog,
effectiveTapAction: card._effectiveMarkerTapAction(card._markerDialog),
preview: card._markerPreviewDevice(card._markerDialog),
selectIds: [...root().querySelectorAll('select')].map((item) => item.id),
}));
out.selectorForMultiple = !!select && select.options.length === 3;
out.savedSelectionProjected = select?.value === primary;
select.value = secondary;
select.dispatchEvent(new Event('change', { bubbles: true }));
await card.updateComplete;
select = root().querySelector('#marker-toggle-entity');
const hint = root().querySelector('#marker-toggle-hint')?.textContent || '';
out.selectionUpdatesDraft = card._markerDialog.toggleEntity === secondary
&& select?.value === secondary;
out.selectionUpdatesHintBeforeSave = hint.includes(secondary)
&& card._markerDialog.tapHintAnnouncement.includes(secondary);
out.writerPersistsSelection = card._markerToggleEntityFields(card._markerDialog)
.toggle_entity === secondary;
// Re-open the persisted shape, then simulate a temporarily missing entity.
card._markerDialog = null;
card._openMarkerDialog({
...base,
tapAction: 'toggle',
entities: [primary, secondary],
marker: {
...(base.marker || {}), id: base.id, binding: `device:${base.bindingRef}`,
tap_action: 'toggle', toggle_entity: secondary,
},
});
await card.updateComplete;
out.reopenRestoresSelection = root().querySelector('#marker-toggle-entity')?.value === secondary;
card._markerDialog = {
...card._markerDialog,
toggleEntity: 'switch.issue178_removed',
toggleEntityTouched: false,
originalHasToggleEntity: true,
originalToggleEntity: 'switch.issue178_removed',
};
await card.updateComplete;
const warning = root().querySelector('.markertoggleentity [role="status"]');
out.staleWarnsAndShowsAuto = root().querySelector('#marker-toggle-entity')?.value === ''
&& warning?.textContent?.includes('switch.issue178_removed');
out.staleLiteralSurvives = card._markerToggleEntityFields(card._markerDialog)
.toggle_entity === 'switch.issue178_removed';
card._markerDialog = null;
card._markerPreviewDevice = realPreview;
const single = card._devices.find((device) =>
device.bindingKind === 'device' && device.entities.filter((eid) =>
eid.startsWith('light.') || eid.startsWith('switch.')).length === 1);
card._openMarkerDialog({
...single,
tapAction: 'toggle',
marker: {
...(single.marker || {}), id: single.id, binding: `device:${single.bindingRef}`,
tap_action: 'toggle',
},
});
await card.updateComplete;
out.singleEntityHidesSelector = !root().querySelector('#marker-toggle-entity');
card._markerDialog = null;
await card.updateComplete;
return out;
});
checkAll(result, {});
await finish(browser, result);
+158
View File
@@ -0,0 +1,158 @@
/** Issue #173: one Walls chain, face proposal and atomic finish/cancel. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1100, height: 820 }, 1);
const result = await page.evaluate(async () => {
const out = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const update = async () => { card.requestUpdate(); await card.updateComplete; };
const space = () => card._serverCfg.spaces[0];
const resetGeometry = async (rooms = []) => {
const current = space();
current.rooms = JSON.parse(JSON.stringify(rooms));
delete current.room_drafts;
delete current.partitions;
delete current.walls;
delete current.open_spans;
card._cfgEpoch++;
card._modelCache = null;
card._path = [];
card._activeDraftId = null;
card._draftSegmentCms = [];
card._wallFaceBatch = null;
card._roomDialog = false;
card._tool = 'draw';
await update();
};
const draw = async (points) => {
card._tool = 'draw';
card._path = [[...points[0]]];
card._activeDraftId = null;
card._draftSegmentCms = [];
for (let i = 1; i < points.length; i++) {
const before = card._path.map((point) => [...point]);
card._path = [...card._path, [...points[i]]];
card._persistActiveDraftSegment();
card._offerWallFaces(before);
await update();
}
};
card._serverCfg = {
spaces: [{
id: 'walls', title: 'Walls', cell_cm: 5, view_box: [0, 0, 1, 0.7],
rooms: [], openings: [], room_drafts: [], partitions: [], wall_columns: [],
}],
markers: [], settings: {},
};
card._space = 'walls';
card._layout = {};
card._cfgEpoch++;
card._modelCache = null;
card._setMode('plan');
card._tool = 'draw';
await update();
const toolbarLabels = [...root().querySelectorAll('.planbar .editbar-tools > button, .wallsgroup > button')]
.map((button) => (button.textContent || '').trim());
out.oneWallsButton = toolbarLabels.filter((label) => ['Walls', 'Стены'].includes(label)).length === 1;
out.partitionButtonRemoved = !toolbarLabels.some((label) => ['Partition', 'Перегородка'].includes(label));
out.splitRemains = toolbarLabels.some((label) => ['Split', 'Разделить'].includes(label));
await resetGeometry();
await draw([[100, 100], [300, 100], [300, 250]]);
out.openChainIsCrashSafeDraft = space().room_drafts?.length === 1
&& space().room_drafts[0].segments.length === 2;
const cms = space().room_drafts[0].segments.map((segment) => segment.cm);
card._activateMarkupTool('select');
await update();
out.toolChangeFinishesPartitions = !space().room_drafts
&& space().partitions?.length === 2
&& space().partitions.every((partition, index) => partition.cm === cms[index]);
out.finishedChainDoesNotResume = !card._activeDraftId && card._path.length === 0;
await resetGeometry();
await draw([[100, 100], [300, 100], [300, 300], [100, 300], [100, 100]]);
out.closedFaceOpensDialog = card._roomDialog && card._wallFaceBatch?.candidates.length === 1;
out.terminalSegmentPersistedBeforeDecision = space().room_drafts?.[0]?.segments.length === 4;
card._nameSel = 'Graph room';
card._saveRoom();
await update();
out.acceptCreatesRoomAtomically = space().rooms?.length === 1
&& space().rooms[0].name === 'Graph room'
&& !space().room_drafts && !(space().partitions?.length);
await resetGeometry();
await draw([[500, 100], [700, 100], [700, 300], [500, 300], [500, 100]]);
const draftBeforeCancel = JSON.stringify(space().room_drafts);
card._roomDialogCancel();
await update();
out.cancelKeepsWholeTerminalDraft = !card._roomDialog && !card._wallFaceBatch
&& JSON.stringify(space().room_drafts) === draftBeforeCancel
&& card._path.length === 5;
card._onKey(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true,
}));
await update();
out.escapeUndoContractStillRemovesLastSegment = space().room_drafts?.[0]?.segments.length === 3
&& card._path.length === 4;
await resetGeometry();
await draw([[100, 450], [300, 450], [300, 650], [100, 650], [100, 450]]);
card._onKey(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true,
}));
await update();
out.ctrlZInQueueRemovesTerminalPoint = !card._roomDialog && !card._wallFaceBatch
&& card._path.length === 4 && space().room_drafts?.[0]?.segments.length === 3;
await resetGeometry();
space().partitions = [
{ id: 'top', a: [0.1, 0.1], b: [0.5, 0.1], cm: 18 },
{ id: 'right', a: [0.5, 0.1], b: [0.5, 0.3], cm: 18 },
{ id: 'bottom', a: [0.5, 0.3], b: [0.1, 0.3], cm: 18 },
{ id: 'left', a: [0.1, 0.3], b: [0.1, 0.1], cm: 18 },
];
card._cfgEpoch++;
card._modelCache = null;
await draw([[260, 100], [260, 300]]);
out.multiFaceTClosureOrdered = card._wallFaceBatch?.candidates.length === 2
&& card._wallFaceBatch.candidates[0].area < card._wallFaceBatch.candidates[1].area;
card._nameSel = 'Small face';
card._saveRoom();
await update();
out.firstQueueDecisionIsBuffered = card._wallFaceBatch?.index === 1
&& space().rooms.length === 0 && space().room_drafts?.length === 1;
card._keepClosedAsPartitions();
await update();
out.mixedQueueAppliesOnce = space().rooms.length === 1
&& space().rooms[0].name === 'Small face'
&& !space().room_drafts && space().partitions?.length === 4;
const parent = {
id: 'parent', name: 'Parent', area: 'area-parent', open_to: ['neighbour'],
settings: { name_scale: 1.2 },
poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]],
};
await resetGeometry([parent]);
await draw([[200, 100], [200, 500]]);
out.cleanSplitUsesOneCandidate = card._roomDialog
&& card._wallFaceBatch?.candidates.length === 1
&& !!card._wallFaceBatch.candidates[0].split;
card._nameSel = 'Child';
card._saveRoom();
await update();
const retained = space().rooms.find((room) => room.id === 'parent');
out.cleanSplitRetainsParentMetadata = space().rooms.length === 2
&& retained?.name === 'Parent' && retained?.area === 'area-parent'
&& retained?.settings?.name_scale === 1.2;
out.cleanSplitConsumesDivider = !(space().partitions?.length) && !space().room_drafts;
out.wallFaceCacheIsBounded = card._wallFaceGraphCache?.length > 0
&& card._wallFaceGraphCache.length <= 4;
return out;
});
await finish(browser, checkAll(result));
+155
View File
@@ -0,0 +1,155 @@
/** Issue #141: joined independent walls share one production geometry surface. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 860 }, 1);
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const update = async () => {
card._cfgEpoch++;
card._modelCache = null;
card._frame = null;
card._wallUnionCache = null;
card._lightBarrierCache = null;
card.requestUpdate();
await card.updateComplete;
};
const entry = (a, b, cm) => ({
// The smoke needs an exact persisted span; the renderer resolves the key
// from these endpoints before it falls back to the legacy bucket.
key: `smoke-${a.join('-')}-${b.join('-')}`, a: [...a], b: [...b], cm,
});
const a = [0.05, 0.05], tr = [0.95, 0.05], br = [0.95, 0.95], bl = [0.05, 0.95];
const cfg = {
spaces: [{
id: 'junctions', title: 'Junctions', cell_cm: 5, view_box: [0, 0, 1, 1],
rooms: [{ id: 'room', name: 'Room', area: null, poly: [a, tr, br, bl] }],
walls: [entry(a, tr, 0), entry(tr, br, 0), entry(br, bl, 0), entry(bl, a, 0)],
partitions: [
{ id: 'l-horizontal', a: [0.10, 0.30], b: [0.40, 0.30], cm: 10 },
{ id: 'l-vertical', a: [0.40, 0.30], b: [0.40, 0.60], cm: 20 },
{ id: 'oblique-a', a: [0.58, 0.22], b: [0.76, 0.38], cm: 22 },
{ id: 'oblique-b', a: [0.76, 0.38], b: [0.62, 0.53], cm: 12 },
{ id: 't-through', a: [0.18, 0.70], b: [0.78, 0.70], cm: 24 },
{ id: 't-branch', a: [0.50, 0.54], b: [0.50, 0.70], cm: 16 },
{ id: 'room-branch', a: [0.30, 0.82], b: [0.30, 0.95], cm: 18 },
],
room_drafts: [{
id: 'saved-draft', points: [[0.12, 0.56], [0.28, 0.56], [0.28, 0.64]],
segments: [{ cm: 12 }, { cm: 18 }],
}],
wall_columns: [],
}],
markers: [], settings: {},
};
card._serverCfg = structuredClone(cfg);
card._layout = {};
card._space = 'junctions';
card._setMode('plan');
card._tool = 'select';
await update();
const sourceBefore = JSON.stringify(card._serverCfg.spaces[0]);
const space = card._spaceModel();
const bodies = card._physicalBodiesR(space);
const raw = card._rawPhysicalBodiesR(space);
const frame = card._physicalBodiesCache;
result.computedPatchesExist = frame?.patches.length >= 4 && bodies.length > raw.length;
result.rawIdentityCountStaysPerSegment = raw.length === 9
&& card._curSpaceCfg.partitions.length === 7
&& card._curSpaceCfg.room_drafts[0].segments.length === 2;
const wallBody = root().querySelector('.wallbody');
const missingCorner = new DOMPoint(404, 298);
result.savedRightAngleToothIsFilled = !!wallBody?.isPointInFill(missingCorner);
const planD = wallBody?.getAttribute('d') || '';
const insideRing = (point, ring) => {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const a0 = ring[i], b0 = ring[j];
const crosses = ((a0[1] > point[1]) !== (b0[1] > point[1]))
&& point[0] < ((b0[0] - a0[0]) * (point[1] - a0[1]))
/ ((b0[1] - a0[1]) || 1e-12) + a0[0];
if (crosses) inside = !inside;
}
return inside;
};
const insideGeometry = (point, geometry) => (geometry || []).some((polygon) =>
polygon?.length && insideRing(point, polygon[0])
&& !polygon.slice(1).some((hole) => insideRing(point, hole)));
const room = space.rooms[0];
const clean = card._cleanFloor(room, room.poly, space);
result.cleanFloorUsesJoinedCorner = !insideGeometry([404, 298], clean.geom);
const light = card._lightBarriers(
space, [{ r: room, poly: room.poly }], card._physicalBodiesR(space),
);
result.lightUsesJoinedCorner = insideGeometry([404, 298], light.masonryGeometry);
card._tool = 'draw';
card._activeDraftId = null;
card._path = [[120, 780], [380, 780]];
card._draftSegmentCms = [12];
card._drawWallField = '24';
card._cursorPt = [380, 600];
await update();
const liveD = root().querySelector('.drawwall-preview')?.getAttribute('d') || '';
card._path = [[120, 780], [380, 780], [380, 600]];
card._draftSegmentCms = [12, 24];
card._cursorPt = null;
await update();
const committedPreviewD = root().querySelector('.drawwall-preview')?.getAttribute('d') || '';
result.rubberBandAndCommittedPreviewMatch = !!liveD && committedPreviewD === liveD;
card._path = [[500, 540]];
card._draftSegmentCms = [];
card._drawWallField = '20';
card._cursorPt = [500, 700];
await update();
result.lineTargetGetsLocalJoinPatch = root().querySelectorAll('.drawwall-preview').length === 2;
result.previewDoesNotWriteOrSplitTarget = JSON.stringify(card._serverCfg.spaces[0]) === sourceBefore;
card._cursorPt = null;
card._path = [];
card._tool = 'select';
card._setMode('view');
await update();
result.planViewParity = !!planD && root().querySelector('.wallbody')?.getAttribute('d') === planD;
history.replaceState(null, '', '?hp-labs=iso#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();
result.isoUsesJoinedFootprint = !!root().querySelector('.iso-walls .iso-wall-top')
&& card._isoSource().build().walls.flat(2).length > 0;
card._setProjection('flat');
await update();
await customElements.whenDefined('houseplan-space-card');
const staticCard = document.createElement('houseplan-space-card');
const baseCall = card.hass.callWS.bind(card.hass);
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: 'junctions', show_button: false });
staticCard.hass = { ...card.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') return { config: structuredClone(cfg), rev: 1 };
if (message.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
return baseCall(message);
} };
document.body.appendChild(staticCard);
const started = Date.now();
while (!staticCard.renderRoot?.querySelector('.wallbody') && Date.now() - started < 6000)
await new Promise((resolve) => setTimeout(resolve, 60));
await staticCard.updateComplete;
result.staticUsesSameJoinedPath = staticCard.renderRoot
?.querySelector('.wallbody')?.getAttribute('d') === planD;
staticCard.remove();
result.renderNeverRewritesConfig = JSON.stringify(card._serverCfg.spaces[0]) === sourceBefore;
return result;
});
await finish(browser, checkAll(out));
+146
View File
@@ -0,0 +1,146 @@
/**
* #150: a production-scale Split must preserve the exact 10 cm → 0 cm
* exterior breakpoint in every consumer of canonical wall geometry.
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 850 });
const result = await page.evaluate(async () => {
const out = {};
const card = window.__card;
const root = () => card.renderRoot;
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const settle = async () => {
await card.updateComplete;
while (card._modeTransitionBusy) await frame();
await frame();
};
const containsPath = (path, x, y) => !!path?.isPointInFill(new DOMPoint(x, y));
const containsD = (d, x, y) => {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', d || '');
path.setAttribute('fill-rule', 'evenodd');
svg.appendChild(path);
document.body.appendChild(svg);
const inside = containsPath(path, x, y);
svg.remove();
return inside;
};
const pointInRing = ([x, y], ring) => {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const a = ring[i], b = ring[j];
if ((a[1] > y) !== (b[1] > y)
&& x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) inside = !inside;
}
return inside;
};
const pointInGeometry = (point, geometry) => (geometry || []).some((polygon) =>
polygon?.length && pointInRing(point, polygon[0])
&& !polygon.slice(1).some((hole) => pointInRing(point, hole)));
const space = card._serverCfg.spaces.find((item) => item.id === card._space);
space.rooms = [{
id: 'split-source', name: 'Source', area: null,
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]],
}];
delete space.walls;
delete space.openings;
delete space.open_spans;
delete space.partitions;
delete space.room_drafts;
delete space.wall_columns;
space.settings = { ...(space.settings || {}), show_borders: true };
card._cfgEpoch++;
card._regSignature = '';
card._setMode('plan');
card.requestUpdate();
await settle();
// Exercise the real Plan handlers: select room, choose both wall endpoints,
// confirm the child, then apply thickness to all walls of the left child.
card._tool = 'split';
card._splitClick([300, 300]);
card._splitClick([500, 100]);
card._splitClick([500, 900]);
out.splitDialog = !!card._pendingSplit && !!card._roomDialog;
card._nameSel = 'Right';
card._saveRoom();
await settle();
const rooms = card._spaceModel().rooms;
const left = rooms.reduce((best, room) => {
const meanX = room.poly.reduce((sum, point) => sum + point[0], 0) / room.poly.length;
return !best || meanX < best.meanX ? { room, meanX } : best;
}, null)?.room;
out.splitCommitted = rooms.length === 2 && !!left;
card._tool = 'wallthick';
card._wallThickClick([300, 100]);
out.leftWallSelected = card._wallDialog?.roomId === left?.id;
card._wallDialog = { ...card._wallDialog, value: '10' };
card._wallThickApply(true);
await settle();
out.savedIntervals = card._intervalCm([100, 100, 500, 100]) === 10
&& card._intervalCm([500, 100, 900, 100]) === 0
&& card._intervalCm([500, 100, 500, 900]) === 10;
const half = (10 / card._cellCm) * card._gridPitch / 2;
const planPath = root().querySelector('[data-hp="wall"]');
const planD = planPath?.getAttribute('d') || '';
out.planFullDepth = containsPath(planPath, 300, 100 - half * 0.75)
&& containsPath(planPath, 300, 100 + half * 0.75);
out.planZeroSideClear = !containsPath(planPath, 700, 96)
&& !containsPath(planPath, 700, 104);
const canonical = card._wallUnionGeometry();
out.paperZeroSideClear = !!canonical?.paperD && !containsD(canonical.paperD, 700, 96);
card._setMode('view');
await settle();
const viewPath = root().querySelector('[data-hp="wall"]');
const viewD = viewPath?.getAttribute('d') || '';
out.viewMatchesPlan = !!planD && viewD === planD
&& !containsPath(viewPath, 700, 96) && containsPath(viewPath, 300, 96);
const model = card._spaceModel();
const polys = model.rooms.map((room) => ({ r: room, poly: room.poly }));
const barriers = card._lightBarriers(model, polys, card._physicalBodiesR(model));
out.lightUsesSteppedMasonry = pointInGeometry([300, 96], barriers.masonryGeometry)
&& !pointInGeometry([700, 96], barriers.masonryGeometry);
await customElements.whenDefined('houseplan-space-card');
const staticCfg = JSON.parse(JSON.stringify(card._serverCfg));
const baseCall = card.hass.callWS.bind(card.hass);
const staticHass = { ...card.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') return { config: staticCfg, rev: 1 };
if (message.type === 'houseplan/layout/get') return { layout: card._layout || {}, rev: 1 };
return baseCall(message);
} };
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: card._space, show_button: false });
staticCard.hass = staticHass;
document.body.appendChild(staticCard);
const started = Date.now();
while (!staticCard.renderRoot?.querySelector('[data-hp="wall"]') && Date.now() - started < 6000)
await new Promise((done) => setTimeout(done, 60));
await staticCard.updateComplete;
const staticPath = staticCard.renderRoot?.querySelector('[data-hp="wall"]');
out.staticMatchesPlan = staticPath?.getAttribute('d') === planD
&& !containsPath(staticPath, 700, 96) && containsPath(staticPath, 300, 96);
staticCard.remove();
const active = Object.freeze(['iso']);
card._onLabsSnapshot({ active, space: '' });
window.__hpLabs = active;
card._setProjection('iso');
await settle();
const isoWalls = card._isoSource().build().walls;
out.hiddenIsoMatchesCanonical = !!root().querySelector('[data-hp="iso-walls"]')
&& pointInGeometry([300, 96], isoWalls) && !pointInGeometry([700, 96], isoWalls);
return out;
});
checkAll(result);
await finish(browser, result);
+167
View File
@@ -0,0 +1,167 @@
/** Issue #172: a slightly angled zero-depth Split stays free of masonry. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const sp = () => c._serverCfg.spaces.find((space) => space.id === c._space);
const pitch = 1 / 240;
const wallKey = (a, b) => {
const q = (value) => Math.round(value / pitch) * pitch;
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy) || 1;
dx /= length; dy /= length;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
angle = Math.round(angle * 1800) / 1800;
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
};
const entry = (a, b, cm) => ({ key: wallKey(a, b), a: [...a], b: [...b], cm });
const update = async () => {
c._cfgEpoch++;
c._wallUnionCache = null;
c._lightBarrierCache = null;
c.requestUpdate();
await c.updateComplete;
};
const samePoint = (a, b, epsilon = 0.02) => (
Math.hypot(a[0] - b[0], a[1] - b[1]) <= epsilon
);
const sharedEdge = (rooms) => {
for (let ai = 0; ai < rooms[0].poly.length; ai++) {
const a0 = rooms[0].poly[ai], a1 = rooms[0].poly[(ai + 1) % rooms[0].poly.length];
for (let bi = 0; bi < rooms[1].poly.length; bi++) {
const b0 = rooms[1].poly[bi], b1 = rooms[1].poly[(bi + 1) % rooms[1].poly.length];
if (samePoint(a0, b1) && samePoint(a1, b0)) return [a0[0], a0[1], a1[0], a1[1]];
}
}
return null;
};
const pointInRing = (point, ring) => {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const a = ring[i], b = ring[j];
if ((a[1] > point[1]) !== (b[1] > point[1])
&& point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0])
inside = !inside;
}
return inside;
};
const pointInGeometry = (point, geometry) => (geometry || []).some((polygon) => (
polygon?.[0] && pointInRing(point, polygon[0])
&& !polygon.slice(1).some((hole) => pointInRing(point, hole))
));
const a = [0.10, 0.10], tr = [0.90, 0.10], br = [0.90, 0.80];
const notchBottom = [0.60, 0.80], notch = [0.60, 0.40], bl = [0.10, 0.40];
const outer = [
entry(a, tr, 15), entry(tr, br, 15), entry(br, notchBottom, 15),
entry(notchBottom, notch, 15), entry(notch, bl, 15), entry(bl, a, 15),
];
const space = sp();
space.settings = { ...(space.settings || {}), show_borders: true };
space.rooms = [{
id: 'zero-divider-source', name: 'Zero divider source', area: null,
poly: [a, tr, br, notchBottom, notch, bl],
}];
space.walls = outer;
delete space.open_spans;
delete space.openings;
delete space.partitions;
delete space.room_drafts;
delete space.wall_columns;
c._setMode('plan');
c._tool = 'split';
await update();
c._splitClick([700, 250]);
c._splitClick([600, 400]);
c._splitClick([900, 405]);
out.realSplitReachedDialog = !!c._pendingSplit && !!c._roomDialog;
c._nameSel = 'Zero divider child';
c._commitRoom();
await update();
const rooms = c._spaceModel().rooms;
const divider = rooms.length === 2 ? sharedEdge(rooms) : null;
out.realSplitSaved = rooms.length === 2 && !c._pendingSplit;
out.dividerFound = !!divider;
out.anglePreserved = !!divider && Math.abs(divider[3] - divider[1]) > 1;
out.dividerStaysZero = !!divider && c._intervalCm(divider) === 0;
const samples = [];
if (divider) {
const [x0, y0, x1, y1] = divider;
const dx = x1 - x0, dy = y1 - y0;
const length = Math.hypot(dx, dy);
const nx = -dy / length, ny = dx / length;
for (const t of [0.2, 0.35, 0.5, 0.65, 0.8]) {
for (const side of [-1, 1])
samples.push([x0 + dx * t + nx * side * 2, y0 + dy * t + ny * side * 2]);
}
}
const wall = sr().querySelector('.wallbody');
out.planHasNoTaper = !!wall && samples.length > 0
&& samples.every((point) => !wall.isPointInFill(new DOMPoint(...point)));
const planD = wall?.getAttribute('d') || '';
out.planUsesCanonicalBody = !!planD && c._wallUnionGeometry()?.d === planD;
const lightSpace = c._spaceModel();
const lightPolys = lightSpace.rooms
.filter((room) => Array.isArray(room.poly))
.map((room) => ({ r: room, poly: room.poly }));
const lightPhysical = c._physicalBodiesR(lightSpace);
const masonry = c._lightBarriers(lightSpace, lightPolys, lightPhysical).masonryGeometry;
out.lightHasNoTaper = samples.every((point) => !pointInGeometry(point, masonry));
const persisted = JSON.stringify({ rooms: space.rooms, walls: space.walls });
c._setMode('view');
await update();
out.planViewParity = sr().querySelector('.wallbody')?.getAttribute('d') === planD;
const kioskBefore = c._config.kiosk;
c._config.kiosk = true;
await update();
out.kioskParity = sr().querySelector('.wallbody')?.getAttribute('d') === planD;
c._config.kiosk = kioskBefore;
history.replaceState(null, '', `?hp-labs=iso#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();
out.isoUsesCanonicalBody = !!sr().querySelector('.iso-walls .iso-wall-top')
&& c._isoSource().build().walls.flat(2).length > 0;
c._setProjection('flat');
await update();
await customElements.whenDefined('houseplan-space-card');
const cfg = structuredClone(c._serverCfg);
const baseCall = c.hass.callWS.bind(c.hass);
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
staticCard.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (message.type === 'houseplan/layout/get') return { layout: c._layout || {}, rev: 1 };
return baseCall(message);
} };
document.body.appendChild(staticCard);
const started = Date.now();
while (!staticCard.renderRoot?.querySelector('.wallbody') && Date.now() - started < 6000)
await new Promise((resolve) => setTimeout(resolve, 60));
await staticCard.updateComplete;
out.staticParity = staticCard.renderRoot?.querySelector('.wallbody')?.getAttribute('d') === planD;
staticCard.remove();
out.renderDoesNotRewriteConfig = JSON.stringify({ rooms: space.rooms, walls: space.walls }) === persisted;
return out;
});
checkAll(res);
await finish(browser, res);
File diff suppressed because one or more lines are too long
+636 -404
View File
File diff suppressed because one or more lines are too long
+131 -30
View File
@@ -1,6 +1,6 @@
# House Plan architecture
Updated: 2026-08-11 (v1.61.0-beta.6 light-transport rewrite). The repository = a HACS integration (category **Integration**)
Updated: 2026-08-19 (#113 optional space-model lifecycle). The repository = a HACS integration (category **Integration**)
that contains both the backend (`custom_components/houseplan`) and the Lovelace card (`src/` → `dist/`).
## Layout
@@ -15,6 +15,7 @@ houseplan-card/
│ ├─ floating-surface-controller.ts # shared Popover/fallback portal DOM lifecycle
│ ├─ editor-secondary.ts # context tray model, groups, focus/dismiss lifecycle and stable template
│ ├─ editor-secondary.styles.ts # styles owned by the context tray/submenu surface
│ ├─ space-model-selection.ts # active-or-first and exact optional space selectors
│ ├─ render/opening-tunnels.ts # immutable SVG projection of resolved tunnel geometry/fills
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
│ ├─ rules.ts # icon rules (iconFor), filtering, groups, fallback order
@@ -92,7 +93,13 @@ the same profiler available between stable promotions.
commit actions use two explicit wrapping groups: destructive actions stay
left, while Cancel/Save move together to a right-aligned second line when
translated labels do not fit.
6. **One transient-surface contract.** `hp-dialog` owns a scoped LIFO registry
6. **Open passages are negative architecture.** `OpeningCfg.type=passage`
shares placement, wall-cut and tunnel geometry with other openings but has
no visible leaf, state binding or isometric panel. Backend semantic
validation is change-aware: existing broken records remain readable, while
new writes/imports are canonical. Static wall fingerprints include passage
cuts only, preserving the historical output of doors/windows/gates.
7. **One transient-surface contract.** `hp-dialog` owns a scoped LIFO registry
for explanatory/help and colour-picker surfaces. Escape and toast close the
upper transient surface before the dialog, and a new transient surface
replaces the previous one only inside the same dialog. `hp-help` and
@@ -104,6 +111,18 @@ the same profiler available between stable promotions.
affordance exists only when both its localized body and complete accessible
label are non-empty; the card factory and `hp-help` enforce this independently,
so incomplete content cannot leave a dead focus target or a layout gap.
8. **One four-phase environment resolver.** `resolveDayCycle()` in `src/sun.ts`
atomically chooses a strict real `sun.sun` sample or browser-local clock
fallback and returns only phase/source/light tokens. `src/day-cycle-render.ts`
owns the constant four-layer DOM and exact palette used by full View, kiosk,
and `houseplan-space-card`; no surface copies thresholds or formulas. The
environment is a pointer-inert sibling behind the plan. The only
phase-dependent effect touching the SVG is a zero-offset filter on the one
grouped `.hp-paperg` footprint; the content tree has no brightness, tint,
opacity, or blend changes. Full and static card lifecycles arm a 30-second
timer only during clock fallback while visible, catch up on visibility return,
and dispose it on disconnect. Window-ray geometry remains a separate
north-gated consumer of `sun.sun`.
## Coordinate system
@@ -119,6 +138,23 @@ the same profiler available between stable promotions.
## Card data model (runtime)
`SpaceModel` is absent when the authoritative configuration has no spaces.
`_spaceModel()` therefore returns `SpaceModel | undefined`: it preserves the
legacy active-or-first selection for rendering and current navigation, but it
never invents a dummy space. Commands carrying a persisted or otherwise stable
space id use the exact `_spaceModelById()` selector; a stale id aborts before
config, layout, file or service side effects instead of mutating the first
space.
The first update that observes an authoritative empty `spaces` array runs one
space-bound lifecycle cleanup. It releases tracked pointer capture, cancels
pan/pinch/drag/resize/vacuum and geometry gestures, clears draft/history and
space dialogs, cancels the debounced config write and returns the card to View.
The global empty-state Create/import flows remain available. Recreating a space
re-arms cleanup so a later WS transition back to empty is handled identically.
Pure render/geometry helpers may return an empty result while the model is
absent; mutation entry points must guard explicitly.
`DevItem`: id (device_id), name, model, area, floor, icon, entities[], primary
(the first resolved state entity for actions requiring one target), temp,
members[] (light group), link/linkPrimary (Z2M group). Marker state consumes
@@ -311,12 +347,22 @@ quotas, nothing is ever deleted for being old (docs/SCOPE.md).
Room-boundary walls remain *derived* from room outlines (`roomEdges`, deduped by
`segKey`), so deleting a room keeps the boundaries its neighbours still
contribute. Three explicitly typed exceptions are stored per space:
`room_drafts` for persisted unfinished room outlines, `partitions` for
one-segment independent walls and `wall_columns` for square/circular columns.
`room_drafts` for crash-safe unfinished Walls chains, `partitions` for finished
independent wall segments and `wall_columns` for square/circular columns.
They do not create a room or HA area and never split a room implicitly. Their
physical bodies are unioned with room walls for rendering and light occlusion,
and subtracted from clean room floor area. Openings (doors, windows and gates)
still belong only to derived room walls and never cut an independent object.
and subtracted from clean room floor area. A finished partition may explicitly
host a door, window, gate or passage; unfinished drafts and columns may not.
Independent linear objects have two deliberate projections. Raw flat-capped
quads preserve source identity for hit/selection/drag/properties/delete/history
and furniture magnet behaviour. `physicalBodySet()` also derives exact
endpoint↔endpoint and endpoint↔line topology, adds bounded mitre/bevel patches
without persisted nodes or segment splits, and exposes the joined geometry to
presentation and physics. Degree-one caps remain flat; an interior X crossing
is only a boolean overlap. The full card caches this structural frame by
space/config geometry, while static cards use a weak server-snapshot cache;
cursor and HA state updates do not repeat the saved O(N²) node search.
Rooms may not overlap
(`pointStrictlyInside` + `roomsOverlap`; being ON a shared wall is legal — real neighbouring
@@ -334,7 +380,20 @@ before the shell is restored. Consequently a Split edge ending at an exterior
vertex cannot contribute a child-room mitre to the facade. Per-room rings remain
an interior join/nested-room representation, and atomic quads provide a safe
physical interval when an acute child ring cannot be subtracted. Paper and
masonry paths are emitted by that same geometry pass. The full card retains the
masonry paths are emitted by that same geometry pass. Computed independent
junction patches enter through the same physical union. A partition-hosted
opening is subtracted from its explicit raw body before that union. It also
cuts a derived room wall only when the wall is exactly collinear and covers
the complete hosted interval; crossing or nearby bodies remain opaque and room
exterior authority remains intact.
Before the exterior offset is built, each saved atomic endpoint splits its
containing collinear union edge. Offset changes are explicit butt steps at that
endpoint, including nonzero-to-zero transitions. The topology tolerance starts
in render units and is divided by the current edge length before it is compared
with or used to de-duplicate normalized `t` fractions; this keeps the result
scale-independent and prevents one interval's depth from leaking into its
neighbour.
The full card retains the
pair in `_wallUnionCache`; static cards retain it in a weak server-snapshot
cache guarded by a structural geometry fingerprint. This is computed render
state only: it never rewrites rooms or wall entries, and an HA state tick does
@@ -367,7 +426,7 @@ uses the Stage 1 latched Flat fallback. Details and fixed ratios are recorded in
## Markup editor (v1.4.0+)
State inside the card: `_markup` (mode), `_tool` (draw/partition/column/merge/split/resize/opening/
State inside the card: `_markup` (mode), `_tool` (draw/column/merge/split/resize/opening/
boundary/wallthick/delroom), `_path` (the current outline,
vertices on the GRID_N=240 grid). Clicks on the stage → `_svgPoint`→`_snap`. The outline is closed
= a click on the first vertex → area select (hass.areas) + name → room {poly}. Polygon rooms and
@@ -390,16 +449,32 @@ viewBox, so zoom never changes the effective target. The first point is a
transient gesture only: Esc, Undo/Redo, navigation, external config adoption,
pointer cancellation and multi-touch discard it without touching history.
Every completed segment of an unfinished room contour is persisted in
`room_drafts`, including the thickness selected when that segment was placed.
Switching tools keeps it; the same editing session resumes automatically, and
after reload either endpoint can be selected to continue. Closing converts the
draft into a room, while the room dialog's secondary action converts its edges
to independent partitions. `partition` creates exactly one wall per gesture;
`column` creates a square object whose size comes from the current Thickness
field. Double click edits physical-object properties, pointer drag moves the
whole object on the grid, and Delete removes only the selected object. The
legacy root `space.segments` array is still stripped on every save.
Every completed Walls segment is persisted in `room_drafts`, including the
thickness selected when that segment was placed. Changing Plan tool, editor or
floor explicitly finishes an open chain by converting it to ordinary
`partitions` in one history/save transaction; re-selecting Walls is a no-op.
Pan, pinch, pointer cancellation and suppressed clicks never finish a chain or
append a segment. A finished open chain is ordinary masonry and is not resumed
as a draft. Reload recovery may resume only a still-active persisted draft.
`src/wall-face-graph.ts` derives an immutable planar graph from solid room edges
after opening cuts, partitions, inactive drafts and the active chain. A sweep
broadphase atomizes endpoint, T, X and collinear intersections; deterministic
half-edge traversal extracts bounded canonical faces. The click handler diffs
the graph before/after the latest segment and offers only newly created faces
that contain an atom of that segment, ordered by area and canonical key. Exact
or partial overlap with a room is rejected while legal nesting is preserved.
A clean single-room divider reuses `splitRoomPath`, offering only the smaller
child while the larger child retains the original room identity and metadata.
Room answers are buffered in `_wallFaceBatch`. Create/Keep-as-walls advance the
queue without mutating geometry; Cancel/Esc restores the terminal draft. The
last answer revalidates every face and capacity limit, then commits all accepted
rooms, the split result and every unconsumed active atom in one Undo/Redo and
config transaction. Existing saved source geometry is never atomized or
rewritten merely because it participated in a face. `column` still creates a
physical object whose size comes from the current Thickness field. The legacy
root `space.segments` array is stripped on every save.
While drawing, the length of the current segment follows the cursor (`_fmtLen` → `segmentCm`/
`formatLength`): metres, or feet+inches when `hass.config.unit_system` is imperial. The scale is
@@ -435,16 +510,17 @@ navigation, focus restoration and outside-dismiss consumption), but no current
tools are grouped without a separate product decision. The change is UI-only:
plan/config models and geometry commands are unchanged.
## Doors, windows & gates (v1.23.0+)
## Doors, windows, gates & passages (v1.23.0+)
`space.openings[]` — plan geometry, **not** markers: an opening needs an angle, a length and a
wall, while markers are free points whose positions live in the layout store. Model:
`{id, type: door|window|gate, x, y, angle, length, contact?, lock?, invert?, flip_h?, flip_v?}`
(normalized coords; `length` normalized by plan width). Placement snaps onto the nearest
**derived** wall via `snapToWall` (logic.ts) — the angle is normalized to [-90, 90) because two
rooms share a wall with opposite edge directions, and without that a drag across segment
boundaries flips the hinge. The opening then keeps **absolute coordinates**, so editing, merging
or deleting rooms never breaks it.
`space.openings[]` — plan geometry, **not** markers: an opening needs an angle,
a length and one wall, while markers are free points whose positions live in
the layout store. Model:
`{id, type: door|window|gate|passage, x, y, angle, length, host?, contact?, lock?, invert?, flip_h?, flip_v?}`.
Room-wall openings omit `host` and retain the absolute-coordinate association.
An independent-wall opening stores
`host:{kind:'partition',id,t}`; the stable id and normalized position `t` are
authoritative, while `x/y/angle` are an atomically refreshed compatibility
projection. No explicit host ever falls back to a nearest wall.
Rendering (after easy-floorplan, MIT): SVG symbol at the origin (jambs + hinged leaf + a
quarter-circle arc revealed via `stroke-dashoffset`), translated/rotated onto the wall; windows
@@ -461,8 +537,11 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
Contact and lock are exact HA references owned by the opening, not aliases of
standalone markers. Their candidate/action path follows HA binding status while
their render path follows the frozen active-registry projection; neither path
consults marker tombstones. Plan-level consumers keep the tombstone policy
described above.
consults marker tombstones. For that projection, the presence of an exact state
is sufficient: registry-less YAML entities have no row, while explicit
disabled/orphan rows have already been stripped together with their states.
The render helper must never receive raw live hass. Plan-level consumers keep
the tombstone policy described above.
For a wall with thickness, one `OpeningWallIndex` resolves the atomic wall
interval and adjacent room on each side of the centreline. Opening symbols,
@@ -482,6 +561,15 @@ room both halves; shared openings use a local-coordinate hard stop at `y=0`.
Virtual spans, unfinished drafts and zero-thickness walls are ignored; legacy
spans are clipped per atomic body.
The same resolved host drives placement, symbol face, full-depth partition cut,
static/hidden-isometric rendering, Glow and edit operations. Rigid host drag
keeps `t` and updates every materialized projection in one history command.
Deleting a host with openings requires an explicit cascade dialog; an invalid
host fails dark and is visible only as a rebind diagnostic in Plan. Structural
room-face topology deliberately keeps every valid wall axis continuous through
all opening types (#185); only `open_spans` and absent walls are connectivity
gaps.
## Integration WS API
| Command | Parameters | Response |
@@ -504,7 +592,7 @@ spans are clipped per atomic body.
| `houseplan/files/migrate` | `from_id`, `to_id` | `{mapping}` — COPY, never move |
| `houseplan/files/cleanup` | `marker_id`, `keep?` | replacement-only collection |
| `houseplan/content/sign` | `paths[]` | `{urls}` — authSig for `<image>`/`<a>` fetches |
| `houseplan/export/create` | `kind`, `space_id?`, `card_version` | consistent versioned JSON document + safe filename |
| `houseplan/export/create` | `kind`, `space_id?`, `plan_only?`, `card_version` | consistent versioned JSON document + safe filename; plan-only is valid only for one space |
| `houseplan/import/revalidate` | preview `token`, `duplicate_policy?` | refreshed bounded preview and current expected revisions |
| `houseplan/import/apply` | token, both expected revisions, content confirmation | crash-resumable paired config/layout commit; full import gets one-deep undo |
@@ -535,6 +623,14 @@ versions, and retains the parsed candidate only in memory for ten minutes. Its
opaque token is bound to the HA user, normalized-candidate digest and the exact
config/layout revisions. Parsed candidates are capped globally as well as per
user.
Plan-only export is a server-owned, fail-closed projection rather than a
client-side scrub. It removes every marker and all device layout, preserves
only canonical room-label placements, and copies one space through explicit
geometry/presentation allowlists. The parser recomputes that projection and
its placement manifest before showing a plan-only preview, so manually adding
a private field while keeping `transfer.plan_only: true` is rejected.
The browser never parses imported configuration. Full import and maintenance
share the `optimize_pending` crash-recovery intent and the one-deep backup slot;
the backup carries `kind: optimize|import`, while every layout-store writer
@@ -895,6 +991,11 @@ hash falls back to the default.
service catalog is a second fail-closed guard. A click resolves the current
marker by id rather than using a retained #73 visual snapshot; the snapshot
remains valid only for read-only presentation.
Optional `marker.toggle_entity` is an exact own `light.*`/`switch.*` override
layered before the legacy own-role resolver. Its absence leaves legacy
single/group membership unchanged; an active explicit choice also joins an
explicit controls group, while stale values fall back without being erased.
It is deliberately independent from visual `marker.light_entity`.
- **Resolved device state** (2026-08-06): HA provides states per entity, not
one state per device. `resolvedDeviceStateEntities` therefore starts from
uncategorised registry entities, resolves one functional role (whole-device
+53
View File
@@ -542,3 +542,56 @@ the ±`CANVAS_LIMIT` guard. Rigid partition drag clamps one shared delta against
both endpoints, so it cannot deform the segment or let its far endpoint cross
the backend boundary. Hit areas and drag thresholds are expressed in CSS
pixels, therefore selection remains usable at every zoom.
## Architectural connection overlay
When **Walls** is active in the Plan editor, a derived
pointer-transparent SVG layer exposes the centre axes of completed room walls,
saved inactive outlines and independent partitions. It is painted after their
physical wall bodies, but before interactive editor chrome. Columns, decor,
devices, the active wall chain and its live preview are not candidates. Door,
window, gate and intentionally open-span intervals are cut from presentation
axes; a cut boundary does not become a new endpoint.
The layer and hit resolver share one immutable geometry snapshot. Original
segment endpoints are deduplicated and drawn at a physical radius of 5 cm.
Inside a 12 CSS px hit zone, an endpoint wins over every line and grows to
10 cm. Otherwise the nearest solid line receives one 10 cm dynamic node: the
raw pointer is projected onto that line, then quantized by the grid step along
the line from its stable start. This keeps diagonal connections wall-bound even
when neither resulting coordinate is a global grid multiple. The same resolver
runs again on click, so hover is only a preview and never authoritative.
Endpoint and line candidates override the normal grid and Shift/45° result.
Outside the hit zone, §9.3–9.4 remain unchanged. A line connection adds only the
new segment endpoint; it does not split or rewrite the existing wall. The
current anchor is excluded to prevent zero-length segments. The static geometry
is cached by structural editor state; pointer movement changes at most the
single active candidate and never writes config, layout or storage.
## Planar wall faces
Every completed Walls segment is first persisted in the active `room_drafts`
chain. On the click path only, an immutable planar graph is built from structural
room edges, independent partitions, inactive drafts and the active chain both
before and after the latest segment. Unlike the presentation/snap snapshot, this
face graph ignores door/window/gate/passage cuts but still applies `open_spans`.
Endpoint, T, X and
collinear-overlap junctions atomize that computed graph without rewriting any
saved wall. A deterministic half-edge walk extracts bounded faces; canonical
identity ignores winding, cyclic start and derived collinear subdivision.
Only faces added by the latest segment and containing one of its atoms are
offered. They are ordered by area and then canonical key. Existing exact or
partially overlapping rooms are excluded, nested rooms remain legal, and any
physical gap created by an `open_span` or absent wall remains a gap. A door,
window, gate or passage is a property of a wall and preserves connectivity. A clean divider across
one room reuses the Split contract: the larger side keeps the room identity,
metadata and device binding, and only the smaller side is offered.
The active draft remains persisted while the resulting room dialogs are open.
Create/Keep-as-walls answers are buffered; Cancel/Esc discards all answers and
restores the terminal draft. The final answer revalidates the whole batch and
applies accepted rooms plus every unconsumed active atom as one history/config
transaction. Graph construction never runs on pointermove, Home Assistant state
updates or ordinary rendering.
+150 -1
View File
@@ -1,6 +1,155 @@
# Changelog
## Unreleased
## v1.65.0-beta.2 — 2026-08-18
- **Toggle state** confirmation now shows the current state and the exact
expected result before acting. Groups show their active/total count and
unavailable targets separately, while confirmation still re-resolves the
live state and cancels if the target set changed
([#103](https://github.com/Matysh/houseplan-card/issues/103)).
- Composite Home Assistant devices with several light/switch entities now let
you choose the exact entity operated by **Toggle state**. The dialog previews
the selected target immediately, preserves missing choices with a warning,
and can include the selected own entity in an explicitly configured group
without changing existing plans ([#178](https://github.com/Matysh/houseplan-card/issues/178)).
- A virtual Always-light linked to a real relay now follows that relay instead
of keeping an independent manual state. Tapping either marker operates the
real Home Assistant device, while Glow, Light fill, room counts and both
markers update together; removing the final link restores the preserved
manual state ([#174](https://github.com/Matysh/houseplan-card/issues/174)).
- The Plan editor now has one **Walls** tool instead of separate Room outline
and Partition tools. An open chain becomes ordinary walls when the tool,
editor or floor changes; closing endpoint/T/X geometry offers every newly
formed room in stable area order, with clean room splits and atomic
Create/Keep/Cancel decisions. Ctrl/Cmd+click no longer adds an extra point
while the chain is still too short to close
([#173](https://github.com/Matysh/houseplan-card/issues/173)).
- Doors, windows, gates and open passages can now be placed in finished
independent Walls segments. They cut the full wall depth, move with their
host, keep the same Home Assistant state/actions as room-wall openings and
are removed only through an explicit cascade confirmation. Openings no
longer break the structural wall axis, so drawing a closed contour still
offers the room even when one of its walls already contains an opening.
Space backups preserve the opening-to-wall binding when imported, while the
editor's visual snap guide keeps its physical gap across the opening
([#132](https://github.com/Matysh/houseplan-card/issues/132),
[#185](https://github.com/Matysh/houseplan-card/issues/185)).
- Collinear exterior walls now change thickness exactly at their saved
breakpoint. After splitting a room, a 10 cm wall therefore keeps its full
depth up to the divider without leaking onto an adjacent zero-thickness
facade; Plan, View, static, hidden isometric and light geometry share the
corrected outline ([#150](https://github.com/Matysh/houseplan-card/issues/150)).
- A zero-thickness Split divider no longer borrows a triangular wedge from an
adjoining thick wall when the cut is slightly angled. The divider stays
visually open along its full length while the real wall ends in a local cap,
with the same geometry in Plan, View, Static, hidden isometric and lighting
([#172](https://github.com/Matysh/houseplan-card/issues/172)).
- Home Assistant devices and individual entities manually assigned to a room
without an HA Area now stay in that exact room instead of returning to their
registry Area. Existing saved assignments recover automatically, and
editing a room within the same space keeps the marker position
([#170](https://github.com/Matysh/houseplan-card/issues/170)).
- Plan editor now supports **Open passage**, a 90 cm architectural opening
with no leaf, arc or Home Assistant binding. It cuts masonry and continues
the room floor in Full and Static views, passes interior light, and remains
a full-height zero-panel cut in the hidden isometric experiment
([#157](https://github.com/Matysh/houseplan-card/issues/157)).
## v1.65.0-beta.1 — 2026-08-17
- Current-space export can now create a **Plan only** JSON template with rooms,
walls, openings, decor, backdrop, room-label positions and scale, while
removing devices and structural Home Assistant bindings. Import preview identifies
the template before adding it as a new space
([#167](https://github.com/Matysh/houseplan-card/issues/167)).
- Composite appliances such as washing machines now use an explicit Home
Assistant Status/Run state/Job state to show the yellow working marker during
an active cycle. Power-on alone remains neutral, Power-off still fades stale
activity, and ordinary lone relays keep their existing behaviour
([#164](https://github.com/Matysh/houseplan-card/issues/164)).
- Window sunlight now combines the Home Assistant azimuth with the literal
direction of the compass N arrow, so a rotated real north lights the
physically correct side of the plan. Users who mirrored the compass to work
around the previous bug should point it back to the real north
([#166](https://github.com/Matysh/houseplan-card/issues/166)).
- Small fixes and improvements.
- Door, window and gate contacts and locks now keep working when they are live
YAML entities without a `unique_id` and therefore have no Entity Registry
row. The picker, View animation, lock badge and opening info card now follow
the same exact reference, while disabled, orphaned and missing entities
remain unavailable ([#117](https://github.com/Matysh/houseplan-card/issues/117)).
## v1.64.0-beta.3 — 2026-08-14
- Large plans no longer recompute an unused physical-wall union on every floor
switch, and the Plan snap target before the first click updates without
rerendering the complete card, removing the `v1.64.0` release performance
regressions while preserving the same geometry and snap behaviour
([#156](https://github.com/Matysh/houseplan-card/issues/156)).
- Small fixes and improvements.
## v1.64.0 — 2026-08-14
- **Follows the Sun** now gives the plan a four-phase dawn/day/dusk/night
environment with moving soft light while preserving the plan's live fills,
Glow, devices, decor, vacuum, hover and window rays
([#146](https://github.com/Matysh/houseplan-card/issues/146)).
- Room outline and Partition drawing now exposes exact wall snap nodes and
centre lines, closes valid contours along an existing room wall, and forms
seamless thick corner and T-junctions in flat, static and hidden isometric
geometry ([#137](https://github.com/Matysh/houseplan-card/issues/137),
[#138](https://github.com/Matysh/houseplan-card/issues/138),
[#141](https://github.com/Matysh/houseplan-card/issues/141)).
- Read-only View and kiosk cards now paint a complete first frame even when
Home Assistant refuses live-sync event subscriptions
([#131](https://github.com/Matysh/houseplan-card/issues/131)).
- Virtual markers configured as an always-on light source can now be toggled
without a Home Assistant helper, with persistent shared state across full
and static cards ([#107](https://github.com/Matysh/houseplan-card/issues/107)).
- Opening and physical-object property dialogs keep their footer actions on one
desktop line while retaining responsive narrow-screen wrapping
([#140](https://github.com/Matysh/houseplan-card/issues/140)).
- Large plans no longer recompute an unused physical-wall union on every floor
switch, and the Plan snap target before the first click updates without
rerendering the complete card
([#156](https://github.com/Matysh/houseplan-card/issues/156)).
- Small fixes and improvements.
## v1.64.0-beta.2 — 2026-08-14
- **Follows the Sun** now gives the plan a four-phase dawn/day/dusk/night
environment with a moving soft light and alpha-aware outer outline. It uses
`sun.sun` when its sample is complete, falls back to local browser time, and
no longer needs the compass; the plan, Glow, fills, devices, decor, vacuum,
hover and window rays remain visually unchanged across phases. Full View,
kiosk and static space cards share the result. New installations and spaces
enable it explicitly, while a one-time migration and portable import/export
preserve every existing plan's prior background choice. Existing zoom
percentage badges remain visible above the plan at magnification
([#146](https://github.com/Matysh/houseplan-card/issues/146)).
- Opening and physical-object property dialogs now use the existing 500 px
desktop shell, keeping Delete, Cancel and Save on one line with localization
headroom while preserving responsive wrapping on narrow screens
([#140](https://github.com/Matysh/houseplan-card/issues/140)).
- A new room outline that starts and ends on one uninterrupted solid interval
of an existing room wall now closes automatically and opens the normal room
dialog. Endpoints and wall-bound T-connection points work alike; openings,
intentional gaps, different edges and a second point without enough sides do
not trigger the shortcut ([#138](https://github.com/Matysh/houseplan-card/issues/138)).
- Connected Room outline and Partition segments now form one seamless thick
wall immediately, including the live rubber-band. Right and oblique corners
use bounded mitre/bevel joins, T-connections have no visible butt face, each
segment keeps its own thickness, and single-segment thickness hover and free
ends remain visible and flat consistently in
Plan, View, static cards, hidden isometric, clean-floor and light geometry
([#141](https://github.com/Matysh/houseplan-card/issues/141)).
- Room outline and Partition drawing now shows wall centre lines and exact
endpoints above existing walls. The enlarged target previews whether the
next click will join an endpoint or create a wall-bound T-connection, while
door/window/gate and intentional open-span gaps remain excluded
([#137](https://github.com/Matysh/houseplan-card/issues/137)).
- Small fixes and improvements.
## v1.64.0-beta.1 — 2026-08-14

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