Compare commits

..
Author SHA1 Message Date
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
Sergey Matyunin 159094cfec Fix pre-release smoke probes after merged contracts
Issue: #122
Issue: #131
Issue: #107
User-Visible: no
2026-08-14 04:29:21 +03:00
Sergey Matyunin 188a386cd8 Accept v1.64.0-beta.1 Linux golden baselines
Issue: #122
User-Visible: no
Release: v1.64.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/31759881579
2026-08-14 04:18:30 +03:00
Sergey Matyunin 5df8b723e7 Release v1.64.0-beta.1 candidate
Issue: #122
Issue: #131
Issue: #107
User-Visible: yes
2026-08-14 04:12:23 +03:00
Sergey Matyunin de0171dd02 fix: keep manual virtual light face canonical
Issue: #107
User-Visible: yes
2026-08-14 03:55:06 +03:00
claude[bot] f1e6cca3db docs: code review document for #107 (r1, red)
Issue: #107
User-Visible: no
2026-08-14 00:51:58 +00:00
Sergey Matyunin 1079cdfab2 feat: add persistent virtual light toggles
Issue: #107
User-Visible: yes
2026-08-14 03:32:53 +03:00
claude[bot] b7b28ee579 docs: review document for #107
Issue: #107
User-Visible: no
2026-08-14 00:02:41 +00:00
Sergey Matyunin af851cda85 docs: specify virtual light toggle
Issue: #107
User-Visible: no
2026-08-14 02:54:05 +03:00
Sergey Matyuninandclaude[bot] 0af095a6c4 fix: complete read-only cold start
Issue: #131
User-Visible: yes
2026-08-13 23:28:19 +00:00
claude[bot]andclaude[bot] 9ad2b3b4ef docs: spec review document for #131 (r1, green)
Issue: #131
User-Visible: no
2026-08-13 23:28:19 +00:00
Sergey Matyuninandclaude[bot] fea0d55c67 docs: specify readonly cold start behavior
Issue: #131
User-Visible: no
2026-08-13 23:28:19 +00:00
Matysh bc98116a31 test: a registry of known breakages that tests must catch
Five times in this project a green test meant nothing was checked. The
continuity smoke stayed green after the entire mechanism it guards was cut out.
The golden scene created to protect doorway light was empty — 1,177 warm pixels
against 107,119, all of them icons. The shadow smoke passed while no shadow was
drawn. Each time the test had been written alongside the code, went green at
once, and nobody ever asked whether it could go red.

The gate makes that question routine. Each mutant is a few lines of patch that
reproduce a known breakage, plus the name of the test that must fail on it. A
worktree is patched, the bundle rebuilt, the guard run — and a guard that stays
green fails the gate. Six mutants cover the holes documented in #85; the anchors
are exact strings from today's source, so the registry cannot silently drift —
a unit test that runs with the ordinary suite refuses a stale anchor.

The full run rebuilds the bundle per mutant, so it lives in its own workflow,
before a stable release and on a weekly schedule, not in Validate. The rules for
new tests are written at the top of docs/TESTING.md, and the sixth of them is
the cheapest: an assertion that reads back the property the code just set is
not written at all.

Issue: #85
User-Visible: no
2026-08-14 02:15:12 +03:00
Matysh 328ed7afc0 test: a registry of known breakages that tests must catch
Validate / provenance (push) Successful in 46s
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 12s
Validate / process-gate (push) Failing after 35s
Validate / frontend (push) Successful in 6m11s
Validate / backend (push) Failing after 9m24s
Validate / golden (push) Failing after 10m10s
Validate / performance_smoke (push) Failing after 12m46s
Validate / smoke (push) Failing after 30m15s
Five times in this project a green test meant nothing was checked. The
continuity smoke stayed green after the entire mechanism it guards was cut out.
The golden scene created to protect doorway light was empty — 1,177 warm pixels
against 107,119, all of them icons. The shadow smoke passed while no shadow was
drawn. Each time the test had been written alongside the code, went green at
once, and nobody ever asked whether it could go red.

The gate makes that question routine. Each mutant is a few lines of patch that
reproduce a known breakage, plus the name of the test that must fail on it. A
worktree is patched, the bundle rebuilt, the guard run — and a guard that stays
green fails the gate. Six mutants cover the holes documented in #85; the anchors
are exact strings from today's source, so the registry cannot silently drift —
a unit test that runs with the ordinary suite refuses a stale anchor.

The full run rebuilds the bundle per mutant, so it lives in its own workflow,
before a stable release and on a weekly schedule, not in Validate. The rules for
new tests are written at the top of docs/TESTING.md, and the sixth of them is
the cheapest: an assertion that reads back the property the code just set is
not written at all.

Issue: #85
User-Visible: no
2026-08-14 02:05:53 +03:00
claude[bot] 0e69c4a183 docs: code review document for #122 (r2, green)
Issue: #122
User-Visible: no
2026-08-13 22:27:31 +00:00
Sergey Matyunin ef3cc98d1c fix: preserve isometric fallback rendering
Issue: #122
User-Visible: no
2026-08-14 01:13:11 +03:00
claude[bot] e13215c02f docs: code review document for #122 (r1, red)
Issue: #122
User-Visible: no
2026-08-13 22:04:00 +00:00
Sergey Matyunin 42b3f44c4a feat: add hidden isometric stage 2
Issue: #122
User-Visible: no
2026-08-14 00:43:13 +03:00
claude[bot] 4c73e2ccdb docs: review document for #122
Issue: #122
User-Visible: no
2026-08-13 21:04:32 +00:00
Sergey Matyunin 76ce755742 docs: specify hidden isometric stage 2
Issue: #122
User-Visible: no
2026-08-13 23:55:45 +03:00
Sergey Matyunin 50099acc75 fix: allow stable promotion of published beta history
Validate / provenance (push) Successful in 5m46s
Validate / process-gate (push) Failing after 5m56s
Validate / hacs (push) Failing after 20s
Validate / hassfest (push) Failing after 14s
Validate / frontend (push) Successful in 13m53s
Validate / backend (push) Failing after 10m41s
Validate / golden (push) Failing after 9m40s
Validate / performance_smoke (push) Failing after 17m26s
Validate / smoke (push) Failing after 34m54s
Full Performance / performance (push) Failing after 1h54m13s
Issue: #130
User-Visible: no
2026-08-13 22:59:23 +03:00
Sergey Matyunin a282f850af build: promote v1.63.0
Issue: #129
User-Visible: yes
2026-08-13 22:47:48 +03:00
Sergey Matyunin d7f3bb8119 test: accept v1.63.0-beta.2 golden baselines
Issue: #123
User-Visible: no
Release: v1.63.0-beta.2
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/31734606270
2026-08-13 22:26:38 +03:00
Sergey Matyunin 5c6ab8ea9b Release v1.63.0-beta.2 candidate
Issue: #123
User-Visible: yes
2026-08-13 22:11:02 +03:00
Sergey Matyunin fda4893f0c Merge updated dev for v1.63.0 2026-08-13 22:10:28 +03:00
Matysh 888e90450a perf: make review scope and ceremony fit the size of the task
The owner's report: the process works but every stage takes a long time even on
simple bugs. Two causes, and neither was the one that first comes to mind.

The reviewer ran everything regardless. On #89 it installed Chromium, ran all 127
smoke files and a full golden capture — right for a task rated 10/10 for
complexity, absurd for a bug about a room divider. Full suites are the pre-beta
gate; the review now runs typecheck, unit and build always, and smokes, golden,
pytest or performance only where the diff and the AC call for them. The price of
narrowing it is honesty: the reviewer must list which gates it ran, which it did
not, and why, so a skipped gate is a visible decision rather than a silent one.

The reviewer also built its own environment out of model turns, with no npm cache
and no browser cache, paid for from the same forty-five minutes. The workflow now
installs dependencies and Chromium as ordinary cached steps, after switching to
the task branch so the lockfile is the branch's own.

Second, ceremony did not scale down. The light track makes a spec cheap; the new
trivial track does without one — S2-analysis straight to S5-ready, no spec review,
AC in the issue body. It is deliberately hard to qualify for: a bug on one surface,
no new UX contract, no migration, no i18n, no perf or touch effect, three checkable
AC at most, and expected behaviour already on record. Nothing left to decide is the
criterion that holds the whole thing up, and it cannot be met by feeling sure.

Code review is never skipped on either track. It is what stands in for testing
here, so it is the one stage speed may not buy.

Issue: #127
Issue: #128
User-Visible: no
2026-08-13 22:07:42 +03:00
Sergey Matyunin b2263a6551 Merge main into dev for v1.63.0 2026-08-13 22:05:35 +03:00
Matysh 565f518dcd perf: make review scope and ceremony fit the size of the task
The owner's report: the process works but every stage takes a long time even on
simple bugs. Two causes, and neither was the one that first comes to mind.

The reviewer ran everything regardless. On #89 it installed Chromium, ran all 127
smoke files and a full golden capture — right for a task rated 10/10 for
complexity, absurd for a bug about a room divider. Full suites are the pre-beta
gate; the review now runs typecheck, unit and build always, and smokes, golden,
pytest or performance only where the diff and the AC call for them. The price of
narrowing it is honesty: the reviewer must list which gates it ran, which it did
not, and why, so a skipped gate is a visible decision rather than a silent one.

The reviewer also built its own environment out of model turns, with no npm cache
and no browser cache, paid for from the same forty-five minutes. The workflow now
installs dependencies and Chromium as ordinary cached steps, after switching to
the task branch so the lockfile is the branch's own.

Second, ceremony did not scale down. The light track makes a spec cheap; the new
trivial track does without one — S2-analysis straight to S5-ready, no spec review,
AC in the issue body. It is deliberately hard to qualify for: a bug on one surface,
no new UX contract, no migration, no i18n, no perf or touch effect, three checkable
AC at most, and expected behaviour already on record. Nothing left to decide is the
criterion that holds the whole thing up, and it cannot be met by feeling sure.

Code review is never skipped on either track. It is what stands in for testing
here, so it is the one stage speed may not buy.

Issue: #127
Issue: #128
User-Visible: no
2026-08-13 21:59:55 +03:00
Sergey Matyuninandclaude[bot] 02e0c9801d Fix corner split smoke geometry input
Issue: #123
User-Visible: no
2026-08-13 18:55:49 +00:00
claude[bot]andclaude[bot] e93c405b13 docs: code review document for #123
Issue: #123
User-Visible: no
2026-08-13 18:55:49 +00:00
Sergey Matyuninandclaude[bot] 955de3e69c Fix corner split exterior walls
Issue: #123
User-Visible: yes
2026-08-13 18:55:49 +00:00
claude[bot]andclaude[bot] 7af4146614 docs: review document for #123
Issue: #123
User-Visible: no
2026-08-13 18:55:49 +00:00
Sergey Matyuninandclaude[bot] a43602934c Specify corner split wall geometry
Issue: #123
User-Visible: no
2026-08-13 18:55:49 +00:00
Matysh 516257e322 perf: make review scope and ceremony fit the size of the task
The owner's report: the process works but every stage takes a long time even on
simple bugs. Two causes, and neither was the one that first comes to mind.

The reviewer ran everything regardless. On #89 it installed Chromium, ran all 127
smoke files and a full golden capture — right for a task rated 10/10 for
complexity, absurd for a bug about a room divider. Full suites are the pre-beta
gate; the review now runs typecheck, unit and build always, and smokes, golden,
pytest or performance only where the diff and the AC call for them. The price of
narrowing it is honesty: the reviewer must list which gates it ran, which it did
not, and why, so a skipped gate is a visible decision rather than a silent one.

The reviewer also built its own environment out of model turns, with no npm cache
and no browser cache, paid for from the same forty-five minutes. The workflow now
installs dependencies and Chromium as ordinary cached steps, after switching to
the task branch so the lockfile is the branch's own.

Second, ceremony did not scale down. The light track makes a spec cheap; the new
trivial track does without one — S2-analysis straight to S5-ready, no spec review,
AC in the issue body. It is deliberately hard to qualify for: a bug on one surface,
no new UX contract, no migration, no i18n, no perf or touch effect, three checkable
AC at most, and expected behaviour already on record. Nothing left to decide is the
criterion that holds the whole thing up, and it cannot be met by feeling sure.

Code review is never skipped on either track. It is what stands in for testing
here, so it is the one stage speed may not buy.

Issue: #127
Issue: #128
User-Visible: no
2026-08-13 21:51:42 +03:00
Matysh 9177c9a944 perf: make review scope and ceremony fit the size of the task
The owner's report: the process works but every stage takes a long time even on
simple bugs. Two causes, and neither was the one that first comes to mind.

The reviewer ran everything regardless. On #89 it installed Chromium, ran all 127
smoke files and a full golden capture — right for a task rated 10/10 for
complexity, absurd for a bug about a room divider. Full suites are the pre-beta
gate; the review now runs typecheck, unit and build always, and smokes, golden,
pytest or performance only where the diff and the AC call for them. The price of
narrowing it is honesty: the reviewer must list which gates it ran, which it did
not, and why, so a skipped gate is a visible decision rather than a silent one.

The reviewer also built its own environment out of model turns, with no npm cache
and no browser cache, paid for from the same forty-five minutes. The workflow now
installs dependencies and Chromium as ordinary cached steps, after switching to
the task branch so the lockfile is the branch's own.

Second, ceremony did not scale down. The light track makes a spec cheap; the new
trivial track does without one — S2-analysis straight to S5-ready, no spec review,
AC in the issue body. It is deliberately hard to qualify for: a bug on one surface,
no new UX contract, no migration, no i18n, no perf or touch effect, three checkable
AC at most, and expected behaviour already on record. Nothing left to decide is the
criterion that holds the whole thing up, and it cannot be met by feeling sure.

Code review is never skipped on either track. It is what stands in for testing
here, so it is the one stage speed may not buy.

Issue: #127
Issue: #128
User-Visible: no
2026-08-13 21:33:36 +03:00
Matysh 8a3f6efa0a fix: the review document is published even without a task branch
Issues labelled before the pipeline existed keep their spec straight in dev and
have no issue/NN branch. The publish step quietly exited zero for them, so the
verdict would arrive as a comment and the analysis behind it would be thrown
away — the fifth instance today of a step reporting success by doing nothing.

The document now goes wherever the spec itself lives: the task branch when there
is one, dev otherwise. Publishing also survives dev moving on while the review
ran, which takes up to forty-five minutes, by rebasing once before it gives up.

Four issues are waiting on this — #12, #30, #44 and #52 — each with a spec in dev,
a status label applied during the bulk pass in August and a review that never ran
because nothing was there to raise the event.

Issue: #114
User-Visible: no
2026-08-13 21:11:41 +03:00
Matysh be7d6b9706 fix: the review document is published even without a task branch
Issues labelled before the pipeline existed keep their spec straight in dev and
have no issue/NN branch. The publish step quietly exited zero for them, so the
verdict would arrive as a comment and the analysis behind it would be thrown
away — the fifth instance today of a step reporting success by doing nothing.

The document now goes wherever the spec itself lives: the task branch when there
is one, dev otherwise. Publishing also survives dev moving on while the review
ran, which takes up to forty-five minutes, by rebasing once before it gives up.

Four issues are waiting on this — #12, #30, #44 and #52 — each with a spec in dev,
a status label applied during the bulk pass in August and a review that never ran
because nothing was there to raise the event.

Issue: #114
User-Visible: no
2026-08-13 21:05:17 +03:00
Matysh 2fd042a7de feat: an outsider's issue is worked like any other once admitted
The guard refused to review any issue the owner had not filed himself. The rule
was meant to keep malformed outside reports out of the pipeline, but it checked at
every step instead of at the entrance, and it duplicated a guarantee the platform
already gives: only someone with write access can apply a label. Applying the
first status label is the owner's explicit decision, and it is the only place the
question belongs.

So the author check is gone. While an issue carries no status label it sits
outside the process and the invariants do not apply; once labelled, the task is in
flight and who filed it stops mattering.

The old rule also cost real work. On #123 an outside bug report had been analysed
and specified before the guard turned it away in nine seconds, and the remedy on
offer was to refile the same thing as the owner's own issue.

Issue: #114
User-Visible: no
2026-08-13 20:41:36 +03:00
Matysh d7e2c4d4f0 fix: the guard says why it refused, in the issue
A review label promises work. When the guard declined it wrote the reason to the
run log and nothing else, so the issue sat in a status nobody was acting on and
nobody could tell. #123 showed it: an outside reporter's issue was walked up to
S4-spec-review, the guard refused in nine seconds because only the owner's issues
enter the process, and the issue itself said not a word.

Refusals that a human can act on now become a comment: wrong author, blocked,
review-4. Only when a stage was actually recognised, so an unrelated label change
stays silent.

This is the same defect as the merge conflict that left the label untouched, seen
from the other side. The pattern is worth naming: doing nothing quietly is the
most expensive thing a pipeline can do.

Issue: #114
User-Visible: no
2026-08-13 20:30:15 +03:00
Matysh d1be6891b2 fix: a review run always moves the label, conflict or not
Validate / hacs (push) Failing after 55s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 6m16s
Validate / backend (push) Failing after 8m39s
Validate / provenance (push) Successful in 37s
Validate / golden (push) Failing after 8m4s
Validate / smoke (push) Failing after 27m15s
Validate / performance_smoke (push) Failing after 14m5s
Full Performance / performance (push) Failing after 1h15m11s
A green code review whose merge conflicted used to leave the label where it was.
That is a dead end: the author waits for the label to change, so it polled thirty
times and reported the limit as exhausted — on a task the reviewer had already
passed. The verdict existed and nobody could act on it.

The merge step no longer fails the job. It reports whether it merged, and a green
review that did not merge sends the task back to S6-in-progress, because the work
did return to the author — a rebase rather than a code fix, and the comment says
so and says the verdict still stands.

The invariant is now stronger and worth stating plainly: after a review run the
label always changes. A pipeline whose state can stall silently is worse than one
that reports the wrong state loudly.

Issue: #114
User-Visible: no
2026-08-13 16:58:18 +03:00
Matysh 9be81c1413 fix: repair the line continuation in the failure handler
The step that comments on the issue when a review run dies carried a literal
backslash instead of a line continuation, so gh received four arguments and
--repo ran as a command of its own. The handler for failures would itself have
failed, silently, and only when something had already gone wrong.

bash -n does not catch this: the syntax is valid, the meaning is not. Checking
run blocks now also means looking for a doubled backslash at end of line.

Issue: #114
User-Visible: no
2026-08-13 16:16:39 +03:00
Matysh fafeca4540 fix: count review cycles per stage, not across the whole issue
The guard counted every verdict comment on the issue, so a spec-review verdict
consumed a cycle from the code-review budget. On #89 the first code review came
out as r2/4. With two spec cycles the second code review would have hit review-4
after a single fix — the limit would have fired on a task nobody had reviewed
twice.

The stage is now resolved first and only its own verdicts are counted, recognised
by the review document named in the comment. If the document is missing the
verdict is not counted: undercounting grants an extra cycle, overcounting would
stop the work early, and of the two mistakes the recoverable one wins.

Issue: #114
User-Visible: no
2026-08-13 16:08:20 +03:00
Matysh 3ade633538 ci: merge into dev before setting S8-merged
The label asserts the code is in dev. The workflow used to set it on a green
code review while the commits were still only on the task branch, so between
the verdict and the author's merge the state machine stated something untrue —
which is exactly what happened on #104.

The merge now runs inside the pipeline, before the label. A conflict leaves
the issue in S7-code-review and comments instead.

Issue: #114
User-Visible: no
2026-08-13 13:20:26 +03:00
Matysh 68596a75a0 ci: the reviewer writes a review document to the task branch
PROCESS.md wants a review document in docs/reviews/; the CI reviewer could
only leave a comment, and flagged the gap itself. It may now write there.

What lands in the commit is decided by the workflow, not by the model: every
path outside docs/reviews/ is reverted before staging, and the commit carries
the usual trailers so the provenance gate accepts it.

Issue: #114
User-Visible: no
2026-08-13 13:04:26 +03:00
Matysh 65a86db122 fix(ci): repair the failure comment step
The multi-line --body started at column zero, which ends the YAML block
scalar. The parser silently truncated the run script and left an unclosed
double quote, so the whole workflow became unusable and blocked the code
review on #104.

The body now goes through a heredoc. Validating YAML alone did not catch
this; every run block is checked with bash -n from now on.

Issue: #114
User-Visible: no
2026-08-13 12:57:04 +03:00
Matysh a29df12e0b ci: raise the turn limit, bound the run by time instead
The r2 spec review on #104 produced a complete green verdict and then failed
on --max-turns 40 at turn 43, so the label step never ran and the transition
had to be reconciled by hand. Forty was a guess; a review that reads SCOPE,
AGENTS, PROCESS, the issue thread and the spec exceeds it routinely, and a
code review that also runs gates needs far more.

The real guard against a runaway run is the job timeout, not the turn count.

Issue: #114
User-Visible: no
2026-08-13 12:37:14 +03:00
Matysh 9146b4c357 ci: fix OIDC permission and review the issue branch
The first live run failed with "Could not fetch an OIDC token": the action
needs id-token: write to authenticate the GitHub App.

The reviewer also checked out dev, where the material under review does not
exist yet — specs and code are committed to issue/<NN>-slug. The job now
switches to that branch when it is pushed, and warns loudly when it is not.

Issue: #114
User-Visible: no
2026-08-13 12:02:31 +03:00
Matysh 9ad01be813 ci: event-driven process pipeline for spec and code review
Adds .github/workflows/process.yml. A status label change is the trigger:
S4-spec-review runs the spec review, S7-code-review runs the code review,
and the verdict decides the next label. Only a green verdict advances;
yellow and red return the task to its author. Cycle limits (4, or 2 on the
light track) are counted from the verdicts already posted on the issue.

Labels are moved with HP_PROCESS_TOKEN, not GITHUB_TOKEN, so the change
emits an event and the chain continues.

Issue: #114
User-Visible: no
2026-08-13 11:34:01 +03:00
175 changed files with 17680 additions and 2460 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ while read -r local_ref local_sha remote_ref remote_sha; do
echo "process-gate: $local_ref, диапазон ${base}..${local_sha}" >&2
# shellcheck disable=SC2086
if ! node "$gate" --range "${base}..${local_sha}" $issues_flag >&2; then
if ! node "$gate" --range "${base}..${local_sha}" --target-ref "$remote_ref" $issues_flag >&2; then
status=1
fi
done
+61
View File
@@ -0,0 +1,61 @@
name: Mutation gate
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
# защитой, и это хуже его отсутствия.
#
# Прогон дорогой: пересборка бандла на каждого мутанта. Поэтому он не входит в
# Validate и не идёт на каждый push. Его место — перед стабильным релизом
# (PROCESS.md §8) и раз в неделю по расписанию, чтобы дрейф тестов не копился
# до релиза. Дешёвая половина — «якоря патчей живы, guard-файлы существуют» —
# идёт с обычными юнитами: test/mutation-gate.test.mjs.
on:
workflow_dispatch:
schedule:
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
- cron: '20 5 * * 1'
permissions:
contents: read
concurrency:
group: mutation-gate
cancel-in-progress: true
jobs:
mutants:
runs-on: ubuntu-latest
# Шесть мутантов × (сборка + браузерный смок) — это десятки минут, и это
# нормально: гейт предрелизный. Час — потолок против зависшего Chromium.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
ref: dev
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v4
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
- name: Реестр применим к текущему коду
run: node scripts/mutation-gate.mjs --check
- name: Каждый тест ловит свою поломку
run: node scripts/mutation-gate.mjs
+3
View File
@@ -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,6 +166,7 @@ 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
+88 -12
View File
@@ -48,6 +48,7 @@ jobs:
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
SMALL: ${{ contains(github.event.issue.labels.*.name, 'small') }}
TRIVIAL: ${{ contains(github.event.issue.labels.*.name, 'trivial') }}
NUM: ${{ github.event.issue.number }}
run: |
# Этап определяется первым: от него зависит, какие вердикты считать.
@@ -58,8 +59,9 @@ jobs:
*) echo "метка $LABEL конвейер не запускает" ;;
esac
# Лимит циклов: 4 обычный, 2 на лёгком треке (PROCESS.md §4).
limit=4; [ "$SMALL" = "true" ] && limit=2
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
limit=4
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
@@ -134,8 +136,15 @@ jobs:
fetch-depth: 0
ref: dev
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
# тяжёлых job, здесь его не было.
- uses: actions/setup-node@v4
with: { node-version: 22 }
with:
node-version: 22
cache: npm
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
@@ -156,6 +165,24 @@ jobs:
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
fi
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
- name: Установить зависимости
run: npm ci
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
# но когда нужен — качать его заново дороже, чем держать в кэше.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v4
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
- name: Review
id: review
uses: anthropics/claude-code-action@v1
@@ -205,10 +232,38 @@ jobs:
По каждому AC: либо он доказан автотестом и ты убедился, что тест
умеет падать, либо разобран по коду с явной записью «проверено
чтением, не исполнением». «Verified» без названной команды и её
результата доказательством не является. Зависимостей в рабочей
копии нет: перед гейтами выполни `npm ci`. Проверь трейлеры Issue и
User-Visible, при User-Visible: yes — правки в оба changelog в том же
коммите.
результата доказательством не является. Зависимости уже установлены
workflow, Chromium тоже — `npm ci` выполнять не нужно. Проверь
трейлеры Issue и User-Visible, при User-Visible: yes — правки в оба
changelog в том же коммите.
**Объём гейтов соразмерен задаче.** Прогонять весь набор на каждой
правке — не тщательность, а потеря времени: полные наборы это
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
Всегда, они дешёвые:
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
копий бандла.
По необходимости, и «необходимость» определяется diff'ом и AC:
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс
относящиеся к тронутым поверхностям. Их 127; прогон всех уместен
только когда задача действительно задевает всё;
- `npm run golden:verify` — если diff может изменить видимый
результат: рендер, геометрия, стили, слои;
- `python -m pytest tests_backend -q` — если тронут
`custom_components/**/*.py`;
- performance-профили — если названы в AC либо тронуты
чувствительные к перфу пути.
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
тем тестам, которые ты прогонял.
**В комментарии обязателен перечень: какие гейты прогнал, какие нет и
почему.** Это условие честности такого сужения: непрогнанный гейт
становится видимым решением, а не молчаливым пропуском. Раздел «чего
не проверял» в документе ревью — не формальность, а главный его
раздел на коротких задачах.
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
@@ -249,14 +304,21 @@ jobs:
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
run: |
# Ветки задачи может не быть: у задач, размеченных до появления
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
# комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что
# ничего не сделал. Документ ложится туда же, где лежит само ТЗ.
target="${BRANCH:-dev}"
if [ -z "$BRANCH" ]; then
echo "ветки задачи нет — документ некуда класть"; exit 0
echo "::warning::ветки задачи нет — документ ревью ляжет в dev"
fi
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 "документ ревью не создан"; exit 0
echo "::warning::документ ревью не создан"
exit 0
fi
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
@@ -266,9 +328,23 @@ jobs:
Issue: #$NUM
User-Visible: no
EOF
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$BRANCH"
echo "документ опубликован в $BRANCH"
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
# либо dev, поэтому push нужен с явным перебазированием при гонке:
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"; then
git fetch -q origin "$target"
if ! git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
rebase "origin/$target"; then
git rebase --abort || true
echo "::error::документ ревью не удалось опубликовать в $target: конфликт"
exit 0
fi
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"
fi
echo "документ опубликован в $target"
- name: Решение по вердикту
id: decide
+57
View File
@@ -51,12 +51,63 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TARGET_REF: ${{ github.ref }}
# Публичный репозиторий: штатного токена хватает на чтение issue.
GH_TOKEN: ${{ github.token }}
run: |
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@v4
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
@@ -66,6 +117,8 @@ jobs:
category: integration
hassfest:
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -73,6 +126,8 @@ jobs:
uses: home-assistant/actions/hassfest@master
frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -193,6 +248,8 @@ jobs:
path: artifacts/performance-smoke
backend:
needs: changes
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+45 -5
View File
@@ -38,8 +38,16 @@ 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
stage at all, `S2-analysis` straight to `S5-ready`, with the AC written into the
issue body first. `trivial` requires a bug confined to one surface with no new UX
contract, no migration, no i18n, no perf or touch impact, at most three checkable
AC, **and expected behaviour already on record** — nothing left to decide. Code
review is never skipped on either track; it is what stands in for testing.
`PROCESS.md` §5 and §5.1 hold the criteria.
An issue filed by an outsider is worked exactly like one of the owner's own, once
the owner has decided to take it. The check sits **at the entrance**, not on every
@@ -146,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
@@ -290,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
+93 -14
View File
@@ -11,7 +11,7 @@
> его не содержал вовсе.
>
> **Приоритет источников.** Канонический бэклог — GitHub Issues; статус живёт в
> метках, Project v2 остаётся человеческим представлением. При расхождении
> метках и больше нигде: Project v2 не используется. При расхождении
> документации с GitHub побеждает GitHub. При расхождении этого документа с
> `.github/workflows/*.yml` и `scripts/*` побеждает **фактическая автоматизация**:
> она исполняется, а описание — нет. Расхождение при этом не игнорируется, а
@@ -70,7 +70,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
→ S6-in-progress → S7-code-review ⟲ → S8-merged → закрыт при выпуске беты
служебные: blocked (поверх статуса) rejected (закрыт)
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком треке 2
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
```
Переходы `S4-spec-review` и `S7-code-review` выполняются **автоматически**: метка
@@ -85,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`;
@@ -97,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 ТЗ в работе — написание ТЗ
@@ -294,6 +309,42 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
модуль) — метка `small` снимается, issue возвращается в `S3-spec` и получает
нормальный файл ТЗ. Это не провал, это ранняя диагностика.
### 5.1 Короткий трек (метка `trivial`)
Решение владельца 2026-08-13, issue #128. Лёгкий трек делает ТЗ дешёвым; короткий
обходится без него совсем.
**Маршрут:** `S1-new` → `S2-analysis` → `S5-ready` → `S6-in-progress` →
`S7-code-review` → `S8-merged`. Стадии `S3-spec` и `S4-spec-review` пропускаются.
`S2-analysis` остаётся: это комментарий, а не прогон CI, и именно там владелец
решает приоритет и ценность. AC пишет автор в теле issue при переводе в
`S5-ready` — до перехода, иначе ревьюеру нечего будет сверять.
**Критерии, все обязательны:**
- тип `bug`;
- правка ограничена одной поверхностью, нового UX-контракта нет;
- нет миграции конфига, новых ключей i18n, влияния на перф и touch;
- AC выражаются тремя проверяемыми утверждениями или меньше;
- **ожидаемое поведение уже зафиксировано** — в `docs/USER-GUIDE.ru.md`, в
каноническом документе подсистемы либо однозначно в самом отчёте. Решать нечего.
Если есть что решать, это `S3-spec`, и никакая экономия этого не отменяет.
Метка ставится в `S2-analysis` вместе с остальными оценками, одним комментарием,
где владелец утверждает и приоритет.
**Что не упрощается:** issue, оценка, статусы, трейлеры, changelog и **код-ревью**.
Лимит циклов код-ревью — 2, как на лёгком треке.
Если по ходу выясняется, что критерий нарушен, метка снимается и issue уходит в
`S3-spec` за нормальным ТЗ. Как и на лёгком треке, это не провал, а ранняя
диагностика.
**Чем этот трек опасен.** Он убирает единственное место, где решение проверялось
до написания кода. Признак «решать нечего» держит всю конструкцию, и его нельзя
подтверждать ощущением — только ссылкой на уже зафиксированное поведение.
---
## 6. Роли
@@ -431,6 +482,17 @@ npm run golden:verify # если менялся визуал
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
```
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла. По
необходимости, определяемой diff'ом и AC: браузерные смоки (их 127 — прогон всех
уместен только когда задача задевает всё), `golden:verify` при изменении видимого
результата, `pytest tests_backend` при правках в Python, performance-профили при
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
пропуском.
**Гейт беты** (условие закрытия issue): CI Validate зелёный на точном SHA тега.
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
@@ -444,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). Русские имена в этом
документе были только на бумаге; репозиторий с самого начала жил на английских.
@@ -464,8 +529,8 @@ Project v2 остаётся человеческим представление
| `blocked` | Ждём внешнего или владельца, **поверх** статусной метки |
| `rejected` | Отклонено, issue закрыт |
Модификаторы: `small` (лёгкий трек, сложность ≤3), `hotfix`, `process`,
`review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
Модификаторы: `small` (лёгкий трек, сложность ≤3), `trivial` (короткий трек,
§5.1), `hotfix`, `process`, `review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
ортогональны процессу.
@@ -523,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`, поэтому при его отсутствии хук печатает
предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук,
который не работает в самолёте, отключают целиком, а строгий проход всё равно
@@ -560,7 +632,7 @@ Project v2 остаётся человеческим представление
{`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`}; закрытый,
недоступный или помеченный `blocked` — отказ (**fail closed**).
Две оговорки к проверке 8, обе выяснились при реализации.
Три оговорки к проверке 8 выяснились при реализации.
**`S8-merged` входит в множество**, хотя по смыслу задача уже принята. Причина
механическая: конвейер (§10.4) сливает ветку в `dev` **раньше**, чем ставит метку,
@@ -573,6 +645,13 @@ Validate стартует от этого push и успевает прочит
документ ревью: он ложится в ветку задачи, пока та в `S4-spec-review` или
`S7-code-review`, то есть заведомо вне рабочего множества.
**При продвижении в `main` не перепроверяются коммиты, уже достижимые из
prerelease-тега.** После выпуска беты их issue по §2.8 должны быть закрыты, а
stable fast-forward снова включает эти коммиты в диапазон `old-main..candidate`.
Pre-push передаёт целевую remote ref через `--target-ref`, а Validate — через
`TARGET_REF`; оба исключают только уже опубликованную prerelease-историю. Любой
post-beta коммит остаётся в проверке и по закрытому issue отклоняется fail-closed.
Не реализовано и остаётся долгом:
9. `npm run release:prerelease -- --issues=…` не проверяет, есть ли у issue
+11
View File
@@ -312,6 +312,17 @@ 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
+18 -6
View File
@@ -24,7 +24,12 @@ from .const import (
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
from .plans import collect_attachments, collect_plans, sweep_upload_temps
from .repairs import async_check_plan_files
from .store import HouseplanConfigEntry, async_save_layout_state, create_data
from .store import (
HouseplanConfigEntry,
async_save_config_state,
async_save_layout_state,
create_data,
)
_LOGGER = logging.getLogger(__name__)
@@ -60,6 +65,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
await data.config_store.async_load()
except Exception as err: # noqa: BLE001 — corrupt/unreadable .storage
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
try:
await data.virtual_light_store.async_load()
except Exception: # noqa: BLE001 — operational state fails safe to default on
_LOGGER.exception("House Plan: virtual-light storage is not readable; using default on")
entry.runtime_data = data
# server-side vacuum trails: the integration records the path itself
@@ -152,7 +161,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
rev = int(stored.get("rev", 0))
if cfg and migrate_config(cfg): # 2. the config half
rev += 1
await data.config_store.async_save({"config": cfg, "rev": rev})
await async_save_config_state(data, cfg, rev, previous_rev=rev - 1)
migrate_layout(layout, merged) # 3. the layout half + intent cleared
await async_save_layout_state(
data, lay_stored, layout, lay_rev + 1, remove=("geom_pending",)
@@ -186,11 +195,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
"layout_rev", layout_rev + (lay_stored.get("layout", {}) != target_layout)
))
if stored.get("config") != target_config or config_rev < target_config_rev:
previous_config_rev = config_rev
config_rev = max(config_rev, target_config_rev)
await data.config_store.async_save({
"config": target_config,
"rev": config_rev,
})
await async_save_config_state(
data,
target_config,
config_rev,
previous_rev=previous_config_rev,
)
if lay_stored.get("layout", {}) != target_layout or layout_rev < target_layout_rev:
layout_rev = max(layout_rev, target_layout_rev)
exact_metadata = pending.get("final_metadata")
+4 -3
View File
@@ -3,8 +3,9 @@
DOMAIN = "houseplan"
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
@@ -45,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.63.0-beta.1"
VERSION = "1.64.0"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
@@ -64,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
@@ -73,6 +73,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)
@@ -239,6 +268,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,6 +277,7 @@ 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()
@@ -358,6 +389,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:
@@ -560,6 +597,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 []
@@ -1039,6 +1077,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.63.0-beta.1"
"version": "1.64.0"
}
+89 -5
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import asyncio
import copy
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
@@ -10,7 +12,43 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from .const import DOMAIN, STORAGE_CONFIG_KEY, STORAGE_KEY, STORAGE_MINOR_VERSION, STORAGE_VERSION
from .const import (
DOMAIN,
STORAGE_CONFIG_KEY,
STORAGE_KEY,
STORAGE_MINOR_VERSION,
STORAGE_VERSION,
STORAGE_VIRTUAL_LIGHTS_KEY,
)
_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):
@@ -28,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
@@ -40,6 +77,7 @@ class HouseplanData:
store: HouseplanStore
config_store: HouseplanStore
virtual_light_store: HouseplanStore
# One lock for every load→modify→save cycle of both stores: prevents
# lost updates from concurrent WS calls and makes the rev check atomic.
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
@@ -71,6 +109,12 @@ def create_data(hass: HomeAssistant) -> HouseplanData:
config_store=HouseplanStore(
hass, STORAGE_VERSION, STORAGE_CONFIG_KEY, minor_version=STORAGE_MINOR_VERSION
),
virtual_light_store=HouseplanStore(
hass,
STORAGE_VERSION,
STORAGE_VIRTUAL_LIGHTS_KEY,
minor_version=STORAGE_MINOR_VERSION,
),
)
@@ -139,3 +183,43 @@ async def async_save_layout_state(
)
await runtime.store.async_save(payload)
return payload
async def async_save_config_state(
runtime: HouseplanData,
config: dict[str, Any],
rev: int,
*,
previous_rev: int | None = None,
) -> dict[str, Any]:
"""Persist configuration and reconcile dependent operational state.
Callers already hold ``runtime.write_lock``. Reading the previous
revision here keeps less common writers (import recovery and undo) on the
same path as ordinary editor saves without duplicating lifecycle rules.
"""
if previous_rev is None:
previous = await runtime.config_store.async_load() or {}
try:
previous_rev = int(previous.get("rev", 0))
except (TypeError, ValueError):
previous_rev = 0
payload = {"config": config, "rev": rev}
await runtime.config_store.async_save(payload)
# The config is already durable at this point. Reconciliation remains a
# separate Store write; an interrupted pair is detected from config_rev on
# the next read and fails safe to the compatibility default (all on).
from .virtual_lights import async_reconcile_virtual_lights
try:
await async_reconcile_virtual_lights(
runtime.virtual_light_store,
config,
rev,
previous_config_rev=previous_rev,
)
except Exception: # noqa: BLE001 - config commit already stands
_LOGGER.exception("House Plan: virtual-light state reconciliation failed")
return payload
@@ -0,0 +1,125 @@
"""Persistent operational state for manual virtual lights."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .store import HouseplanStore
EVENT_VIRTUAL_LIGHT_UPDATED = "houseplan_virtual_light_updated"
def is_manual_virtual_light(marker: Any) -> bool:
"""Return whether a marker uses the exact persistent manual-light mode."""
return (
isinstance(marker, dict)
and isinstance(marker.get("id"), str)
and bool(marker["id"])
and marker.get("binding") == "virtual"
and marker.get("is_light") is True
and marker.get("tap_action") == "toggle"
and marker.get("removed") is not True
)
def eligible_virtual_light_ids(config: Any) -> set[str]:
"""Collect live marker ids eligible for persistent manual state."""
if not isinstance(config, dict):
return set()
markers = config.get("markers")
if not isinstance(markers, list):
return set()
return {marker["id"] for marker in markers if is_manual_virtual_light(marker)}
def _integer(value: Any, default: int = 0) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
return default
return max(0, parsed)
def _read_state(stored: Any) -> tuple[int, int, set[str]]:
if not isinstance(stored, dict):
return 0, 0, set()
raw_off = stored.get("off")
off = (
{item for item in raw_off if isinstance(item, str) and item}
if isinstance(raw_off, list)
else set()
)
return _integer(stored.get("rev")), _integer(stored.get("config_rev")), off
def _wire(rev: int, config_rev: int, off: set[str]) -> dict[str, Any]:
return {"rev": rev, "config_rev": config_rev, "off": sorted(off)}
async def async_virtual_light_snapshot(
store: HouseplanStore,
config: dict[str, Any],
config_rev: int,
) -> dict[str, Any]:
"""Return a coherent snapshot, repairing stale or interrupted state.
A revision gap means an older writer may have changed eligibility without
knowing about this Store. Clearing every manual-off bit is conservative:
it restores the pre-feature/default-on behaviour and cannot resurrect an
old off state for a marker whose role changed in the meantime.
"""
stored = await store.async_load() or {}
rev, state_config_rev, stored_off = _read_state(stored)
eligible = eligible_virtual_light_ids(config)
off = stored_off & eligible if state_config_rev == config_rev else set()
if off != stored_off:
rev += 1
payload = _wire(rev, config_rev, off)
if payload != stored:
await store.async_save(payload)
return payload
async def async_reconcile_virtual_lights(
store: HouseplanStore,
config: dict[str, Any],
config_rev: int,
*,
previous_config_rev: int,
) -> dict[str, Any]:
"""Carry eligible state across one known configuration transition."""
stored = await store.async_load() or {}
rev, state_config_rev, stored_off = _read_state(stored)
eligible = eligible_virtual_light_ids(config)
off = stored_off & eligible if state_config_rev == previous_config_rev else set()
if off != stored_off:
rev += 1
payload = _wire(rev, config_rev, off)
if payload != stored:
await store.async_save(payload)
return payload
async def async_toggle_virtual_light(
store: HouseplanStore,
config: dict[str, Any],
config_rev: int,
marker_id: str,
) -> dict[str, Any] | None:
"""Atomically invert one eligible marker and persist before returning."""
if marker_id not in eligible_virtual_light_ids(config):
return None
snapshot = await async_virtual_light_snapshot(store, config, config_rev)
off = set(snapshot["off"])
if marker_id in off:
off.remove(marker_id)
else:
off.add(marker_id)
payload = _wire(_integer(snapshot["rev"]) + 1, config_rev, off)
await store.async_save(payload)
return {
"marker_id": marker_id,
"on": marker_id not in off,
"rev": payload["rev"],
}
+84 -11
View File
@@ -44,10 +44,16 @@ from .store import (
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
HouseplanData,
async_save_config_state,
async_save_layout_state,
get_data,
get_entry,
)
from .virtual_lights import (
EVENT_VIRTUAL_LIGHT_UPDATED,
async_toggle_virtual_light,
async_virtual_light_snapshot,
)
from .registry_snapshot import import_registry_snapshot
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
@@ -126,6 +132,7 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_layout_update)
websocket_api.async_register_command(hass, ws_layout_delete)
websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_virtual_light_toggle)
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_optimize)
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
@@ -194,10 +201,11 @@ async def _persist_pair_intent(
async def _converge_pair(rt: HouseplanData, pending: dict[str, Any]) -> None:
"""Write both target halves and remove the durable intent last."""
await rt.config_store.async_save({
"config": pending["config"],
"rev": int(pending["config_rev"]),
})
await async_save_config_state(
rt,
pending["config"],
int(pending["config_rev"]),
)
stored = await rt.store.async_load() or {}
await async_save_layout_state(
rt,
@@ -1058,14 +1066,28 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
async with rt.write_lock:
data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
config_rev = int(data.get("rev", 0))
try:
virtual_lights = await async_virtual_light_snapshot(
rt.virtual_light_store,
config,
config_rev,
)
except Exception: # noqa: BLE001 - config remains independently readable
_LOGGER.exception("House Plan: reading virtual-light state failed")
# Never expose a stale off bit after an unreadable/revision-gap
# operational store. Compatibility/default on is the safe frame.
virtual_lights = {"rev": 0, "config_rev": config_rev, "off": []}
connection.send_result(
msg["id"],
{
"config": config,
"rev": data.get("rev", 0),
"rev": config_rev,
"virtual_lights": virtual_lights,
"can_write": may_write(hass, getattr(connection, "user", None)),
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
"undo_kind": _undo_kind(data, layout_data),
@@ -1073,6 +1095,42 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/virtual_light/toggle",
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=500)),
}
)
@websocket_api.async_response
async def ws_virtual_light_toggle(
hass: HomeAssistant, connection, msg: dict[str, Any]
) -> None:
"""Atomically toggle one eligible virtual light for any signed-in user."""
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
async with rt.write_lock:
data = await rt.config_store.async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
result = await async_toggle_virtual_light(
rt.virtual_light_store,
config,
int(data.get("rev", 0)),
msg["marker_id"],
)
if result is None:
connection.send_error(
msg["id"],
"not_toggleable",
"Marker is not an active virtual light with tap_action=toggle",
)
return
# Both the reply and event follow the durable Store write. There is no
# optimistic client state, so all cards converge on this revision.
connection.send_result(msg["id"], result)
hass.bus.async_fire(EVENT_VIRTUAL_LIGHT_UPDATED, result)
def _internal_plan_names(config: dict[str, Any]) -> set[str]:
"""Plan file names a configuration names through OUR urls.
@@ -1215,7 +1273,12 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
return
new_rev = current_rev + 1
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
await async_save_config_state(
rt,
msg["config"],
new_rev,
previous_rev=int(current_rev),
)
try:
await _discard_optimizer_snapshot(rt)
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
@@ -1340,7 +1403,12 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
await rt.config_store.async_save({"config": msg["config"], "rev": new_config_rev})
await async_save_config_state(
rt,
msg["config"],
new_config_rev,
previous_rev=config_rev,
)
await async_save_layout_state(
rt, layout_data, msg["layout"], new_layout_rev,
metadata={_OPTIMIZE_BACKUP: backup},
@@ -1410,7 +1478,12 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
await rt.config_store.async_save({"config": restored_config, "rev": new_config_rev})
await async_save_config_state(
rt,
restored_config,
new_config_rev,
previous_rev=config_rev,
)
await async_save_layout_state(
rt, layout_data, restored_layout, new_layout_rev,
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup"),
+108 -3
View File
@@ -14,11 +14,26 @@ 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 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 +63,9 @@ 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,
}) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
const started = performance.now();
@@ -104,6 +121,7 @@ try {
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
isoGeometry: card._isoGeometryCache?.size ?? 0,
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
});
window.__card?.remove?.();
@@ -120,6 +138,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 +153,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')
@@ -196,6 +216,85 @@ 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 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,
};
if (requiresPlanSnap && (
staticLines < fixture.counts.rooms || staticNodes < fixture.counts.rooms
|| !planSnapDiagnostics.cacheStable || !planSnapDiagnostics.domStable
|| !planSnapDiagnostics.configStable || planSnapDiagnostics.wsWrites !== 0
|| !seenKinds.has('endpoint') || !seenKinds.has('line')
)) throw new Error(`plan-snap structural 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 +378,10 @@ try {
modelReadyMs,
firstStableRenderMs,
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
...(planSnapPointer ? {
planSnapPointerMs: planSnapPointer.ms,
planSnapDiagnostics,
} : {}),
spaceSwitchMs: spaceSwitch.ms,
stateUpdateMs: stateUpdate.ms,
resizePreviewMs: resizePreview.ms,
@@ -288,6 +391,7 @@ try {
longTasks: {
load: loadLongTaskResult,
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
spaceSwitch: spaceSwitch.longTasks,
stateUpdate: stateUpdate.longTasks,
resizePreview: resizePreview.longTasks,
@@ -306,7 +410,7 @@ try {
return result;
}, {
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
isometric, requiresIsometric,
isometric, requiresIsometric, planSnap, requiresPlanSnap,
});
if (measuredSample >= 0) rows.push(row);
}
@@ -319,6 +423,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,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

+38 -25
View File
@@ -1,29 +1,42 @@
{
"schema": 1,
"matrixVersion": 17,
"acceptedAt": "2026-08-13T14:30:05.929Z",
"sourceFingerprint": "66f31850eafc963848acdc4e56e363a364bbb9b189bf4ade73d3c111ae2fc375",
"matrixVersion": 22,
"acceptedAt": "2026-08-14T16:03:22.017Z",
"sourceFingerprint": "24117bcfb4c94d5421e0304612089743615233eab3911f065e8cfa003f48fa7d",
"chromium": "151.0.7922.34",
"scenarios": {
"isometric-geometry-view-dark": "6db601f322fe55e33a1a89fe62f5d3a7d53f12bac4a68c7911b12f6382ef4fca",
"isometric-geometry-view-light": "c4c005fa55f64280abf9fccccdf7dc8efc3497bd626572bc11f45cc3cddaa159",
"isometric-live-layers-dark": "869c62bf9cd762c36d342d1a3bbd992969425b46b132f3243439735e2c75c14f",
"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",
"isometric-live-layers-dark": "c841269f672c7ad8b208a549cc87592bd26f2a9e226f793a2b79592b65ee54c6",
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
"isometric-touch-kiosk-dark": "36c83bbe39809f346ebb5a0f4ed633c938e2fed028defaa48097c281db8e23a3",
"isometric-large-warm-remount-dark": "798d312671dffebf59034a39f2865a65ece27a84b150e77bc59038bb2567d07c",
"geometry-view-dark-fit": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
"geometry-view-light-fit": "0c57dee930f30a1f9c16e4e704675156f57a7623a4edf839c14a4fecbf12881c",
"geometry-plan-editor-dark": "6b06213324c5ff50c7176451307a33d8ac31ee636c96698f8efe61e8db763bae",
"opening-placement-door-thick-wall-dark": "24f472818f2246eeaa6568648ca4a6c42d9d84723fbcdd879435253399e2995d",
"geometry-devices-editor-dark": "c850e83f1af747f063b895109fe26d6a5804356fab505b0976073c28ad146104",
"geometry-decor-editor-dark": "44a95fd0b397c2729fea65c750fedcb11df10042ae198f3690151ead077c84e1",
"tray-wide-selection-en": "468ac6acfd7fcdcbfa947e032843ff117a32ae01b4ec30937a6cddecff534504",
"tray-wide-tool-ru": "388e03d7bf7a2391d0581e8446d0692048b9792e80bf3b9dd9bca86222fe9418",
"tray-medium-group-en": "190d5c356461ad2aa8b63b132416e8958a285047f8dea92301213678c7ac5a92",
"tray-medium-selection-ru": "ab91e88583b2863305434ce2775e31328638414671ff0a4f8ee82c8274c04405",
"tray-narrow-palette-en": "fb63483e7101d457d7fb1c83ae50435410ed797771adcbfde14dc2798a000ce1",
"tray-narrow-tool-ru": "60ce3c75de88b72fdb5d3fe7a16790184a8265c74f0f97e5be4fd83dbe0259fd",
"geometry-diagonal-45-opening-dark": "3736a75163d47a71f60c45cd554d604f27cfbc81119b5b55e8134da61a2a0f0a",
"isometric-touch-kiosk-dark": "fd2e74c5966b4adcf4e66021e2cef781873f9a2b58f16ad19d57fb451ba45dc9",
"isometric-large-warm-remount-dark": "0afc1069d334f10be2d0ed135ee0eb52e9272a1a1ad1224e00f05753b8789d0c",
"geometry-view-dark-fit": "3df272f6c3c3d20e9e375ea037f3dbb885657b94b0b29d0067505f4a73741237",
"geometry-view-light-fit": "a7f2c9667d9872dd84a37d5318fd238c9eabcc0f413017eb19e02204587b4e1a",
"day-cycle-dawn-dark": "a573083c4ee21993cf2e482b60418a30e388a14117d056a2b0ea559a0c928039",
"day-cycle-day-dark": "6f24cd1d8669c9f3451c06ca5a5fd499d8f1ae2698a34f4a63ffee2e9e6b4330",
"day-cycle-dusk-dark": "80029577c25f8759090ee2550fe530a35c189806dd6fa26e887da4f5d1cf54a3",
"day-cycle-night-dark": "d855785914d3e11198d3e4671c1fbcad15104c95954bc1ed9b7366aa51aac65e",
"geometry-plan-editor-dark": "16364738754515e81e6d0ae13c0358db8c7f9d26c2f34dfde15f5855c6af13fa",
"plan-snap-endpoint-light": "c5f63ca2ca2706a062a2fc97e25670768662810bd7e0b251f0a9cfdbaf60c758",
"plan-snap-line-gaps-dark": "44808a816e62416b9c2c39a6e06cd4a8631860e178f246772ce8c7a46f98edd0",
"wall-junctions-plan-preview-light": "9e3a07da3e3ae1b2a95299f92b9500f347f0bfbd20d87429505b30769ee627c2",
"wall-junctions-plan-t-dark": "a4a958f20ed5f4b8d4e6bca9ebd1b289186c0cb48b48897a1624b49014f3ae5d",
"wall-junctions-view-dark": "73a64c65c8e77aa767bb7f401d68c61df5749e65e75273c85b0d6c72cb430766",
"isometric-wall-junctions-dark": "cb1e28f484b304ecda3e35f66e0c0429016d10a5a3022dcc9315a92eb537300f",
"opening-placement-door-thick-wall-dark": "395c03bbf5d968e83664fd6621f0ac25902e718022f2e92ffbcddb8ce629cf9c",
"geometry-devices-editor-dark": "a9e4846ce5453400b87e6ad3d575882bc23a07b59dc3eecb612ed872b0c871ec",
"geometry-decor-editor-dark": "435b36096bbb2996d56ff0af262ddebff4a727edd841b0fad9b8d4f507b987ac",
"tray-wide-selection-en": "06b0df980fd79e8bfc2957878966a11ae9d1a61e80ee870094dc60eae4de26bb",
"tray-wide-tool-ru": "e5a6b2057acd9af2c5417bf413778395112eb1ebecd784251c68c74a16b9dc5f",
"tray-medium-group-en": "5115910bc0f359ce91f361794a51ae1ae6a493203941d09166b412b696eda775",
"tray-medium-selection-ru": "4e5f235be8ed6296e136641d172e727a6a6b7a9d061f0928a96ccc1103c19f1f",
"tray-narrow-palette-en": "88b9846e4b451ed95b7ae7d2c3183a2ea191d7668768992a1364c6a7a53eb0c6",
"tray-narrow-tool-ru": "c4130715b3cb31c68619dfc706a3aa308e86edf272bfae6833b20666764ece2b",
"geometry-diagonal-45-opening-dark": "01206d25631c8fd09fa077932fbb5c1ee115b65ba76b38e6f7b09315dbbf6002",
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
@@ -42,15 +55,15 @@
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
"hover-nested-room-dark": "b70e385829a4fe45a77f5e1c3f8af4e18efec5282fb9594b26fab08c1f113d52",
"hover-nested-room-dark": "6c09526ad885c4555063def5b43287b41124a81d90972c425084dba6e622d055",
"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",
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
"decor-color-popover-mobile-ru": "16d2859d1ed3715c1c4d3d451a8428c37e91ba00da17272c59cf83420a7b6c31",
"backup-full-preview-desktop-en": "7f12943d1027c89f6fe46978fa1f4e1bcdbf85a1216281d850d467e68f52cbda",
"backup-space-preview-mobile-ru": "998c6b52c1cc95109feb440a9966cade738154ceeae387246d40f8c56f9e8a3a"
"decor-color-popover-mobile-ru": "46d4c2e4dd20c3a38e90efe3db59b3e878bdbcf273fbc1aa23de4b230723fa6e",
"backup-full-preview-desktop-en": "cc42a621f55f043b272014b9c32127823ca3573520e502966c71642027f7fdaf",
"backup-space-preview-mobile-ru": "16d06859ea6aac6cbed586f0d6918da972e9c95206fffd771fe43b6084f79877"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 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: 292 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 280 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: 336 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: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 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: 323 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 201 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: 321 KiB

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+97 -1
View File
@@ -79,6 +79,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,12 +119,13 @@ 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'
if (scenario.fillMode || scenario.bgMode || typeof scenario.glowEnabled === 'boolean'
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean') {
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 } : {}),
@@ -318,6 +349,60 @@ export async function prepareGoldenScenario(page, scenario) {
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)
@@ -510,6 +595,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 {
+44 -1
View File
@@ -1,7 +1,7 @@
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 = 18;
export const GOLDEN_MATRIX_VERSION = 22;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -15,6 +15,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ 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: '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 },
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
labs: ['iso'], projection: 'iso', theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
@@ -34,8 +35,50 @@ export const GOLDEN_SCENARIOS = Object.freeze([
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: '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', 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
+11
View File
@@ -12,6 +12,16 @@ flat while reporting the same profile. The dedicated
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 +120,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,95 @@
{
"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
},
"cacheGrowth": {
"cleanFloor": 0,
"glowClip": 0,
"wallUnion": 0,
"openingTunnel": 0,
"openingWallIndex": 0,
"planSnapGeometry": 0
},
"renderedDevices": 200
}
+6 -1
View File
@@ -33,13 +33,15 @@ 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']),
optionalFields: Object.freeze(['_isoGeometryCache', '_planSnapGeometryCache', '_setProjection']),
fieldTypes: Object.freeze({
_booting: 'boolean',
_cleanFloorCache: 'map',
@@ -48,8 +50,11 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
_gridPitch: 'number',
_loadOk: 'boolean',
_model: 'array',
_path: 'array',
_isoGeometryCache: 'map',
_planSnapGeometryCache: 'object',
_setProjection: 'function',
_serverCfg: 'object',
_tool: 'string',
}),
});
+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');
+6 -1
View File
@@ -257,7 +257,12 @@ const layers = await page.evaluate(() => {
const c = window.__card;
const m = c._spaceModel();
const svgEl = c.renderRoot.querySelector('.stage svg');
const kids = [...svgEl.children];
// Stage 2 keeps the flat floor DOM byte-for-byte equivalent but places it
// in one transformable scene group shared with Iso. Ordering is therefore
// measured inside the group that actually owns paper/image/rooms.
const layerRoot = [...svgEl.children].find((node) =>
node.querySelector?.('.hp-paperg') && node.querySelector?.('.room')) || svgEl;
const kids = [...layerRoot.children];
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
const papers = [...svgEl.querySelectorAll('.hp-paper')];
return {
+51 -12
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;
@@ -197,7 +229,10 @@ const paper = await page.evaluate(async () => {
&& cs.fill !== 'none' && !/rgba\(.*,\s*0\)/.test(cs.fill);
}
// …and the picture is drawn ON that paper: image after paper, before walls
const kids = [...sr().querySelector('.stage svg').children];
const svgEl = sr().querySelector('.stage svg');
const layerRoot = [...svgEl.children].find((node) =>
node.querySelector?.('.hp-paperg') && node.querySelector?.('.room')) || svgEl;
const kids = [...layerRoot.children];
const idxOf = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
out.imageAbovePaper = idxOf('image') > idxOf('.hp-paper') && idxOf('image') >= 0;
out.imageBelowRooms = idxOf('image') < idxOf('.room, .room-outline');
@@ -207,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;
@@ -265,8 +302,10 @@ 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)
const first = sr().querySelector('.stage.noplan svg').firstElementChild;
// 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');
out.paperUnderneath = !!first && first.classList.contains('hp-paperg')
&& !!first.firstElementChild && first.firstElementChild.classList.contains('hp-paper');
// live resize preview: drag the L's right wall 0.625 -> 0.6875 — the paper
+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,
});
+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,
});
+7 -1
View File
@@ -197,7 +197,13 @@ Object.assign(out, await page.evaluate(async () => {
},
// модульный кэш конфига инвалидируется только через это событие — стаб
// сохраняет колбэк, чтобы следующий тест мог сбросить кэш
connection: { subscribeEvents: async (cb) => { window.__hpInvalidate = cb; return () => {}; } } };
connection: { subscribeEvents: async (cb, type) => {
// #107 added a third, virtual-light subscription. Keep the config event
// callback specifically; otherwise the next fixture calls the unrelated
// virtual-light listener and reuses this stale shared config snapshot.
if (type === 'houseplan_config_updated') window.__hpInvalidate = cb;
return () => {};
} } };
const host = document.createElement('div');
document.body.appendChild(host);
const card = document.createElement('houseplan-space-card');
+9 -3
View File
@@ -1,4 +1,4 @@
// #89 Stage 1: Labs lifecycle, toggle, projection parity and flat editor boundary.
// #122 Stage 2: Labs lifecycle, structural composition and flat editor boundary.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 850 });
@@ -44,7 +44,12 @@ const out = await page.evaluate(async () => {
const device = root().querySelector('[data-hp="device"]');
const roomLabel = root().querySelector('[data-hp="room-label"]');
result.isoRendered = isoToggle?.getAttribute('aria-pressed') === 'true'
&& !!root().querySelector('[data-hp="iso-underlay"] .iso-floor-side')
&& !!root().querySelector('[data-hp="iso-walls"] .iso-wall-top');
result.sharedProjectionSnapshot = ['.iso-underlay-svg', '.plan-svg', '.iso-shadows-svg', '.iso-walls-svg']
.map((selector) => root().querySelector(selector)?.getAttribute('viewBox'))
.every((value, _index, values) => !!value && value === values[0]);
result.stage2DefinitionsBounded = root().querySelectorAll('[id^="hp-iso-"]').length <= 5;
result.preferenceStored = JSON.parse(localStorage.getItem('houseplan_card_view_v1') || '{}').f1 === 'iso';
result.anchorsFinite = [device, roomLabel].every((node) => node
&& center(node).every((value) => Number.isFinite(value)));
@@ -79,7 +84,7 @@ const out = await page.evaluate(async () => {
build: () => {
attempts++;
if (shouldFail) throw new Error('injected isometric failure');
return [];
return { walls: [], floor: [], openings: [] };
},
});
card.requestUpdate();
@@ -108,7 +113,8 @@ const out = await page.evaluate(async () => {
await frame();
result.removalIsImmediateFlat = JSON.stringify(window.__hpLabs) === '[]'
&& !root().querySelector('[data-hp="projection-toggle"]')
&& !root().querySelector('[data-hp="iso-walls"]');
&& !root().querySelector('[data-hp="iso-walls"]')
&& !root().querySelector('[id^="hp-iso-"]');
return result;
});
+57 -4
View File
@@ -1,4 +1,4 @@
// #89 Stage 1: Q6 live-floor preservation plus touch, kiosk and remount paths.
// #122 Stage 2: live-floor preservation, vertical openings, touch and remount paths.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(
@@ -26,6 +26,8 @@ const out = await page.evaluate(async () => {
contact: 'binary_sensor.window' },
{ id: 'iso-door', type: 'door', x: 0.55, y: 0.36, angle: 90, length: 0.12,
contact: 'binary_sensor.window', lock: 'lock.front_door' },
{ id: 'iso-gate', type: 'gate', x: 0.72, y: 0.14, angle: 0, length: 0.16,
contact: 'binary_sensor.gate', lock: 'lock.front_door' },
];
configSpace.partitions = [{
id: 'iso-live-wall', a: [0.15, 0.12], b: [0.85, 0.12], cm: 15,
@@ -86,6 +88,7 @@ const out = await page.evaluate(async () => {
glowSources: count('.glowlayer [data-glow-source]'),
sun: count('.sunlayer'),
openings: count('[data-hp="opening"]'),
verticalOpenings: count('[data-hp="iso-openings"] .iso-opening-panel'),
devices: count('[data-hp="device"]'),
hover: count('.room-hover-fill'),
vacuumPucks: count('.vacpuck'),
@@ -104,6 +107,8 @@ const out = await page.evaluate(async () => {
const wallFingerprint = root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint;
const cachedGeometry = original._isoGeometryCache.get(wallFingerprint)?.geometry;
const light = original.hass.states['light.ceiling'];
const openingPanelBefore = root(original)
.querySelector('[data-hp="iso-openings"] [data-id="iso-door"]')?.getAttribute('d');
original.hass = {
...original.hass,
states: {
@@ -111,6 +116,9 @@ const out = await page.evaluate(async () => {
'light.ceiling': {
...light, attributes: { ...light.attributes, rgb_color: [255, 64, 32] },
},
'binary_sensor.window': {
...original.hass.states['binary_sensor.window'], state: 'off',
},
},
};
await original.updateComplete;
@@ -118,22 +126,26 @@ const out = await page.evaluate(async () => {
const afterSpillParts = [...root(original).querySelectorAll('.glow-pool')]
.map((node) => node.getAttribute('data-lit-parts'));
const ordered = [
root(original).querySelector('.iso-underlay-svg'),
root(original).querySelector('.hp-backdrop'),
root(original).querySelector('.decorlayer'),
root(original).querySelector('[data-hp="room"]'),
root(original).querySelector('.glow-base-layer'),
root(original).querySelector('.glowlayer'),
root(original).querySelector('.sunlayer'),
root(original).querySelector('.iso-shadows-svg'),
root(original).querySelector('.iso-walls-svg'),
root(original).querySelector('.vacpuck'),
];
const result = {
isoOnTouch: !!root(original).querySelector('[data-hp="iso-walls"]'),
flatIsoLayerParity: JSON.stringify(flat) === JSON.stringify(before)
flatIsoLayerParity: JSON.stringify({ ...flat, openings: 0, verticalOpenings: before.verticalOpenings })
=== JSON.stringify(before)
&& JSON.stringify(flatSpillParts) === JSON.stringify(beforeSpillParts),
liveLayersPresent: before.rooms > 0 && before.roomFills > 0 && before.decor >= 2
&& before.furniture > 0 && before.backdrop > 0 && before.glowBase > 0
&& before.glowSources >= 2 && before.sun > 0 && before.openings >= 2
&& before.glowSources >= 2 && before.sun > 0 && before.openings === 0
&& before.verticalOpenings === 5
&& before.devices > 0 && before.hover > 0 && before.vacuumPucks > 0
&& before.vacuumTrails >= 2,
liveLayersStable: JSON.stringify(after) === JSON.stringify(before),
@@ -147,9 +159,50 @@ const out = await page.evaluate(async () => {
.some((stop) => stop.getAttribute('stop-color') === '#ff4020'),
sameWallFingerprint: root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint === wallFingerprint,
haUpdateReusesGeometry: original._isoGeometryCache.get(wallFingerprint)?.geometry === cachedGeometry,
noStage1VerticalOpenings: !root(original).querySelector('.iso-window,.window-light,.vertical-door'),
contactUpdateMovesOnlyLivePanel: !!openingPanelBefore
&& root(original).querySelector('[data-hp="iso-openings"] [data-id="iso-door"]')?.getAttribute('d')
!== openingPanelBefore
&& original._isoGeometryCache.size === 1,
oneLightModel: !root(original).querySelector('.window-light,.iso-window-light,.iso-glow,.iso-sun'),
oldFloorSymbolsNotDuplicated: before.openings === 0,
};
configSpace.settings.hide_openings = true;
original._cfgEpoch++;
original.requestUpdate();
await original.updateComplete;
result.hideOpeningsKeepsStructure = !!root(original).querySelector('[data-hp="iso-walls"]')
&& !root(original).querySelector('[data-hp="iso-openings"]')
&& !root(original).querySelector('[data-hp="opening"]')
&& root(original).querySelectorAll('.glowlayer [data-glow-source]').length >= 2;
configSpace.settings.hide_openings = false;
configSpace.settings.show_borders = false;
original._cfgEpoch++;
original.requestUpdate();
await original.updateComplete;
result.noBordersUsesFloorSymbols = !root(original).querySelector('[data-hp="iso-walls"]')
&& !root(original).querySelector('[data-hp="iso-underlay"]')
&& root(original).querySelectorAll('[data-hp="opening"]').length === 3;
configSpace.settings.show_borders = true;
original._cfgEpoch++;
original.requestUpdate();
await original.updateComplete;
result.visibleBordersRestoreStage2 = !!root(original).querySelector('[data-hp="iso-openings"]')
&& original._isoGeometryCache.size === 1;
const supports = CSS.supports;
CSS.supports = () => false;
original.requestUpdate();
await original.updateComplete;
result.unsupportedDecorationKeepsIsoStructure = !!root(original).querySelector('[data-hp="iso-walls"]')
&& !!root(original).querySelector('[data-hp="iso-openings"]')
&& !root(original).querySelector('[data-hp="iso-shadows"]');
CSS.supports = supports;
original.requestUpdate();
await original.updateComplete;
const calls = [];
original.hass = {
...original.hass,
+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));
+187
View File
@@ -0,0 +1,187 @@
// #131: a read-only HA session may read the House Plan snapshot while HA
// rejects event subscriptions. The initial frame must still select one exact
// space and render every raw-space layer before any user click.
import { launch, checkAll, finish } from './serve.mjs';
import { makeVisualMatrixFixture } from './fixtures/visual-matrix.mjs';
const fixture = makeVisualMatrixFixture();
const { page, browser } = await launch({ width: 820, height: 760 });
const result = await page.evaluate(async (rawFixture) => {
const out = {};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const HP = customElements.get('houseplan-card');
const host = document.getElementById('host');
window.__card.remove();
HP._warmBootReset?.();
for (const key of Object.keys(localStorage)) {
if (key.startsWith('houseplan_card_')) localStorage.removeItem(key);
}
history.replaceState(null, '', '/demo.html');
const fixture = structuredClone(rawFixture);
const lighting = fixture.config.spaces.find((space) => space.id === 'golden-lighting');
const geometry = fixture.config.spaces.find((space) => space.id === 'golden-geometry');
lighting.id = 'home';
lighting.title = 'Home';
lighting.settings = {
...lighting.settings,
fill_mode: 'custom',
custom_fill: { c: '#cdbb96', a: 0.42 },
glow_enabled: true,
show_borders: true,
};
lighting.decor = [{
id: 'readonly-furniture', kind: 'rect', x: 0.16, y: 0.18, w: 0.12, h: 0.08,
color: '#8d6e63', opacity: 1, width_cm: 2, fill: true,
fill_color: '#bcaaa4', fill_opacity: 1,
}];
geometry.id = 'upstairs';
geometry.title = 'Upstairs';
fixture.config.spaces = [lighting, geometry];
fixture.config.markers.push({
id: 'golden-light-one', binding: 'device:golden-light-one', display: 'value',
});
fixture.layout = Object.fromEntries(Object.entries(fixture.layout).map(([id, pos]) => [
id,
{ ...pos, s: pos.s === 'golden-lighting' ? 'home' : pos.s === 'golden-geometry' ? 'upstairs' : pos.s },
]));
const base = window.__mkHass();
const makeHass = (subscriptionMode = 'partial') => {
const calls = { configGets: 0, events: {}, unsubscribed: [] };
const connection = {
subscribeEvents: async (callback, event) => {
calls.events[event] = (calls.events[event] || 0) + 1;
if (String(event).startsWith('houseplan_')
&& (subscriptionMode === 'reject-all'
|| event === 'houseplan_config_updated' || event === 'houseplan_trail_updated')) {
throw new Error('unauthorized');
}
return () => { calls.unsubscribed.push(event); };
},
subscribeMessage: async () => () => {},
};
const hass = {
...base,
user: { id: 'readonly', name: 'Readonly', is_admin: false },
devices: fixture.devices,
entities: fixture.entities,
areas: fixture.areas,
states: fixture.states,
connection,
callWS: async (message) => {
if (message.type === 'houseplan/config/get') {
calls.configGets += 1;
return { config: structuredClone(fixture.config), rev: 131, can_write: false };
}
if (message.type === 'houseplan/layout/get') {
return { layout: structuredClone(fixture.layout), rev: 131 };
}
if (message.type === 'houseplan/trail/get') return { trails: {} };
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
if (message.type === 'config_entries/get') {
return [{ entry_id: 'golden_entry', domain: 'houseplan_golden', title: 'Golden' }];
}
if (message.type === 'manifest/list') {
return [{ domain: 'houseplan_golden', name: 'Golden' }];
}
return { ok: true };
},
};
return { hass, calls };
};
const layerSnapshot = (card) => {
const root = card.shadowRoot || card.renderRoot;
return {
exactSpace: card._curSpaceCfg?.id || null,
activeSpace: root.querySelector('[data-hp="space-tab"].active')?.getAttribute('data-id') || null,
rooms: root.querySelectorAll('[data-hp="room"]').length,
decor: root.querySelectorAll('[data-hp="decor"]').length,
walls: root.querySelectorAll('.wallbodies .wallbody-fill').length,
glow: root.querySelectorAll('.glow-spot').length,
devices: root.querySelectorAll('[data-hp="device"]').length,
values: root.querySelectorAll('[data-hp="device"] .valtext').length,
};
};
const complete = (snapshot) => snapshot.exactSpace === 'home'
&& snapshot.activeSpace === 'home' && snapshot.rooms > 0 && snapshot.decor > 0
&& snapshot.walls > 0 && snapshot.glow > 0 && snapshot.devices > 0 && snapshot.values > 0;
const waitForCard = async (card, calls, kiosk = false) => {
const until = performance.now() + 9000;
while (performance.now() < until) {
const snapshot = layerSnapshot(card);
const allSubscriptionsTried = ['houseplan_config_updated', 'houseplan_trail_updated', 'houseplan_layout_updated']
.every((event) => calls.events[event] === 1);
if (card._loadOk && !card._loading && card._booting === false
&& allSubscriptionsTried && complete({
...snapshot,
activeSpace: kiosk ? 'home' : snapshot.activeSpace,
})) return snapshot;
await sleep(25);
}
throw new Error(`readonly card did not settle: ${JSON.stringify({
state: layerSnapshot(card), calls, space: card._space, loadOk: card._loadOk,
})}`);
};
const mount = async (title, options = {}) => {
const runtime = makeHass(options.subscriptionMode);
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', title, kiosk: !!options.kiosk });
host.appendChild(card);
card.hass = runtime.hass;
const snapshot = await waitForCard(card, runtime.calls, !!options.kiosk);
return { card, snapshot, ...runtime };
};
const cold = await mount('Readonly cold start');
out.coldSelectsExactSpace = cold.card._space === 'home'
&& cold.snapshot.exactSpace === 'home' && cold.snapshot.activeSpace === 'home';
out.coldRendersAllSpatialLayers = complete(cold.snapshot);
out.readOnlyStaysReadOnly = cold.card._canEdit === false
&& !(cold.card.shadowRoot || cold.card.renderRoot).querySelector('.modetab');
out.allSubscriptionsAttempted = ['houseplan_config_updated', 'houseplan_trail_updated', 'houseplan_layout_updated']
.every((event) => cold.calls.events[event] === 1);
out.allowedSubscriptionAdopted = typeof cold.card._unsubLayout === 'function'
&& !cold.card._unsubCfg && !cold.card._unsubTrail;
out.cacheWritten = !!localStorage.getItem('houseplan_card_cfg_v1');
const beforeClick = layerSnapshot(cold.card);
cold.card._pickSpace('home');
await cold.card.updateComplete;
out.activeTabClickIsNoop = JSON.stringify(layerSnapshot(cold.card)) === JSON.stringify(beforeClick);
await sleep(700);
out.optionalFailureNoFullRetry = cold.calls.configGets === 1;
cold.card.remove();
await sleep(20);
out.successfulSubscriptionCleanedUp = cold.calls.unsubscribed.includes('houseplan_layout_updated');
const warm = await mount('Readonly cold start');
out.warmRemountKeepsCompleteSpace = complete(warm.snapshot) && warm.card._space === 'home';
warm.card.remove();
await sleep(20);
HP._warmBootReset?.();
const reload = await mount('Readonly simulated reload');
out.cachedReloadKeepsCompleteSpace = complete(reload.snapshot) && reload.card._space === 'home';
reload.card.remove();
await sleep(20);
HP._warmBootReset?.();
localStorage.removeItem('houseplan_card_cfg_v1');
localStorage.removeItem('houseplan_card_nav_v1');
const kiosk = await mount('Readonly cold kiosk', { kiosk: true, subscriptionMode: 'reject-all' });
const kioskRoot = kiosk.card.shadowRoot || kiosk.card.renderRoot;
out.coldKioskCompleteWithoutHeaderAction = kiosk.card._space === 'home'
&& kiosk.snapshot.exactSpace === 'home' && kiosk.snapshot.rooms > 0
&& kiosk.snapshot.decor > 0 && kiosk.snapshot.walls > 0 && kiosk.snapshot.glow > 0
&& kiosk.snapshot.devices > 0 && getComputedStyle(kioskRoot.querySelector('.hdr')).display === 'none';
out.kioskCacheWritten = !!localStorage.getItem('houseplan_card_cfg_v1');
kiosk.card.remove();
return out;
}, fixture);
checkAll(result);
await finish(browser, result);
+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);
+149
View File
@@ -0,0 +1,149 @@
/** 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._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._path.length === 5
&& close(card._path[0][0], card._path.at(-1)[0])
&& close(card._path[0][1], card._path.at(-1)[1]);
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;
// A canonical cut splits the room wall, so the same endpoint pair no longer closes.
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.openingCutPreventsAutoClose = !card._roomDialog
&& card._path.length === 4
&& currentDraft()?.points.length === 4;
// An eligible but self-intersecting prospective ring is consumed without partial write.
await reset();
await click(500, 100);
await click(400, 300);
await click(700, 300);
const invalidDraftBefore = clone(currentDraft());
await click(500, 500);
result.invalidCloseHasNoPartialWrite = !card._roomDialog
&& card._path.length === 3
&& JSON.stringify(currentDraft()) === JSON.stringify(invalidDraftBefore)
&& card._toast === card._t('toast.contour_cannot_close');
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);
+1 -1
View File
@@ -116,7 +116,7 @@ const res = await page.evaluate(async () => {
c._setProjection('iso');
await update();
out.isoUsesCanonicalBody = !!sr().querySelector('.iso-walls .iso-wall-top')
&& c._isoSource().build().flat(2).length > 0;
&& c._isoSource().build().walls.flat(2).length > 0;
c._setProjection('flat');
await update();
+12 -12
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;
@@ -99,21 +99,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();
@@ -153,13 +153,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));
+171
View File
@@ -0,0 +1,171 @@
// #107: persistent manual virtual light, shared by two full cards and one static card.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1200, height: 900 });
const result = await page.evaluate(async () => {
const first = window.__card;
const markerId = 'smoke_manual_virtual_light';
const space = first._model[0];
const room = space.rooms.find((candidate) => candidate.id && candidate.area);
const serverConfig = structuredClone(first._serverCfg);
serverConfig.markers = [...(serverConfig.markers || []), {
id: markerId,
name: 'Manual virtual lamp',
binding: 'virtual',
is_light: true,
tap_action: 'toggle',
tap_confirm: false,
controls: ['light.ceiling'],
space: space.id,
area: room.area,
room_id: room.id,
}];
const serverLayout = {
...first._layout,
[markerId]: { s: space.id, x: 0.32, y: 0.32 },
};
const virtual = { rev: 0, config_rev: first._cfgRev, off: [] };
const subscriptions = new Map();
const serviceCalls = [];
let toggleCalls = 0;
const emit = (event, data) => {
for (const listener of subscriptions.get(event) || []) listener({ data });
};
const baseHass = first.hass;
const baseCallWS = baseHass.callWS.bind(baseHass);
const connection = {
...baseHass.connection,
addEventListener: baseHass.connection?.addEventListener?.bind(baseHass.connection),
removeEventListener: baseHass.connection?.removeEventListener?.bind(baseHass.connection),
subscribeMessage: baseHass.connection?.subscribeMessage?.bind(baseHass.connection),
subscribeEvents: async (callback, event) => {
const listeners = subscriptions.get(event) || new Set();
listeners.add(callback);
subscriptions.set(event, listeners);
return () => listeners.delete(callback);
},
};
const hass = {
...baseHass,
connection,
callService: async (domain, service, data) => {
serviceCalls.push({ domain, service, data });
},
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: first._layoutRev,
};
if (message.type === 'houseplan/virtual_light/toggle') {
toggleCalls++;
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++;
const reply = {
marker_id: message.marker_id,
on: !off.has(message.marker_id),
rev: virtual.rev,
};
queueMicrotask(() => emit('houseplan_virtual_light_updated', reply));
return reply;
}
return baseCallWS(message);
},
};
// Reconnect the already mounted card to the fake server authority.
first._unsubVirtual?.();
first._unsubVirtual = null;
first.hass = hass;
await first._reloadConfigOnly(true);
first._layout = serverLayout;
first._regSignature = '';
first._maybeRebuildDevices();
first._ensureLiveSyncSubscriptions();
await first.updateComplete;
const second = document.createElement('houseplan-card');
second.setConfig({ type: 'custom:houseplan-card', icon_size: 3.4 });
document.body.append(second);
second.hass = hass;
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: space.id });
document.body.append(staticCard);
staticCard.hass = hass;
const waitFor = async (predicate, timeout = 6000) => {
const started = Date.now();
while (!predicate() && Date.now() - started < timeout) {
await new Promise((resolve) => setTimeout(resolve, 40));
}
return !!predicate();
};
await waitFor(() => second._devices?.some((device) => device.id === markerId));
await waitFor(() => staticCard.renderRoot?.querySelector(`[data-id="${markerId}"]`));
await second.updateComplete;
await staticCard.updateComplete;
const deviceOf = (card) => card._devices.find((device) => device.id === markerId);
const on = (card) => card._stateClass(deviceOf(card)).includes('on');
const staticOn = () => staticCard.renderRoot
.querySelector(`[data-id="${markerId}"]`)?.classList.contains('on');
const initialEverywhereOn = on(first) && on(second) && staticOn();
const markerNode = first.renderRoot.querySelector(`.dev[data-id="${markerId}"]`);
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await waitFor(() => virtual.rev === 1 && !on(second) && staticOn() === false);
await first.updateComplete;
const clickEverywhereOff = !on(first) && !on(second) && staticOn() === false;
// A second real click returns to on; the saved outgoing control never calls HA.
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await waitFor(() => virtual.rev === 2 && on(second) && staticOn() === true);
const secondClickEverywhereOn = on(first) && on(second) && staticOn();
const beforeTouch = toggleCalls;
markerNode.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, pointerType: 'touch', pointerId: 41,
clientX: 100, clientY: 100,
}));
markerNode.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, composed: true, pointerType: 'touch', pointerId: 41,
clientX: 100, clientY: 100,
}));
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await waitFor(() => virtual.rev === 3 && !on(second) && staticOn() === false);
const touchSingleToggle = toggleCalls === beforeTouch + 1;
// A fresh card must paint the server's off snapshot, not wait for an event.
const reloaded = document.createElement('houseplan-card');
reloaded.setConfig({ type: 'custom:houseplan-card', icon_size: 3.4 });
document.body.append(reloaded);
reloaded.hass = hass;
await waitFor(() => reloaded._devices?.some((device) => device.id === markerId));
await reloaded.updateComplete;
const reloadFirstStateOff = !on(reloaded);
second.remove();
staticCard.remove();
reloaded.remove();
return {
initialEverywhereOn,
clickEverywhereOff,
secondClickEverywhereOn,
touchSingleToggle,
reloadFirstStateOff,
oneServerTogglePerGesture: toggleCalls === 3,
noHaServiceCalls: serviceCalls.length === 0,
};
});
checkAll(result);
await finish(browser, result);
+5
View File
@@ -42,6 +42,11 @@ const result = await page.evaluate(async (visualFixture) => {
card._serverCfg = visualFixture.config;
card._layout = visualFixture.layout;
card._space = 'golden-lighting';
// This fixture starts after a same-route space choice. #131 deliberately
// refuses to treat the class initializer/current id as cold-start intent;
// mark this injected selection as already adopted navigation so the reload
// exercises continuity instead of cold-start precedence.
card._navApplied = true;
card._regSignature = '';
card.hass = {
...card.hass,
+153
View File
@@ -0,0 +1,153 @@
/** 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'));
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));
File diff suppressed because one or more lines are too long
+775 -539
View File
File diff suppressed because one or more lines are too long
+98 -2
View File
@@ -104,6 +104,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.
7. **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
@@ -318,6 +330,16 @@ 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.
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
walls overlap collinearly rather than match exactly). **Merge/Split** use boolean geometry from
@@ -334,12 +356,40 @@ 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 as extras only after room opening cuts, so an opening
cannot cut a coincident partition and room exterior authority remains intact.
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
not rebuild topology.
### Hidden Isometric Stage 2 composition (#122)
The hidden `iso` View reuses that masonry but has one bounded structural scene,
not a second house model. `_isoGeometryCache` remains an eight-entry LRU keyed
by room/wall/opening geometry (including opening flips), scale/camera, fixed
wall/floor-edge heights and an algorithm revision. Each value holds wall faces,
the room/exterior slab edge, immutable opening jamb bases and the projected
frame. HA state, theme, hover and filter support are presentation inputs and
never enter this key.
`floorFootprintGeometry()` derives only the union of room floors and exterior
masonry; unlike wall volume, it has no independent partition/column input.
`buildIsoFloorGeometry()` emits visible low faces for outer component rings,
not internal edges or holes. `src/iso-openings.ts` stores jamb/axis topology and
applies `openingAmount()` only during live projection, keeping contact updates
out of the boolean geometry path.
Composition is shared-viewBox SVG: ambient shadow/floor edge → the existing
affine-projected floor/live scene → contact/leaf shadows → wall material and
vertical panels → existing screen-facing HTML overlays. A constant set of
gradients/filters serves every face. Unsupported decoration or forced colours
remove nuance/shadows without changing projection; only structural failure
uses the Stage 1 latched Flat fallback. Details and fixed ratios are recorded in
`docs/adr/122-isometric-stage2-composition.md`.
## Markup editor (v1.4.0+)
State inside the card: `_markup` (mode), `_tool` (draw/partition/column/merge/split/resize/opening/
@@ -376,6 +426,16 @@ 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.
Adjacent-room auto-close reuses the immutable architectural snap snapshot and
requires the first and prospective terminal points to belong to one completed
room's same solid segment after opening/open-span cuts. It runs only after the
draft has two edges and after explicit first-point/Ctrl closure handling. The
prospective polygon is checked for range, self-intersection and room overlap
before `_path` or `room_drafts` changes; success persists the terminal segment
and then opens the ordinary room dialog. Cancel therefore returns to the open
draft at that terminal point, while Save lets the normal room/wall
normalization preserve an existing shared wall's thickness.
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
per-space `cell_cm` — cm represented by one grid cell (default 5, so 240 cells ≈ 12 m).
@@ -464,7 +524,8 @@ spans are clipped per atomic body.
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}, rev}` |
| `houseplan/layout/set` | `layout`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_layout_updated` |
| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` |
| `houseplan/config/get` | — | `{config, rev}` |
| `houseplan/config/get` | — | `{config, rev, virtual_lights:{rev,config_rev,off[]}}` (`virtual_lights` optional for rolling compatibility) |
| `houseplan/virtual_light/toggle` | `marker_id` | `{marker_id,on,rev}` / err `not_toggleable`; event `houseplan_virtual_light_updated` |
| `houseplan/trail/get` | — | `{trails: {marker: {current, previous}}}` — vacuum runs, raw robot coords |
| `houseplan/trail/delete` | `marker_id` | `{ok, removed}` — erase current/previous runs after marker deletion |
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
@@ -485,6 +546,23 @@ spans are clipped per atomic body.
Manual attachments upload over HTTP (streaming, transactional staging), not WS —
the old `houseplan/file/set` was removed in v1.10.0.
Manual virtual-light state is operational data, not plan configuration. The
integration owns a separate versioned `houseplan.virtual_lights` Store whose
bounded payload contains only `{rev, config_rev, off[]}`. The existing shared
write lock serializes config reconciliation and atomic toggles. Eligibility is
always recalculated from server config; the toggle command accepts no desired
state, entity id or service. It is intentionally available to every
authenticated View user, while config writers remain governed by `may_write`.
The durable save precedes both reply and event. A config-revision gap from an
older writer clears manual off bits to the compatibility default `on`.
The first `config/get` frame carries the coherent operational snapshot. Full
cards subscribe directly to the update event; all `houseplan-space-card`
instances share the module-level config cache and one subscription. Local
storage may retain the last snapshot for continuity, but never authorizes an
optimistic toggle. The data is excluded from marker/layout schemas, portable
export/import and the HA entity registry.
Portable import preview uses authenticated
`POST /api/houseplan/import/preview`. The endpoint streams at most 8 MiB,
strictly rejects duplicate/prototype keys, non-finite numbers and future model
@@ -651,6 +729,24 @@ localized opaque recovery overlay, after a 150 ms delay. The overlay never
steals initial focus; while visible it alone is interactive and the scene is
`inert`.
**The initial snapshot does not depend on live-sync subscriptions** (#131).
`houseplan-card` first accepts config and layout, builds the model, chooses one
exact space, caches the accepted snapshot and restores its viewport. Only then
is the mandatory load complete. Config, trail and layout event subscriptions
start together as independent best-effort enrichments: one rejected channel
does not prevent the others from subscribing, does not erase the usable
snapshot and does not schedule a full-load retry solely for that rejection.
Missing channels get another attempt on the next normal load or reconnect.
`src/initial-load.ts` is the shared authority for the exact space used by a
cached snapshot, a live snapshot and its protected-backdrop candidate. A cold
load considers only valid ids in this order: URL hash, saved navigation,
`default_floor`, first live space. Once an initial URL hash has been consumed,
a valid same-route current selection is preserved instead of repeatedly
snapping back to that hash. The legacy field initializer is never a cold-start
choice by itself. A plan with no spaces keeps `null` authority and does not
invent an id.
**Room climate is one pass per hass snapshot** (review R2-3). `areaClimateMap()`
classifies the whole registry once and returns `Map<area, {temp, hum}>`; the
card memoizes it on `hass` identity, which Home Assistant replaces on every
+37
View File
@@ -542,3 +542,40 @@ 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 **Room outline** or **Partition** 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 outline and its live preview are not candidates. Door,
window, gate and intentionally open-span intervals are cut from room 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, while the first
point of a valid room outline remains an explicit closure target. 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.
For **Room outline**, the same completed-room provenance also enables adjacent
auto-close. Once the draft already has two edges, a click that places its next
point on the same uninterrupted solid room-wall segment as the first point is
validated as the prospective polygon before any mutation. A valid click
persists that terminal draft segment and opens the standard room dialog with
the existing wall as the closing edge. A cut, a different edge, a saved draft
or an independent partition is ineligible; an eligible but invalid polygon is
reported without appending a point or segment. Explicit first-point and
Ctrl/Cmd closure retain priority.
+101
View File
@@ -2,11 +2,112 @@
## Unreleased
## 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
- Read-only View and kiosk cards now paint a complete first frame even when
Home Assistant refuses live-sync event subscriptions. The selected space,
room fills, decor, walls, Glow and device values no longer require clicking
the already active space tab ([#131](https://github.com/Matysh/houseplan-card/issues/131)).
- Virtual markers configured as **Light source → Always** with **Toggle state**
can now be switched directly without an HA helper. Their shared state
survives reloads/restarts and stays consistent across Glow, room light
presentation, full cards and static space cards, including markers that retain
saved outgoing controls
([#107](https://github.com/Matysh/houseplan-card/issues/107)).
- Small fixes and improvements.
## v1.63.0 — 2026-08-13
- Preserved explicit door, window and gate bindings when their standalone
sensor or lock marker is removed, and fixed the supported empty state after
deleting the last space
([#104](https://github.com/Matysh/houseplan-card/issues/104),
[#111](https://github.com/Matysh/houseplan-card/issues/111)).
- Splitting a room from an existing corner no longer deforms the exterior
facade, including with thick dividers. Flat, static and hidden isometric
rendering use the same preserved wall geometry
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
- Small fixes and improvements.
## v1.63.0-beta.2 — 2026-08-13
- Splitting a room from an existing corner no longer deforms the exterior wall
or pulls a thick internal divider through the facade. Plan, View, kiosk,
static cards, hidden isometric rendering and light obstacles now use the same
preserved exterior geometry, including already saved plans
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
- Small fixes and improvements.
## v1.63.0-beta.1 — 2026-08-13
+105
View File
@@ -8,11 +8,116 @@
## Unreleased
## v1.64.0-beta.3 — 2026-08-14
- Большие планы больше не пересчитывают неиспользуемое объединение физических
стен при каждом переключении этажа, а цель привязки в Плане до первого клика
обновляется без полной перерисовки карточки. Регрессии производительности
релизной линии `v1.64.0` устранены без изменения геометрии и поведения snap
([#156](https://github.com/Matysh/houseplan-card/issues/156)).
- Мелкие исправления и улучшения.
## v1.64.0 — 2026-08-14
- **«Следует за Солнцем»** теперь создаёт вокруг плана четырёхфазное окружение
рассвета, дня, заката и ночи с движущимся мягким светом, сохраняя без изменений
живые заливки плана, Glow, устройства, декор, пылесос, hover и оконные лучи
([#146](https://github.com/Matysh/houseplan-card/issues/146)).
- При рисовании «Контура комнаты» и «Перегородки» теперь видны точные узлы и
осевые линии привязки стен; допустимый контур замыкается по существующей стене,
а толстые угловые и T-стыки выглядят бесшовно в плоской, статичной и скрытой
изометрической геометрии
([#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 и киоска теперь сразу показывают полный первый кадр,
даже если Home Assistant запрещает подписки на события live-sync
([#131](https://github.com/Matysh/houseplan-card/issues/131)).
- Виртуальные маркеры, настроенные как постоянно активный источник света, теперь
можно переключать без helper Home Assistant; общее состояние сохраняется и
согласовано между полной и статичной карточками
([#107](https://github.com/Matysh/houseplan-card/issues/107)).
- Диалоги свойств проёма и физического объекта сохраняют кнопки футера в одной
строке на настольном экране и адаптивный перенос на узком
([#140](https://github.com/Matysh/houseplan-card/issues/140)).
- Большие планы больше не пересчитывают неиспользуемое объединение физических
стен при каждом переключении этажа, а цель привязки в Плане до первого клика
обновляется без полной перерисовки карточки
([#156](https://github.com/Matysh/houseplan-card/issues/156)).
- Мелкие исправления и улучшения.
## v1.64.0-beta.2 — 2026-08-14
- **«Следует за Солнцем»** теперь создаёт вокруг плана четырёхфазное окружение:
рассвет, день, закат и ночь, с движущимся мягким светом и полупрозрачным
внешним контуром. Полный снимок `sun.sun` используется при наличии, иначе
включается локальное время браузера; компас фону больше не нужен. Сам план,
Glow, заливки, устройства, декор, пылесос, hover и оконные лучи между фазами
не меняются. Полный View, киоск и статическая карточка показывают один
результат. Новые установки и пространства включают режим явно, а однократная
миграция и перенос full/space сохраняют прежний выбор фона существующих
планов. Существующий индикатор масштаба с процентами остаётся видимым поверх
плана при увеличении ([#146](https://github.com/Matysh/houseplan-card/issues/146)).
- Диалоги свойств проёма и физического объекта теперь используют существующую
настольную ширину 500 px: «Удалить», «Отмена» и «Сохранить» помещаются в одну
строку с запасом для локализации, а на узком экране сохраняется адаптивный
перенос ([#140](https://github.com/Matysh/houseplan-card/issues/140)).
- Новый контур комнаты, начатый и законченный на одном непрерывном сплошном
участке стены существующей комнаты, теперь замыкается автоматически и
открывает обычный диалог комнаты. Одинаково работают конечные узлы и точки
T-соединения на линии; проёмы, намеренные разрывы, разные грани и второй клик
без достаточного числа сторон быстрым замыканием не считаются
([#138](https://github.com/Matysh/houseplan-card/issues/138)).
- Соединённые отрезки «Контура комнаты» и «Перегородки» теперь сразу, включая
активный rubber-band, образуют одну бесшовную толстую стену. Прямые и косые
углы получают ограниченный mitre/bevel, T-соединение не показывает торец,
каждый отрезок сохраняет свою толщину, а hover толщины одиночного сегмента и
свободные концы остаются видимыми и плоскими в
Plan, View, статичной карточке, скрытой изометрии, чистой площади и световой
геометрии ([#141](https://github.com/Matysh/houseplan-card/issues/141)).
- При рисовании инструментами «Контур комнаты» и «Перегородка» поверх
существующих стен теперь видны их осевые линии и точные конечные узлы.
Увеличенная точка заранее показывает соединение с концом стены или создание
T-соединения на её линии; разрывы дверей, окон, ворот и намеренно открытых
участков исключены ([#137](https://github.com/Matysh/houseplan-card/issues/137)).
- Мелкие исправления и улучшения.
## v1.64.0-beta.1 — 2026-08-14
- Read-only карточки в режимах View и киоска теперь сразу показывают полный
первый кадр, даже если Home Assistant запрещает подписки на события live-sync.
Выбранное пространство, заливки комнат, декор, стены, Glow и значения
устройств больше не требуют нажатия на уже активную вкладку пространства
([#131](https://github.com/Matysh/houseplan-card/issues/131)).
- Виртуальные маркеры с сочетанием **Источник света → Всегда** и
**Переключить состояние** теперь можно включать и выключать без helper/entity
Home Assistant. Общее состояние переживает перезагрузку страницы и HA и
одинаково отображается в Glow, свете комнаты, полной и статической карточках,
в том числе при сохранённых исходящих связях управления
([#107](https://github.com/Matysh/houseplan-card/issues/107)).
- Мелкие исправления и улучшения.
## v1.63.0 — 2026-08-13
- Сохранены явные привязки дверей, окон и ворот после удаления отдельного
маркера датчика или замка; исправлено предусмотренное пустое состояние после
удаления последнего пространства
([#104](https://github.com/Matysh/houseplan-card/issues/104),
[#111](https://github.com/Matysh/houseplan-card/issues/111)).
- Split из существующего угла комнаты больше не деформирует наружный фасад,
в том числе с толстым разделителем. Плоский, статичный и скрытый
изометрический рендер используют одну сохранённую геометрию стен
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
- Мелкие исправления и улучшения.
## v1.63.0-beta.2 — 2026-08-13
- Split из существующего угла комнаты больше не деформирует наружную стену и
не вытягивает толстый внутренний разделитель сквозь фасад. Редактор плана,
View, киоск, статичная карточка, скрытая изометрия и световые препятствия
используют одну сохранённую наружную геометрию, в том числе для уже
сохранённых планов ([#123](https://github.com/Matysh/houseplan-card/issues/123)).
- Мелкие исправления и улучшения.
## v1.63.0-beta.1 — 2026-08-13
+47
View File
@@ -48,6 +48,27 @@ Unknown future fields remain outside this report and continue to follow the
backend's forward-compatibility policy. Absence from the report is therefore
not permission to delete a field.
## Four-phase background default and transfer (#146)
The schema remains `settings.bg_mode: static | daynight` globally and per
space; absence is still accepted for older files and the runtime's final
fallback remains `static`. New semantics are materialized instead of changing
that fallback:
- new integration config uses explicit global `daynight`;
- manual and Floors/Areas space creation writes explicit per-space `daynight`;
- storage minor v1.2 migrates a missing or invalid legacy global token to
`static` once, while preserving valid global/per-space values, unknown
siblings, revisions, and the other stores;
- full export/import always carries an explicit global mode, with legacy
missing mode becoming `static` before preview/apply;
- a space export copies its effective mode into the exported space; a legacy
space import without a mode becomes `static` before merge, regardless of the
target installation's global setting.
Import preview and apply therefore operate on the same normalized candidate.
Explicit `static` and `daynight` survive same-instance and foreign transfer.
## Legacy device tap action
The historical marker token `tap_action: cover` remains accepted indefinitely.
@@ -142,3 +163,29 @@ while an untouched old broken source remains readable and round-trippable.
space transfer remaps an internal target and disables/counts a link whose
target is outside the transfer. Older clients ignore the field and may erase
it if they reconstruct the same marker after a downgrade.
## Persistent manual virtual-light state
The exact `virtual` + `is_light:true` + `tap_action:toggle` combination has a
shared operational on/off state. It is not a Marker/ServerConfig field: the
integration stores `{rev, config_rev, off[]}` under a separate versioned Store
key and exposes an optional `virtual_lights` snapshot in `houseplan/config/get`.
It is excluded from layout, full/space export, import and HA entities. Missing
Store data or a missing wire field projects to `on`.
Compatibility matrix:
| Frontend | Backend | Behaviour |
|---|---|---|
| old | new | Extra snapshot/event are ignored; the marker keeps historical #84/#94 behaviour and config remains intact |
| new | old | Missing snapshot starts `on`; an unsupported toggle command reports an error and creates no optimistic local state |
| new | new | Server snapshot is authoritative; revisioned events synchronize full and static cards |
Every current config writer reconciles the Store. Rename, move, hide and
unrelated edits preserve off bits for still-eligible stable marker ids;
binding/role/action changes, tombstones and deletion prune them. Re-entering the
triple therefore starts on. If `config_rev` skips the revision recorded by the
operational Store — for example after downgrade, an old writer or an interrupted
pair — all off bits are cleared rather than resurrected against unknown marker
lifecycle history. Older integrations safely ignore the separate Store on
downgrade.
+4 -4
View File
@@ -263,7 +263,7 @@ stages a draft prerelease and uploads both assets. Only then does it make the
release public. It locates the Release, HACS-zip and Telegram runs by workflow
file plus exact tag/SHA, verifies the downloaded public asset contents and
paginated HACS prerelease order, and finally closes only the explicitly supplied
issues and moves their Project v2 items to Done. Re-running the same command
issues and strips their status label. Re-running the same command
after a partial failure is safe when local/remote tags still resolve to the same
SHA: stale public assets are replaced and verified rather than accepted or left
for manual deletion. ZIP inspection is implemented in Node and does not depend
@@ -278,9 +278,9 @@ draft-first publication entirely on GitHub, including both assets. Prereleases
are intentionally silent in Telegram; only stable releases are announced.
GitHub exposes a `workflow_dispatch` button only after
the workflow file exists on the default branch; until the next promotion to
`main`, use the local command. The button deliberately does not close Issues or
edit the user-owned Project because the repository `GITHUB_TOKEN` has no safe
authority over that Project.
`main`, use the local command. The button deliberately does not close Issues:
closing them is the release manager's call, and the `close-merged` job does it
from the beta itself (#120).
The older release-event workflows remain supported as a recovery/manual
fallback. They still gate assets on the exact tagged SHA, so adopting the new
+82 -1
View File
@@ -1,4 +1,4 @@
# Isometric Stage 1 internals
# Hidden Isometric View internals
Issue [#89](https://github.com/Matysh/houseplan-card/issues/89) implements a
hidden, presentation-only volumetric View experiment. The normative contract is
@@ -53,6 +53,11 @@ physical bodies have been resolved. It normalizes outer/hole winding, builds one
evenodd top path and at most one visible side per ring edge, then uses a stable
depth/order tie-break. Complexity is O(E) in canonical ring edges.
For connected drafts and partitions those extras already contain computed
bounded junction patches. Isometric wall tops/sides therefore use the same
seamless L/T footprint as flat full/static cards; raw per-record rectangles are
reserved for editor identity and never projected as competing wall faces.
The content fingerprint includes rooms, wall geometry/thickness, open cuts,
openings, partitions, drafts, columns, scale/grid inputs, camera, wall height and
algorithm version. It deliberately excludes `_cfgEpoch`, HA state, hover and
@@ -90,3 +95,79 @@ the rollback path and does not depend on the iso cache.
Golden references are accepted only from the complete reviewed Linux artifact.
The full `large-house-isometric-v1` performance comparison is also canonical on
the exact Linux CI SHA.
## Stage 2 composition (#122)
Stage 2 evolves the same hidden `iso` experiment; it does not add a flag,
setting or public activation path. The accepted implementation contract is
`docs/specs/122-isometric-stage2.md` and the fixed composition decisions are in
`docs/adr/122-isometric-stage2-composition.md`. The Labs `since: 1.62.0` and
exclusive `expires: 1.65.0` boundary are unchanged.
### One structural scene, live opening leaves
The per-card LRU remains capped at eight entries. Its Stage 2 value contains:
- canonical wall top/sides and the physical-wall contact path;
- a room/exterior floor footprint and its low visible outer faces;
- immutable opening jamb/axis bases, including type, flips and selected wall
face;
- the shared projected frame, including wall/opening tops and the low floor
edge.
The key includes rooms, masonry/opening geometry, flips, scale/camera, wall and
edge heights and algorithm revision. It excludes HA state, theme, hover,
day/night and filter capability. `openingAmount()` is applied only after an LRU
hit by `projectIsoOpening()`, so a contact update projects O(O) leaves without
repeating a wall or floor boolean operation.
`floorFootprintGeometry()` deliberately accepts no independent physical-body
input. The slab is the union of room floors and derived exterior masonry:
internal room boundaries and nested holes make no decorative step, detached
room components keep separate outside edges, while partitions and columns do
not enlarge it.
### Layer order and materials
All geometry roots use one scene `viewBox`. The existing floor/live nodes are
grouped under the Stage 1 affine matrix; HTML anchors still use
`projectPlanPoint()`.
```text
stage background
→ shared ambient shadow + low exterior floor edge
→ existing floor SVG (paper/image, room fills/hover, decor, Glow, sun)
→ shared contact and live leaf shadows
→ canonical wall sides/top + inert vertical opening panels
→ existing HTML devices, labels/cards, locks and vacuum overlays
```
Wall top and side use two shared matte gradients. Ambient, contact and leaf
shadows use three shared filters; definition count is constant per card, never
per face or opening. Forced colours use solid `Canvas`/`CanvasText` faces and
omit decoration. A runtime without the required filter paint keeps solid
structure, floor edge and vertical panels but emits no Stage 2 shadows; this
does not enter the structural fallback latch.
### Vertical openings and display settings
`src/iso-openings.ts` mirrors the existing opening-symbol transform algebra:
door has one jamb-hinged leaf, gate has two leaves with the established
0–10° exterior-face turn, and window has two light neutral casements. Heights
are fixed presentation ratios of `ISO_WALL_HEIGHT`; there is no schema field.
Panels/shadows are pointer- and ARIA-inert. Existing lock badges/cards and HA
actions remain the only interactive opening surface.
- borders visible: vertical panels replace the floor-plane symbols;
- `hide_openings: true`: panels and leaf shadows disappear, while masonry
cuts, Glow/sun and contact/lock meaning remain;
- `show_borders: false`: Stage 2 roots are absent and the established floor
symbols and Stage 1 projected frame return (subject to `hide_openings`),
avoiding floating panels or an invisible Stage 2 bound that reframes them;
- Flat, editors and `houseplan-space-card` retain their old symbols and DOM.
Stage 2 adds no window beam, Glow source, sun renderer, material config,
network request or HA service path. Structural topology/projection exceptions
still use the Stage 1 latched Flat fallback. The known independent exact-SHA
view-toggle performance debt remains tracked in #124; #122 neither weakens its
budget nor treats fallback as benchmark success.
+21 -1
View File
@@ -26,7 +26,9 @@ plan geometry and shares it between every lamp in the space.
- the wall bodies exactly as the plan draws them (`wallBodiesGeometry`), with
their real thickness and mitred junctions;
- independent bodies: partitions, columns, room drafts;
- independent bodies: partitions, columns and room drafts. Exact connected
draft/partition segments enter as one joined volume, not as raw rectangles
whose former butt faces could become false barriers;
- the bare outline of any room edge that carries no thickness — a wall is still
a wall when it is drawn as a line.
@@ -107,6 +109,12 @@ set is invisible — the plan simply keeps lighting through a wall that now
exists. The same fingerprint, plus position and radius, keys the per-source
region cache (`_glowClipCache`).
The masonry boolean receives room walls after passage cuts plus the cached
joined independent body set. Its outer/hole rings are the authoritative
barriers for both visibility and the fail-dark source guard. A boolean failure
falls back to the raw independent bodies as opaque obstacles; it never turns a
malformed wall transparent.
## Source, state and service identity
The geometry above consumes `resolvedLightSources()`; it never discovers light
@@ -134,6 +142,18 @@ source even when the marker has no controllable HA entity. That last case is a
- its position, room, colour, brightness and radius belong to the target lamp,
not to the switch which drives it.
There is one explicit manual-authority exception. An active marker with a
`virtual` binding, `is_light: true` (Always) and `tap_action: toggle` reads its
on/off value from the integration's revisioned operational store. Absence is
`on`. While the triple remains active, that value overrides incoming controller
OR for the marker source and therefore reaches every consumer of
`resolvedLightSources()` — Glow, room fill/counts, device presentation, preview
and both card types. Saved controls remain lossless but a tap on this exact
marker performs the operational toggle, never an HA service call. Leaving the
triple restores the normal controller rules and clears any stored off bit;
hiding alone does neither. The operational revision is part of the resolver
cache key, so an event changes the projection without an HA state tick.
The controller picker can therefore link a smart relay to a virtual marker for
a dumb physical lamp. Multiple controllers use OR. A direct entity reference
and a marker reference resolving to the same stateful source are deduplicated.
+5 -1
View File
@@ -1,6 +1,10 @@
# Документация House Plan
Этот каталог — новая точка входа в русскоязычную документацию House Plan. Материалы ниже сверены с исходным кодом и интерфейсом версии **v1.60.0**.
Этот каталог — точка входа в русскоязычную документацию House Plan.
> **Сверено с версией v1.60.0.** Продукт с тех пор ушёл вперёд (линия 1.64);
> при расхождении этого каталога с `docs/USER-GUIDE.ru.md` и changelog верить им.
> Пересверка каталога — отдельная документационная задача.
| Документ | Для кого | Что внутри |
|---|---|---|
+9 -7
View File
@@ -1,16 +1,18 @@
<!-- release: v1.63.0-beta.1 -->
<!-- release: v1.64.0 -->
## Основное
- Удаление маркера датчика или замка больше не разрывает его привязку к двери, окну или воротам.
- Исправлено падение после удаления последнего пространства; из пустого состояния снова можно добавить пространство.
- Режим фона «Следует за Солнцем» теперь создаёт четырёхфазное окружение рассвета, дня, заката и ночи с мягким движущимся светом.
- Рисование комнат и перегородок получило видимые узлы и линии привязки, автоматическое замыкание по существующей стене и бесшовные угловые и T-стыки без задержек на больших планах.
- View и киоск надёжно показывают первый кадр, а постоянные виртуальные источники света переключаются без helper Home Assistant.
- Мелкие исправления и улучшения.
## Highlights
- Deleting a sensor or lock marker no longer breaks its door, window or gate binding.
- Fixed the crash after deleting the last space; a space can again be added from the empty state.
- Follows the Sun now creates a four-phase dawn, day, dusk and night environment with a moving soft light.
- Room and partition drawing now has visible snap nodes and lines, automatic closure along an existing wall, and seamless corner and T-junctions without large-plan delays.
- View and kiosk now render their first frame reliably, while always-on virtual light sources can be toggled without a Home Assistant helper.
- Small fixes and improvements.
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.63.0-beta.1/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.63.0-beta.1/docs/CHANGELOG.md)
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.64.0/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.64.0/docs/CHANGELOG.md)
+2 -3
View File
@@ -5,9 +5,8 @@ not a hardcoded feature. Universal (any house, any language, any plan format),
flexible (options instead of assumptions), and measured against the official
**Integration Quality Scale** even though custom integrations are not formally graded.
Current tasks and priorities live only in
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and the linked
[Project v2](https://github.com/users/Matysh/projects/1); this file keeps
historical engineering direction. The original market rationale is archived at
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and their labels
(`PROCESS.md` §9); this file keeps historical engineering direction. The original market rationale is archived at
`legacy/docs/PRODUCT-2026-07-05.md`.
Phases 0–6 of the original plan (server-side config, room markup editor, device
+10 -11
View File
@@ -10,22 +10,22 @@
> v1.42.0 — the user base is largely Russian-speaking, see the Telegram chat)**, STATUS.md for state changes
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
> ARCHITECTURE.md for design changes. Work scope and status live in GitHub
> Issues + Project v2, not in a parallel backlog document.
> Issues and their labels, not in a parallel backlog document.
**Promotion rule (2026-08-08):** every new feature or material behaviour
change must pass through a published beta/RC before stable. Stable release
commits are promotion-only (versions, generated bundles and release/changelog
metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
## Snapshot (2026-08-13)
## Snapshot (2026-08-14)
| Item | State |
|---|---|
| Version | **v1.63.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate |
| Current local cycle | v1.63.0-beta.1 fixes the empty-plan crash (#111) and preserves explicit opening sensor/lock references after standalone marker deletion (#104). Development after that beta preserves the exterior facade when Split starts or ends at a room corner (#123), using one wall geometry for flat/static/isometric rendering and light. The line also carries the reviewed process automation work #105 and #118–#121. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1 as a hidden, expiring `iso` Labs experiment: a fixed near-top orthographic volumetric View. Flat remains default; editors and `houseplan-space-card` remain flat; all existing floor live effects and HA actions are preserved. This is internal, not a public feature. |
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records and the linked [Project v2](https://github.com/users/Matysh/projects/1) is the canonical priority/status view; both must stay current. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| Version | **v1.64.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable promotion after the published performance-repair beta.3 |
| Current local cycle | v1.64.0-beta.3 is published and #156 is closed after exact-SHA Validate, release and HACS-discovery success. Stable v1.64.0 promotes those identical product changes without product-code edits: runtime consumers no longer build an unused physical-wall union on every floor switch, and initial Plan snap hover updates only its active marker. The owner explicitly authorized #156 implementation and self-review in one Codex session while Claude/status automation was unavailable. The independent #124 view-toggle debt remains open under the owner's accepted exception. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | Prerelease publication requires a green exact-SHA Validate: frontend/backend, smoke (including the #73 rAF frame sampler), golden, HACS/Hassfest and a short absolute-ceiling performance smoke. Obsolete same-ref Validate runs are cancelled. Full seven-sample base/candidate performance moved to `performance.yml` (`main` push, weekly, manual); stable release assets fail closed unless Validate and Full Performance are green for the exact tagged SHA and the stable-only CDP compositor screencast finds no empty/black presented frame. |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
@@ -239,10 +239,9 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
## Open items / watchlist
0. **Canonical backlog** — [GitHub Issues](https://github.com/Matysh/houseplan-card/issues)
contain task scope and acceptance criteria; the linked
[Project v2](https://github.com/users/Matysh/projects/1) contains the
current priority and workflow status. Every open in-scope issue belongs in
the Project. The former local product plan is preserved only as a snapshot at
contain task scope and acceptance criteria; their **labels** carry priority
and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used.
The former local product plan is preserved only as a snapshot at
[`legacy/docs/PRODUCT-IMPROVEMENT-PLAN.ru.md`](../legacy/docs/PRODUCT-IMPROVEMENT-PLAN.ru.md)
and must not be updated or used as a backlog.
1. **hacs/default PR #9004** — accepted by the bot into the review queue ('New default
+3 -3
View File
@@ -162,9 +162,9 @@ On a room shape: `.filled` (a fill mode is painting it), `.styled`,
These exist in the DOM and **will** change without notice. Do not build on
them:
- **Boot and transition classes** — `.hpboot`, `.hpsettle`, `.skysnap`,
`.mode-transition`, `.editorchrome.transitioning`,
`.daynight` on `.stage`. They exist for one animation frame each and their
- **Boot and transition classes** — `.hpboot`, `.hpsettle`,
`.mode-transition`, `.editorchrome.transitioning`, and the four-phase
`.daycycle` / `.phase-*` environment classes. Their
whole job is to be replaced.
- **Editor chrome and previews** — `.dtframe`, `.dthandle`, `.dtknob`,
`.dtbox`, `.dtstem`, `.bdhandle`, `.rszhandle`, `.rszicon`, `.rszframe`,
+77 -24
View File
@@ -1,6 +1,9 @@
# Sun on the plan — the spec (source of truth)
Status: approved by the owner 2026-08-03. Shipped in v1.56.0.
Status: window rays approved 2026-08-03 and shipped in v1.56.0; the
four-phase background was replaced by the owner-approved #146 contract in
2026-08. The current background contract below supersedes the retained legacy
notes explicitly marked as historical.
Scope decisions final: the compass lives in the GENERAL settings with a
per-space override, the feature is silent until `north_deg` is set
anywhere, wedges ship for the FULL card only in v1, and mutual shading
@@ -8,19 +11,18 @@ of the building's wings is explicitly NOT computed.
## Principle
The plan learns where north is, and from that single number plus HA's
own `sun.sun` the card knows where the sun stands relative to every
wall. Two visuals follow: the stage background can breathe with the
day (day → golden hour → dusk → night), and windows on exterior walls
cast soft wedges of light into their rooms. Everything is display
only — no entities are created, no services are called.
Two independent display-only visuals use solar data. The four-phase stage
background follows a valid `sun.sun` sample or browser-local clock fallback and
does not need a compass. Window rays use `sun.sun` plus the plan's north to cast
soft wedges from exterior windows. Neither creates entities nor calls services.
## Data
- Source: the `sun.sun` entity (`attributes.azimuth` 0–360, 0 = north,
- Real source: the `sun.sun` entity (`attributes.azimuth` 0–360, 0 = north,
clockwise; `attributes.elevation` in degrees, negative below the
horizon). No `sun.sun` in the install → the whole feature stays
silent and the settings dialog says why.
horizon; boolean `attributes.rising`). Missing/invalid data sends the
background to local-clock fallback; window rays stay off and the settings
dialog says why.
- `sun.sun` is consumed from the live HA state machine. It is valid for
this core/runtime entity to have no Entity Registry row; registry
projection must preserve its live state while still excluding rows
@@ -42,12 +44,57 @@ only — no entities are created, no services are called.
plain number input sits next to it for accessibility and precision.
- Per-space override in the space settings (empty = inherit), the same
pattern as `show_lqi` / `fill_mode`.
- While `north_deg` is null at BOTH levels the whole sun feature is
inert: static background, no wedges, nothing computed. The settings
dialogs show a hint.
- While `north_deg` is null at BOTH levels window rays are inert and the
settings dialogs show a hint. The four-phase background remains active.
- Backend validation: integer in 0–359 at both levels.
## Plan background — `settings.bg_mode: 'static' | 'daynight'`
## Current four-phase background — `settings.bg_mode: 'static' | 'daynight'`
The public setting remains a two-value selector. `static` uses `bg_color`.
`daynight` paints a decorative environment behind the unchanged plan:
`dawn`, `day`, `dusk`, or `night`.
- A valid real sample is atomic: finite `sun.sun` azimuth and elevation plus a
boolean `rising`. Elevation `<= -6°` is night, `>= +6°` is day, and the band
between them is dawn while rising or dusk while falling. Invalid or missing
required data switches the whole resolver to browser-local clock time; real
and fallback fields are never mixed.
- Clock fallback uses dawn 05:00–08:00, day 08:00–18:00, dusk 18:00–21:00,
and night otherwise. A visible card checks it every 30 seconds, catches up on
`pageshow`/visibility return, pauses while hidden, and removes the timer on
disconnect.
- The soft decorative light follows real azimuth/elevation, or the fixed
05:00→13:00→21:00 fallback arc. It has zero visible opacity at night.
- Four constant environment layers cross-fade for exactly 1100 ms with
`cubic-bezier(.22,.61,.36,1)`. Reduced motion disables the transition.
- Only the environment and the zero-offset alpha-aware outline outside the
grouped plan-paper footprint change. The plan, paper, floors, room fills,
Glow/spill, devices, labels, decor/backdrop, vacuum, hover, and window rays
are never dimmed, tinted, faded, blended, or otherwise phase-filtered.
- Full View, kiosk, and `houseplan-space-card` share the same pure resolver,
palette, outline, and clock lifecycle. Plan/Devices/Decor editors remain
static.
- The background is independent of `north_deg`. North remains required only
for direction-dependent window rays.
New installations materialize global `daynight`; every manually or
Floors/Areas-created space materializes its own `daynight`. The storage v1.2
migration materializes a missing/invalid legacy global token as `static`, so an
upgrade cannot change an existing plan's appearance. Full and per-space
export/import likewise materialize compatibility modes; a per-space export
carries its effective mode and a legacy space import without one becomes
`static` before merge. Runtime's last-resort missing-token fallback remains
`static`.
The exact palette, positioning formulas, migration matrix, lifecycle, and
acceptance criteria are in `docs/specs/146-four-phase-sun-background.md`.
## Historical continuous background (removed by #146)
The remainder of this section documents the pre-#146 elevation-interpolated
implementation for archaeology only. It is not a current runtime or UX
contract: the 45-second sky glide, `skysnap`, compass gate, and night
brightness filter described below were removed.
- Global default in the general settings, per-space override (null =
inherit). Default `'static'`.
@@ -313,15 +360,17 @@ saving General settings removes it.
## Edge cases and limits
- No `sun.sun` → silent feature + a hint in the settings dialog.
- `north_deg` unset everywhere → silent feature + a hint.
- No valid `sun.sun` sample → the background follows browser-local time;
window rays stay off and the settings dialog explains the fallback.
- `north_deg` unset everywhere → the background still works; window rays stay
off and the settings dialog shows the compass hint.
- Any weather state, including rain and snow → no effect on rays.
- `prefers-reduced-motion` → no transitions; colors and wedges render
statically for the current sun position.
- Kiosk mode → works (same view path).
- Static `houseplan-space-card` → the background honours the effective
`bg_mode`/color; wedges are v1 FULL-CARD ONLY (documented limit).
- Editors (plan/devices/decor) → no wedges and no day/night: the
four-phase `bg_mode`/color and fallback lifecycle; wedges are FULL-CARD ONLY.
- Editors (plan/devices/decor) → no wedges and no four-phase environment: the
editor canvases render exactly as before.
- Mutual shading of the building's own wings (an L-shaped house
shadowing its inner corner) is NOT computed — a lit window casts its
@@ -335,9 +384,12 @@ saving General settings removes it.
- `src/sun.ts` — pure logic (angles, day phase, exterior walls, wedge
quads + clipping, the rim edges and its stops, settings
inheritance); unit-tested in `test/sun.test.mjs`.
- `src/houseplan-card.ts` — the memoised wedge layer, the day/night
stage background, both settings dialogs (compass dial included).
- `src/space-render.ts` — the static card's background only.
- `src/day-cycle-render.ts` — the shared constant environment layers and
plan-outline variables for full and static cards.
- `src/houseplan-card.ts` — the memoised wedge layer, four-phase background
lifecycle, and both settings dialogs (compass dial included).
- `src/space-render.ts` / `src/space-card.ts` — static-card environment and
visible-only clock lifecycle.
- `custom_components/houseplan/validation.py` — the three active sun
settings plus the accepted legacy global weather field; tests in
`tests_backend/test_validation.py`.
@@ -359,9 +411,10 @@ saving General settings removes it.
editor, unaffected by legacy weather settings — and a pixel probe on
WHITE paper proving the side of the wedge is measurably darker than the paper on either hand
(the point of the whole change).
- `demo/smoke_sun_live_bg.mjs` — the sky follows `sun.sun` on a plain
`hass` tick with no reload, asserted on the COMPUTED background of the
stage; small steps still glide, big ones catch up at once.
- `demo/smoke_sun_live_bg.mjs` — phases follow real samples on plain `hass`
ticks, the plan remains unfiltered, compass independence and atomic clock
fallback are preserved, and the fallback timer follows visibility.
- `demo/golden/matrix.mjs` — deterministic dawn/day/dusk/night visual scenes.
- `demo/shot_sun_short.mjs` — stills at a low and a high sun
(`node demo/shot_sun_short.mjs <outdir> <prefix>`).
- `demo/shot_sun_rim.mjs` — the rim on white paper and on the dark glow
+79 -12
View File
@@ -1,5 +1,38 @@
# Manual testing checklist
## Правила для новых тестов (issue #85) — обязательны
Зелёный тест в этом проекте несколько раз означал «ничего не проверено»:
смок непрерывности не заметил удаления механизма, который защищает; golden-сцена,
заведённая под #71, была пустой; смок теней был зелёным, пока тени физически не
рисовались. Общее у всех случаев — **тест ни разу не проверяли на способность
падать**. Отсюда правила.
1. **Наличие атрибута, класса или узла — не проверка поведения.** Такой ассерт
допустим только рядом с пиксельным либо поведенческим: атрибут доказывает,
что код выполнился, а не что механизм сработал.
2. **Golden-сцена, заведённая под конкретную задачу, несёт семантический
ассерт** (`warmPixelRegion` и родственные в `demo/golden/run.mjs`): сцена
обязана падать, если перестала показывать то, ради чего заведена. Пиксельный
дифф с эталоном этого не заменяет — пустая сцена совпадает со своим пустым
эталоном идеально.
3. **Фикстура содержит слои, которые тест защищает.** Смок непрерывности без
подложки, Glow и декора проверяет пустую страницу; солнце с азимутом, при
котором луч не достигает единственного окна (#89), — та же ошибка в
геометрии.
4. **Тест, охраняющий механизм, сопровождается мутантом** в
`scripts/mutation-gate.mjs`: 2–5 строк патча, воспроизводящего поломку,
против которой тест заведён, и тест обязан на ней краснеть. Чистым функциям
с обычными юнитами мутант не нужен.
5. **Тавтологический ассерт — читающий то же свойство, которое код только что
выставил, — не пишется вовсе.** Он может упасть только при удалении строки,
но не при её неработоспособности.
Проверка: `node scripts/mutation-gate.mjs --check` — якоря патчей живы;
полный прогон — workflow `mutation-gate.yml`, перед стабильным релизом и по
понедельникам. Дешёвая половина идёт с юнитами: `test/mutation-gate.test.mjs`.
## Device value badge (#90)
- [ ] An untouched legacy thermometer/humidity marker remains pixel-identical;
@@ -329,6 +362,18 @@ separately promised workflows:
- [ ] Ctrl/Cmd+click closes the current endpoint back to the first point without
adding another vertex. It requires at least two existing edges and refuses
degenerate or self-intersecting closure [auto: smoke_editor_tabs]
- [ ] Adjacent-room auto-close: after two drawn edges, ending on the same
uninterrupted solid existing-room wall interval as the first point adds
the terminal segment and opens the normal room dialog. Endpoints and
wall-interior snap points both work; Save creates one room without an
independent partition and preserves the shared wall thickness. Cancel
restores the open draft ending at that point [auto: smoke_room_autoclose;
unit: plan-snap-overlay.test.mjs]
- [ ] Adjacent-room auto-close safety: a second point alone stays open; different
room edges, draft/partition-only axes and points separated by an opening
or open-span cut do not trigger it. An eligible self-intersecting contour
shows the existing error and writes neither the terminal point nor segment
[auto: smoke_room_autoclose; unit: plan-snap-overlay.test.mjs]
- [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name
- [ ] Room dialog uses the medium width and its body has no horizontal overflow;
long options stay inside it at desktop and narrow widths [auto:
@@ -1363,16 +1408,24 @@ require hands on real hardware — they remain for the human pass.
## Sun on the plan (docs/SUN.md)
- [ ] Everything below is INERT until `north_deg` is set (general ⚙ or the
space settings) AND the install has `sun.sun`; both dialogs hint at
whichever is missing
- [ ] Four-phase background resolves strict real `sun.sun` data atomically:
`<= -6°` night, `>= +6°` day, the middle band dawn while rising and dusk
while falling. Missing/garbage elevation, azimuth, or rising switches the
whole sample to local-clock fallback at exact 05:00/08:00/18:00/21:00
boundaries [auto: `test/sun.test.mjs`, `smoke_sun_live_bg`]
- [ ] Background works without `north_deg` and without valid `sun.sun`; the
latter arms one visible-only 30-second fallback timer, stops it while
hidden/disconnected, and catches up on pageshow/visible return. Window
rays independently remain gated by valid sun + compass
[auto: `smoke_sun_live_bg`]
- [ ] The ⚙ compass: dragging the «N» arrow turns it in 1° steps (15° with
Shift); the number field mirrors the dial and accepts 0–359; «Clear»
returns the unset state
- [ ] «Plan background» selector: `static` keeps the existing color picker
and behaviour byte-for-byte; `daynight` hides the picker and the stage
follows the sun — neutral day, warm golden hour, dark night — with a
slow (tens of seconds) transition; the PLAN dims only ~10% at night
and behaviour byte-for-byte; `daynight` hides the picker and shows exact
dawn/day/dusk/night environment tokens. Only environment and outer
plan-paper outline cross-fade for 1100 ms; reduced motion is instant
[auto: `smoke_sun_live_bg`, golden `day-cycle-*`]
- [ ] Opaque plan paper (2026-08-03, owner): the scene background —
`bg_color` or the daynight sky — is visible ONLY around the plan and
NEVER bleeds through it, in view/kiosk/editors and on the static
@@ -1385,8 +1438,8 @@ require hands on real hardware — they remain for the human pass.
empty drawn space has no paper at all. A live resize preview
(`_rszPreview`) moves the paper together with the dragged wall.
Colours: historical white for drawn plans, the theme card background
under an image. At night (`daynight`) the paper dims via the zoomwrap
`brightness` filter only — its alpha stays 1. Pixel-proofed against
under an image. Across all four phases the paper and every plan pixel
remain unchanged: no brightness/tint/opacity/blend filter. Pixel-proofed against
an acid `#ff00ff` background [auto: smoke_bg_color]
- [ ] Per-space overrides (background mode, north, sun-in-windows) inherit
when empty, exactly like show_lqi/fill_mode
@@ -1413,10 +1466,14 @@ require hands on real hardware — they remain for the human pass.
wedge geometry and peak opacity; a legacy `weather_entity` value is ignored
- [ ] Sun geometry recomputes ONLY when the sun attributes or the config
change — an unrelated `hass` tick reuses the memo
- [ ] Editors (plan/devices/decor) render with NO wedges and NO day/night;
kiosk works; the static space-card honours the background mode
(wedges are full-card-only in v1)
- [ ] `prefers-reduced-motion` → no transitions, static colors
- [ ] Editors (plan/devices/decor) render with NO wedges and NO four-phase
environment; kiosk and static space-card share phase/palette/fallback
(wedges remain full-card-only)
- [ ] New install and new manual/Floors spaces materialize `daynight`; legacy
missing global mode migrates once to `static`; full/space transfer
materializes source semantics before preview/apply
[auto: `test_ha_import_export.py`]
- [ ] `prefers-reduced-motion` → no transitions, current phase renders directly
- [ ] Smoke: `node demo/smoke_sun.mjs`; units: `test/sun.test.mjs`;
backend: `tests_backend/test_validation.py` (sun settings)
@@ -2056,6 +2113,16 @@ require hands on real hardware — they remain for the human pass.
source inside masonry lights nothing. Window rays are blocked by the same
bodies. `show_borders: false` changes paint only [auto:
physical-geometry.test; manual visual].
- [ ] **Seamless junctions**: saved draft/partition L corners (right, acute and
obtuse), unequal thickness, endpoint-on-line T and a branch touching a
room wall use one bounded joined body; near-miss, X crossing, malformed
segments and flat free caps keep their documented semantics. The active
rubber-band has the same contour before and after commit, target records
are not split, Plan/View/static/hidden-iso paths agree, clean floor and
light use the joined corner, and preview/render never writes config
[auto: wall-thickness.test, physical-geometry.test,
smoke_wall_junctions, wall-junctions golden scenarios; manual golden
artifact review].
- [ ] **Lifecycle/performance**: an external config revision cancels live
move/rotate state before replacing geometry. Drag preview performs no
polygon boolean work; clean floor and Glow clips are reused until the
+64 -6
View File
@@ -33,7 +33,7 @@ House Plan — локальная интеграция и две Lovelace-кар
12. [Визуальные состояния устройств](#12-визуальные-состояния-устройств)
13. [Заливки комнат и свет](#13-заливки-комнат-и-свет)
14. [Редактор подложки](#14-редактор-подложки)
15. [Солнце и фон день/ночь](#15-солнце-и-фон-деньночь)
15. [Солнце: фон и оконные лучи](#15-солнце-фон-и-оконные-лучи)
16. [Роботы-пылесосы](#16-роботы-пылесосы)
17. [Киоск-режим](#17-киоск-режим)
18. [Статическая карточка пространства](#18-статическая-карточка-пространства)
@@ -263,10 +263,31 @@ desktop: для точного рисования, Resize, модификато
5. Замкните контур кликом по первой точке или `Ctrl` + кликом в любом месте.
Быстрое замыкание доступно после двух нарисованных граней и не сработает,
если замыкающая стена создаёт самопересечение.
Если первая и текущая точки лежат на одном непрерывном сплошном участке
стены существующей комнаты, текущий клик сам добавит последнюю грань,
замкнёт контур общей стеной и откроет тот же диалог. Можно попадать как в
конечный узел, так и в точку на линии. Проём или намеренный разрыв разделяет
участок, разные грани не объединяются, а второй клик после единственной
первой точки остаётся обычным незамкнутым отрезком.
6. В диалоге задайте название, выберите свободную HA-зону либо оставьте в списке «— без зоны —», затем нажмите «Сохранить».
Во время рисования показываются длина сегмента и угол. Углы, кратные 45°, выделяются. Удерживайте `Shift`, чтобы зафиксировать текущий отрезок по ближайшему такому направлению; предпросмотр и поставленная точка останутся на сетке. Новая комната не может частично перекрывать другую, но полностью вложенная «островная» комната поддерживается.
При работе инструментами **Контур комнаты** и **Перегородка** поверх уже
нарисованных стен видны тонкие осевые линии и точки их концов. Увеличенная
точка показывает точное место следующего соединения: конец стены имеет
приоритет, а увеличенная точка посреди линии создаёт T-соединение, не разрезая
существующую стену. В проёмах соединительной линии нет. Для точного наведения и
предпросмотра рекомендуется редактор на компьютере; tap на сенсорном экране
тоже выполняет привязку, но отдельный hover до касания не показывается.
Толстые отрезки, соединённые в такой точке, сразу показываются как одна стена:
прямые и косые углы получают ограниченный mitre/bevel без зуба или щели, а
T-соединение входит в проходящую стену без видимого торца. Это действует уже у
активного rubber-band до клика и сохраняется после клика, в View и на статичной
карточке. Толщина каждого ранее поставленного отрезка остаётся своей; свободный
конец незамкнутой стены остаётся плоским.
Каждый законченный отрезок незамкнутого контура сохраняется сразу. В той же
сессии инструмент продолжит его автоматически; после перезагрузки выберите
кликом один из концов. Замкнутый контур можно сохранить как комнату или выбрать
@@ -337,6 +358,7 @@ Glow и солнечные лучи, но при Resize комнаты оста
| Общая стена | Одна физическая стена между комнатами |
| Чистая площадь | Считается по внутренней грани стен |
| Углы | Соседние тела стен соединяются; на малом масштабе штриховка скрывается, тело остаётся |
| Независимые стыки | Контуры и перегородки с точным общим узлом образуют один ограниченный mitre/bevel; T-стык не дробит исходную стену |
| Соседние одинаковые участки | Нормализуются в один участок при редактировании/оптимизации |
| Виртуальный участок | Не имеет тела и толщины; реальные остатки сохраняют свою толщину |
@@ -495,6 +517,22 @@ House Plan читает реестры устройств, сущностей и
`marker:*` является только внутренней ссылкой плана и никогда не отправляется
в Home Assistant как entity ID.
Если у самой виртуальной лампы одновременно выбраны **Является источником
света → Всегда** и **Действие по нажатию → Переключить состояние**, нажатие
переключает её собственное ручное состояние. Новая лампа начинается
включённой; состояние общее для всех полных и статических карточек, сохраняется
после перезагрузки страницы и Home Assistant и одинаково меняет Glow, заливку
«Свет» и статистику комнаты. Переключать может любой вошедший пользователь
дашборда — права редактора плана для этого не требуются.
Это точное исключение из обычной схемы `controls`. Пока три условия активны,
сохранённые внешние `controls` не вызывают сервисы HA и не переопределяют
ручное состояние, но остаются в настройках без потерь. Смена роли, привязки
или действия возвращает обычное управление; при повторном включении ручного
режима лампа снова начинается включённой. Скрытие сохраняет состояние, удаление
маркера очищает его. Ручное состояние не является entity/helper HA и не входит
в экспорт или импорт плана.
Удаление и скрытие — разные операции. Скрытый маркер можно показать через
**Скрытые и деактивированные**, и он продолжает участвовать в предусмотренных для скрытых
устройств данных. Удалённый маркер там не показывается, не участвует в LQI,
@@ -856,15 +894,20 @@ marker на плане при этом не меняются.
- угловыми ручками по умолчанию сохраняет пропорции, а с `Shift` меняет ширину и глубину независимо;
- поворачивается с шагом 5°, а с `Shift` — свободно.
## 15. Солнце и фон день/ночь
## 15. Солнце: фон и оконные лучи
Для солнечных функций нужны `sun.sun` и направление севера на плане. Общие значения можно переопределить в конкретном пространстве.
Это две независимые функции. Фон **Следует за Солнцем** работает без компаса:
он использует корректные данные `sun.sun`, а при их отсутствии автоматически
переходит на локальные часы браузера. Направление севера и `sun.sun` обязательны
только для оконных лучей. Общие значения можно переопределить в конкретном
пространстве.
### Настройка
1. В общих настройках выберите фон **Следует за солнцем** при необходимости.
2. Укажите север числом 0–359° или поверните стрелку компаса.
3. Включите **Солнечный свет через окна**.
2. Если нужны оконные лучи, укажите север числом 0–359° или поверните стрелку
компаса.
3. При необходимости включите **Солнечный свет через окна**.
4. Для отдельного этажа задайте локальный север/режим/включение либо оставьте наследование.
### Поведение
@@ -883,7 +926,22 @@ marker на плане при этом не меняются.
Луч обрезается чистым контуром комнаты, имеет чёткие боковые границы и плавно растворяется по длине.
Фон день/ночь меняет оттенок вокруг плана по высоте солнца: нейтральный день, тёплый горизонт и тёмная ночь. Сам план ночью затемняется не более чем примерно на 10%. Обычные изменения плавные; после сна вкладки большой разрыв корректируется сразу.
Фон проходит четыре состояния: рассвет, день, закат и ночь. При корректном
`sun.sun` фаза определяется высотой и направлением движения Солнца; fallback по
локальным часам использует интервалы 05:00–08:00, 08:00–18:00, 18:00–21:00 и
21:00–05:00. Переход окружения занимает 1,1 секунды, а при системном уменьшении
движения происходит сразу. Мягкий свет движется по небу и полностью исчезает
ночью.
Меняется только окружение вокруг плана и его внешний полупрозрачный контур.
Сам план, заливки, Glow, устройства, подписи, декор, подложка, пылесос,
подсветка при наведении и оконные лучи не затемняются и не перекрашиваются.
Полная карточка, киоск и статическая карточка пространства показывают одну и
ту же фазу; редакторы остаются со своим обычным фоном.
У новых установок и новых пространств режим включён по умолчанию. Обновление
старой установки сохраняет её прежний статический фон; перенос полного плана
или отдельного пространства также сохраняет выбранную семантику.
Тени от других крыльев здания и внешних объектов не моделируются.
+19
View File
@@ -65,6 +65,15 @@ thinner than 3 CSS px on screen, the shared full/static render policy suppresses
only the hatch so it does not collapse into noise; the solid fill remains. Mitre
joins; bevel when the mitre spike exceeds `MITRE_LIMIT × thickness`.
Independent draft/partition segments keep flat raw quads for editor identity,
but exact endpoint↔endpoint and endpoint↔line nodes add computed join patches
before the presentation union. Each incident ray keeps its own half-depth;
ordinary corners use the same bounded `MITRE_LIMIT = 4` rule and excessive
spikes become bevels. A degree-one endpoint receives no patch and therefore
keeps its flat cap. This topology is render-only: a T does not split or rewrite
the saved target segment. The live open-outline/rubber-band preview calls the
same primitive with saved per-segment thicknesses plus the current field value.
Openings cut the body full-depth; jambs cap the cut; window glass mid-tunnel;
door swing from the **inner face**. Association uses wall direction ≈ opening
angle (mod 180°), then nearest span — never a perpendicular neighbour at a T.
@@ -167,3 +176,13 @@ occlusion even when borders are hidden. A source inside/on a physical body is
fully occluded instead of leaking around its own masonry. The same fail-dark
placement rule applies to window tunnels and exterior door/gate openings;
interior passages remain valid source positions (#92).
`physicalBodySet()` separates raw draft/partition/column bodies from computed
junction patches and their joined geometry. Raw bodies remain authoritative for
hit testing, selection, drag, properties, deletion, history and furniture
magnet semantics. Flat full/static render, hidden isometric, clean floor, Glow,
sun and source placement consume the joined set through the canonical masonry
pass, so an old butt face cannot become a visible seam or a false light barrier.
Exact near-misses remain separate, an interior↔interior X crossing keeps normal
boolean-union semantics, and malformed legacy segments fall back opaque without
writing configuration.
@@ -0,0 +1,93 @@
# ADR #122 — Stage 2 isometric composition
- Issue: https://github.com/Matysh/houseplan-card/issues/122
- Status: accepted for implementation
- Date: 2026-08-14
- Normative spec: `docs/specs/122-isometric-stage2.md`, revision 1
- Predecessor: `docs/adr/089-isometric-stage1-renderer.md`
## Context
Stage 1 established one fixed affine floor projection, canonical wall volume,
screen-facing HTML anchors and a bounded structural LRU. Stage 2 must add matte
depth, an exterior floor edge, grounding shadows and vertical openings without
creating another plan/light model or making HA state rebuild topology.
The owner explicitly allowed implementation to begin with #124's known
view-toggle performance debt still open. That arbitration removes the DoR
blocker only: the unchanged exact-SHA performance budget remains a gate and
fallback may not be forced to make it green.
## Decision
### Structural/live boundary
The existing eight-entry Iso LRU becomes a structural scene cache. Each entry
contains wall faces, floor footprint/edge, immutable opening bases and one
projected frame. The fingerprint includes opening geometry and flips plus the
fixed wall/edge heights and algorithm revision. It excludes HA state and every
decorative capability.
An opening basis stores its resolved wall face, jamb hinge, closed vector,
quarter-turn vector and fixed height bounds. Rendering applies the existing
`openingAmount()` after the cache lookup. Door/window/gate live changes are
therefore O(O) vector projection and cannot invoke polygon boolean operations.
### Floor footprint
The slab source is the canonical union of rooms plus derived exterior masonry.
Independent partitions, drafts and columns remain canonical wall volume and
light occluders but are not accepted as floor-footprint input. Stage 2 projects
only outer rings down by ten plan units; holes and internal/shared boundaries
cannot become steps. Disconnected outer polygons remain disconnected.
### Composition
Four absolute SVG roots share the effective scene `viewBox`: underlay, the
existing floor scene, shadows and wall/opening volume. The old floor nodes are
not copied; they live under the same affine matrix Stage 1 used. HTML overlays
stay above every SVG root.
The stable order is ambient shadow, floor edge, current floor/live layers,
contact/leaf shadows, wall sides/top, vertical panels, then HTML overlays.
All new geometry is pointer/focus/ARIA inert.
### Material and degradation
Two shared SVG gradients distinguish matte wall top and side. Three shared
filters provide ambient/contact/leaf softness. Definitions are O(1) per card;
there are no per-face filters, raster textures, data URLs or runtime
dependencies.
Filter-paint failure removes shadows only. Forced colours also remove gradient
nuance and use solid system colours. Structural wall/opening/edge geometry and
all existing live layers remain Iso. Only a structural topology/projection
exception enters the established `space|fingerprint` Flat fallback latch.
### Opening presentation and settings
- door: one full jamb-hinged leaf, 92% of wall height;
- gate: two half leaves, 88%, preserving the existing 0–10° face-aware turn;
- window: two light inserts between 27% and 78% of wall height.
These are presentation constants, not persisted fields. With visible borders,
vertical panels replace the old floor symbols. `hide_openings` hides panels and
leaf shadows. With borders disabled, every Stage 2 root is absent and the
existing floor symbols return, so there are no floating vertical leaves. The
no-borders branch also keeps the accepted Stage 1 projected frame (floor plus
the fixed wall-height allowance): absent Stage 2 floor/opening geometry cannot
contribute invisible bounds and reframe the live floor. With borders visible,
fit includes every Stage 2 structural bound; blur capability never changes it.
## Consequences
- Flat, editors, static card, schema, storage keys, i18n, backend and HA actions
are unchanged.
- Glow/spill and sunlight remain the only room/window light models.
- Theme, hover, filter fallback and HA-only updates do not grow the structural
LRU.
- Fit includes structural opening/wall tops and the low floor edge, never blur.
- Golden and browser evidence must review new Iso pixels; Flat and the existing
no-borders baseline remain unchanged.
- Stage 2 stays hidden and expires with the existing `iso` Labs entry unless a
separate owner-reviewed rollout/graduation issue changes that contract.
+307
View File
@@ -0,0 +1,307 @@
# Код-ревью issue #107 — переключение виртуального источника света «Всегда» (r1)
- **Issue:** https://github.com/Matysh/houseplan-card/issues/107
- **ТЗ:** [`docs/specs/107-virtual-light-toggle.md`](../specs/107-virtual-light-toggle.md), ревью
[`SPEC-REVIEW-107-r1.md`](SPEC-REVIEW-107-r1.md) — зелёное, High 0 / Medium 0.
- **Диапазон:** `origin/dev...HEAD`, коммит `1079cdfab25617df924b8c3592631aa40e078d87`
("feat: add persistent virtual light toggles"), ветка `issue/107-virtual-light-toggle`.
- **Ревьюер:** Claude (код-ревью ≠ ревью ТЗ, свежая сессия, без контекста реализации).
- **Цикл:** r1/4.
## 1. Скоуп изменения
Backend: новый Store `houseplan.virtual_lights` (`virtual_lights.py`, `const.py`,
`store.py`), новая WS-команда `houseplan/virtual_light/toggle`, дополнение
`houseplan/config/get` полем `virtual_lights`, согласование состояния во всех
писателях конфигурации (`websocket_api.py`, `__init__.py` — миграция при
setup).
Frontend: новый модуль `src/virtual-light-state.ts` (нормализация wire-снапшота,
монотонное применение событий, reconciliation при смене config revision);
интеграция в `resolveToggleIntent`/`resolvedLightSources`
(`device-toggle.ts`, `devices.ts`), в live-sync и localStorage-кэш
(`config-store.ts`, `houseplan-card.ts`), в статическую карточку
(`space-card.ts`, `space-render.ts`); i18n EN/RU; unit-тесты
(`test/virtual-light-state.test.mjs`, дополнения `device-toggle.test.mjs`,
`devices.test.mjs`); backend-тесты (`test_virtual_lights.py`,
`test_ha_virtual_lights.py`, дополнение `test_ha_setup.py`); целевой browser
smoke `demo/smoke_virtual_light_toggle.mjs`; документация (`docs/LIGHT.md`,
`docs/CONFIG-COMPATIBILITY.md`, `docs/ARCHITECTURE.md`,
`docs/USER-GUIDE.ru.md`, `README.md`, оба changelog).
Соответствует заявленной поверхности ТЗ. Продуктовое соответствие
`docs/SCOPE.md`: J1/J3, узкое исключение к замороженным virtual devices, не
пересмотрено ревьюером ТЗ повторно (не входит в код-ревью).
## 2. Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit (frontend) | `npm test` | 781/781 pass |
| Build + сверка бандлов | `npm run build && sha256sum dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js demo/srv/assets/houseplan-card.js` | один и тот же SHA-256 `caffc463…d23bbc89` для всех трёх копий; рабочее дерево осталось чистым (`git status --short` пусто) — сборка воспроизводима и совпадает с закоммиченным бандлом |
| Backend pytest (чистое подмножество, без HA) | `python3 -m venv /tmp/venv-review && /tmp/venv-review/bin/pip install pytest voluptuous && PYTHONPATH=. /tmp/venv-review/bin/pytest tests_backend -q` | **упал сбором** — `tests_backend/test_virtual_lights.py` не собирается без `homeassistant` (см. находку M1). Полный HA-харнесс (`pytest-homeassistant-custom-component`) в этом окружении не установлен и не входит в его canonical Linux CI набор по факту — только офлайн-подмножество, которое теперь не запускается вовсе. `test_ha_virtual_lights.py`/`test_ha_setup.py` разобраны чтением (см. §4) |
| Целевой browser smoke | `node demo/smoke_virtual_light_toggle.mjs` (после `npm run build` и синхронизации трёх копий бандла, Chromium/Playwright уже установлены в среде) | **FAILED (2)**: `clickEverywhereOff: expected true, got false`; `reloadFirstStateOff: expected true, got false`. Остальные 5 проверок (`initialEverywhereOn`, `secondClickEverywhereOn`, `touchSingleToggle`, `oneServerTogglePerGesture`, `noHaServiceCalls`) — true. См. находку H1 |
Не прогонялись (осознанно, вне гейта код-ревью для этого объёма изменений
по PROCESS.md §8): `npm run golden:verify` (изменение не трогает
художественный baseline — новая fixture по плану идёт в golden только на
pre-beta gate), полный набор `demo/smoke_*.mjs` (127 сценариев — задача не
задевает все поверхности, а точечный сценарий уже прогнан выше и провалился),
performance-профили (в AC не назван численный бюджет; §15.4 ТЗ явно откладывает
его на pre-beta). Прогон полного HA backend harness (`pytest-homeassistant-custom-component`)
недоступен в этом окружении — AC2/AC3/AC4/AC8/AC9, доказываемые
`test_ha_virtual_lights.py`, разобраны чтением, не исполнением (см. §4).
## 3. Находки
### H1 (High, блокирует). Собственная презентация маркера (иконка/CSS-класс `on`/`off`) не отражает ручное состояние, когда у того же маркера есть исходящие `controls` — подтверждено исполнением целевого smoke
**Файл:** `src/device-presentation.ts` (не тронут этим PR), проявляется через
`src/houseplan-card.ts` (`_devicePresentation`/`_stateClass`),
`src/space-card.ts`, `src/space-render.ts`.
**Сценарий воспроизведения:** ровно fixture `demo/smoke_virtual_light_toggle.mjs`
— маркер `binding: 'virtual', is_light: true, tap_action: 'toggle', controls:
['light.ceiling']` (эта же тройка + исходящий `controls`, то есть ровно сценарий
AC12). Запуск `node demo/smoke_virtual_light_toggle.mjs` на чистом собранном
бандле даёт:
```
FAILED (2):
- clickEverywhereOff: expected true, got false
- reloadFirstStateOff: expected true, got false
```
**Причина, подтверждена чтением и точечной инструментацией (debug-копия
смока, запущена и удалена, в репозиторий не попала):** `resolvePresentationSources()`
(`src/device-presentation.ts:279-280`) выбирает `sourceKind = 'controls'`,
как только у устройства есть хоть один источник с `via === 'controls'` —
это ветка для «маркер-контроллер показывает лицом состояние того, что
включает». Для точной тройки #107 `marker.controls` — это **исходящая**,
не относящаяся к ролям Auto/Always связь (AC12 требует, чтобы она сохранялась
lossless и не вызывала HA-сервис, но не говорит, что она должна забирать
приоритет у собственного ручного состояния маркера). Проверено
инструментацией: без поля `controls` та же тройка `_stateClass`/иконка
корректно показывает `off` после клика (`sourceKind: 'light'`,
`visualSources: [{"eid":"marker:...","state":"off"}]`); с полем `controls`
`_devicePresentation()` даёт `sourceKind: 'controls'`,
`visualSources: [{"eid":"light.ceiling","state":"on"}, {"eid":"marker:...",
"state":"off"}]` — правильное ручное состояние присутствует в списке, но не
выбрано для лица маркера, поэтому `_stateClass()`/CSS-класс `on` остаются
завязаны на состояние `light.ceiling`, а не на ручной toggle.
Это касается всех трёх типов потребителей презентации маркера
(`_devicePresentation` полной карточки, второй полной карточки, статической —
все три вызывают `resolveDevicePresentation` с одним и тем же
`resolvePresentationSources`), поэтому и `on(first)`, и `on(second)`, и
`staticOn()` остаются залипшими на состоянии `light.ceiling`, а не на ручном
toggle — что и даёт `false` в обеих сравнительных проверках смока.
**Почему это блокирует, а не Low/Medium:** ТЗ §6.2 требует «один и тот же
resolved source обязан дать одинаковый результат» для списка потребителей,
явно включающего «room card и device presentation»; AC5 требует `source.on=
false … во всех canonical light consumers»; AC10/AC12 — это ровно комбинация
(манильный маркер + сохранённые исходящие controls), которую тестирует сам
смок. Реализация проходит AC1–AC9 (см. §4) и логику toggle/persistence, но
для этой явно предусмотренной ТЗ комбинации собственная иконка маркера не
меняется — заявленный сценарий («нажатие выключает его … Одновременно
меняются его Glow и все room-light consumers») не выполняется полностью:
Glow/room fill меняются (подтверждено smoke-проверками `oneServerTogglePerGesture`,
`noHaServiceCalls` и раздельно нижеприведённым разбором devices.ts), а лицо
самого маркера — нет. Это ровно тот класс дефекта, который должен доказываться
исполнением, а не чтением: без прогона smoke эта находка осталась бы
незамеченной, потому что unit-тесты `devices.test.mjs`/`device-toggle.test.mjs`
не строят презентацию через `resolveDevicePresentation()` и поэтому не видят
приоритет `sourceKind`.
**Что не задето:** сама персистентность, атомарность toggle, авторизация,
отсутствие HA-сервисного вызова, поведение без `controls` — всё корректно
(unit-тесты и 5/7 smoke-проверок это подтверждают).
### M1 (Medium, обязан стать отдельным issue). `tests_backend/test_virtual_lights.py` не собирается pytest без установленного `homeassistant`, хотя заявлен как «чистый» тест
**Файл:** `tests_backend/test_virtual_lights.py:1,4`.
**Сценарий воспроизведения:**
```
python3 -m venv /tmp/venv-review
/tmp/venv-review/bin/pip install pytest voluptuous
PYTHONPATH=. /tmp/venv-review/bin/pytest tests_backend -q
```
даёт
```
ERROR tests_backend/test_virtual_lights.py
ImportError while importing test module '.../tests_backend/test_virtual_lights.py'
tests_backend/test_virtual_lights.py:4: in <module>
from custom_components.houseplan.virtual_lights import (...)
custom_components/houseplan/__init__.py:9: in <module>
from homeassistant.components.frontend import add_extra_js_url
E ModuleNotFoundError: No module named 'homeassistant'
Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
```
Это полная остановка сбора — ни один тест `tests_backend/` не выполняется,
включая ранее рабочие чистые файлы вроде `test_validation.py`.
**Причина:** файл заявляет в докстринге «Pure operational-store rules
independent of the HA WebSocket harness» и импортирует модуль обычным путём
`from custom_components.houseplan.virtual_lights import …`. Такой импорт
обязан сначала выполнить `custom_components/houseplan/__init__.py` (пакетный
`__init__`), который безусловно импортирует `homeassistant.components.frontend`
и другие модули HA. Существующий `test_validation.py` (не тронут этим PR)
решает ту же задачу иначе и специально: докстринг «validation.py is loaded by
path, without importing the HA integration package», реализовано через
`importlib.util.spec_from_file_location`, ровно чтобы не тянуть
`homeassistant` для чистых тестов.
`AGENTS.md` фиксирует это же поведение как контракт: «Locally only the pure
subset runs; `python -m pytest tests_backend/ -q` without Home Assistant
silently skips `test_ha_*.py` … so a green result proves nothing» — то есть
не-`test_ha_*` файлы обязаны собираться и выполняться без HA. Этот PR впервые
нарушает инвариант: вместо тихого пропуска — полная остановка сбора для всего
каталога.
**Почему не блокирует:** CI-джоб `backend` (`.github/workflows/validate.yml:206`)
всегда ставит `pytest-homeassistant-custom-component`, так что зелёный проход
в CI не страдает — сами тесты внутри `test_virtual_lights.py` корректны и
проверяют то, что заявлено (см. §4). Ломается только локальный/офлайн путь
без HA, который прежде «доказывал что-то» для остальных чистых файлов.
Соответствует критерию Medium: реальный, воспроизводимый дефект вне
заявленных AC, требует отдельного issue со ссылкой на #107 (тип `tech-debt`,
поверхность — тестовая инфраструктура backend).
## 4. Что проверено и корректно
- **AC1 (eligibility matrix, unit):** `isManualVirtualLightMarker()`/
`eligible_virtual_light_ids()` (frontend `virtual-light-state.ts`, backend
`virtual_lights.py`) требуют точную тройку `binding==='virtual'` +
`is_light===true` + `tap_action==='toggle'` + `removed!==true`; unit-тест
`test_eligibility_is_the_exact_triple_and_hidden_is_not_lifecycle` и frontend
`devices.test.mjs`/`device-toggle.test.mjs` покрывают Auto/Never/wrong-action/
non-virtual/removed — доказано автотестом, тест умеет падать (проверено
изменением условия вручную не потребовалось: тест явно перечисляет все
отрицательные варианты одним assert на множество).
- **AC2/AC3 (persistence, atomic concurrency, backend):** `async_toggle_virtual_light`
вызывается из `ws_virtual_light_toggle` **внутри** `rt.write_lock` — тот же
lock, что использует `ws_config_get`/`ws_config_set`, поэтому конкурентные
toggle-запросы физически сериализуются на уровне WS-хендлера, а не только
внутри `virtual_lights.py`. Backend-тест
`test_invalid_target_and_concurrent_toggles_are_server_atomic` (файл требует
полного HA harness — прочитан, не исполнен: заявленное поведение —
две последовательные server revisions `(1,False),(2,True)` — соответствует
коду `async_toggle_virtual_light`, который читает текущий snapshot, инвертирует
и сохраняет одной операцией под общим локом).
- **AC4 (права, ревью кода):** `ws_virtual_light_toggle` не вызывает `_check_write`/
`may_write` — команда доступна любому аутентифицированному соединению,
соответствует ТЗ §7.4. `not_toggleable` возвращается без изменения store и
без события (`if result is None: connection.send_error(...); return` —
находится **внутри** `async with rt.write_lock`, до `send_result`/`async_fire`).
- **AC5 (canonical `source.on`, unit):** `devices.ts:536-548` — цикл идёт по
ВСЕМ passive-источникам (`if (!source.passive) continue`), сначала считает
обычный OR по `incoming`-контроллерам, затем **безусловно** переопределяет
`source.on` через `virtualLightIsOn()`, если `isManualVirtualLightMarker`
— то есть ручное состояние выигрывает и когда `control` есть, и когда его
нет. `devices.test.mjs` («manual virtual state is canonical and invalidates
the light cache») это явно проверяет для случая с входящим контроллером.
Данный путь работает корректно на уровне Glow/room-графа; H1 — это
отдельный путь (device-presentation), который **не** использует этот
безусловный override напрямую, а получает свой собственный `resolvedLightSources()`
вызов без `virtualLights`, скорректированный через `lightSources:
planLightSources`, что ломается только при наличии собственных `controls`
у того же маркера (см. H1).
- **AC6 (typed intent, unit + ревью кода):** `resolveToggleIntent()`
(`device-toggle.ts:610-632`) возвращает `operation: {kind:'virtual-light',
markerId}` и `command: null` для точной тройки, до общей ветки `controls`
— marker ID никогда не подставляется в `ToggleCommand`/`entity_id`;
`test/device-toggle.test.mjs` («exact manual virtual light wins over saved
HA controls») подтверждает `on.command === null` и стабильность
`sameToggleOperationTargets` при смене направления.
- **AC7 (i18n, диалог):** новые ключи `marker.virtual_light_target/
virtual_light_current/virtual_light_state_on/off` есть в EN и RU
(`src/i18n/en.json`, `src/i18n/ru.json`), formatter в `houseplan-card.ts`
(`~17025-17046`) ветвится по `target.via === 'virtual-light'` до общего
`toggle_hint_current`/`toggle_hint_single` — старый `no_actionable_entity`
hint для этой тройки не строится, так как `resolveToggleIntent` возвращает
ненулевой intent раньше проверки `no-actionable-entity`.
- **AC8 (initial snapshot, backend):** `ws_config_get` вызывает
`async_virtual_light_snapshot` **внутри** `rt.write_lock`, тем же
`config_rev`, что и возвращаемый `config`/`rev` — снэпшот согласован с той
же ревизией конфигурации, на которой backend проверял eligibility (прочитано
в коде; `test_default_toggle_event_and_restart_persistence` подтверждает
восстановление `off` после `async_reload` — прочитано, не исполнено, требует
HA harness).
- **AC9 (config writer lifecycle, backend):** все места, где раньше был
прямой `rt.config_store.async_save({...})` (`_converge_pair`, `ws_config_set`,
`ws_plan_optimize`, `ws_plan_optimize_undo`, миграция в `__init__.py`),
заменены на `async_save_config_state`, которая безусловно вызывает
`async_reconcile_virtual_lights` с явным `previous_rev`/`previous_config_rev`
— единая точка, разрозненных ручных `virtual_light_store.async_save()` не
найдено (`grep` по `websocket_api.py`/`__init__.py` подтверждает единственный
вызывающий путь). Fail-safe на неизвестный revision gap (`state_config_rev
== config_rev` иначе `off = set()`) реализован и в `async_virtual_light_snapshot`,
и в `async_reconcile_virtual_lights` — прочитано, соответствует ТЗ §7.5.
- **AC11/AC13/AC14 (live-sync, confirmation, cache invalidation):**
`config-store.ts`/`houseplan-card.ts` подписываются на
`houseplan_virtual_light_updated` рядом с существующими событиями,
`applyVirtualLightEvent` монотонна по `rev` (тест «events are monotonic and
never optimistic» и «event-before-response ordering never rolls state back»
в `test/virtual-light-state.test.mjs» умеют падать — проверено инверсией
условия мысленно: `rev <= current.rev` иначе `event.on` не проверялся бы).
`virtualLightFingerprint` участвует в ключе `RESOLVED_LIGHT_CACHE`
(`devices.ts`), поэтому событие инвалидирует Glow/room-граф без HA state
tick — подтверждено smoke-проверкой `oneServerTogglePerGesture` (одно
серверное действие на один жест) и фактическим обновлением `staticOn`/`on(second)`
в дебаг-прогоне без `controls`.
- **AC15 (build/bundle parity):** `npm run build` воспроизводим, три копии
бандла побайтно идентичны (см. §2).
- **AC16 (документация):** оба changelog, `README.md`, `docs/LIGHT.md`,
`docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`,
`docs/USER-GUIDE.ru.md` обновлены в том же коммите, ссылаются на #107,
описывают точную тройку, persistence/permissions, исключение из экспорта —
сверено построчно, расхождений с реализацией не найдено (кроме того, что
H1 делает фразу README «updates Glow, room fill/statistics, full cards and
`houseplan-space-card` together» неполной для комбинации с `controls` —
само по себе не отдельная находка, устранится вместе с H1).
- **Трейлеры:** коммит `1079cdf` несёт `Issue: #107`, `User-Visible: yes`,
оба changelog правлены в том же коммите — соответствует.
## 5. Чего не проверял
- Полный HA backend harness (`pytest-homeassistant-custom-component`) —
недоступен в этой среде (не `.venv-backend`, чистый Linux-раннер без
предустановленного пакета). AC2/AC3/AC4/AC8/AC9, чьё единственное
автотест-доказательство — `test_ha_virtual_lights.py`, разобраны только
чтением кода и тестового файла (см. §4), не исполнением. Тесты выглядят
корректными и умеющими падать по структуре (явные значения `rev`/`on` на
каждом шаге, явный `monkeypatch` для forced-failure сценария), но это не
заменяет фактический прогон — Linux CI job `backend` остаётся канонической
проверкой для них.
- `npm run golden:verify`, полный набор из 127 `demo/smoke_*.mjs`,
performance-профили — не прогонялись, обоснование в §2 (объём соразмерен
задаче по PROCESS.md §8; они относятся к pre-beta gate).
- Продуктовое соответствие `docs/SCOPE.md`/выбор J1/J3 — не пересматривалось
повторно, это было предметом ревью ТЗ, а не код-ревью.
- Не проверялся весь возможный матрикс `is_light`/`tap_action` смены онлайн
(rename/move/hidden/tombstone и т.д., AC9 edge cases) сверх того, что
показывает `test_lifecycle_preserves_hidden_and_prunes_when_eligibility_ends`
— тест прочитан и логически согласован с кодом, не исполнен.
## 6. Вердикт
`H1` — реальный, воспроизведённый прогоном собственного целевого smoke-теста
дефект: презентация маркера (иконка/CSS-класс, используемый полной и
статической карточками) не отражает ручное состояние виртуального света,
когда у того же маркера есть сохранённые исходящие `controls` — то есть ровно
в комбинации, которую требует покрывать AC10+AC12 и которую строит сам
`demo/smoke_virtual_light_toggle.mjs`. Это High: блокирует.
`M1` — дефект тестовой инфраструктуры (не блокирует CI, но ломает
документированный локальный офлайн-прогон `pytest tests_backend -q` из
`AGENTS.md` для всего каталога, а не только для нового файла) — Medium,
обязан стать отдельным issue.
Задача возвращается в `S6-in-progress`.
+248
View File
@@ -0,0 +1,248 @@
# Код-ревью #122 — Isometric Stage 2: hidden visual polish (r1)
- Issue: https://github.com/Matysh/houseplan-card/issues/122
- Спецификация: `docs/specs/122-isometric-stage2.md` (SPEC-REVIEW-122-r1: green)
- Диапазон: `git diff origin/dev...HEAD` на коммите `42b3f44` (`feat: add hidden
isometric stage 2`), плюс сопутствующие `76ce755` (ТЗ), `4c73e2c` (ревью ТЗ)
- Роль: ревьюер кода (свежая сессия, без контекста реализации)
- Вердикт: **красный · цикл r1/4 · High: 2 · Medium: 1 → #134**
## Скоуп
Класс файлов коммита `42b3f44`: A (`src/houseplan-card.ts`, `src/iso-openings.ts`
(новый), `src/iso-projection.ts`, `src/iso-walls.ts`, `src/wall-thickness.ts`,
`src/styles.ts`) + B (`test/**`, `demo/smoke_isometric_contract.mjs`,
`demo/smoke_isometric_live_touch.mjs`, `demo/golden/matrix.mjs`,
`tsconfig.test.json`) + C (`docs/ISOMETRIC.md`, `docs/ARCHITECTURE.md`,
`docs/STATUS.md`, `docs/adr/122-isometric-stage2-composition.md`). Класс D
(`dist/**`, `custom_components/houseplan/frontend/houseplan-card.js`,
`demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**`) **не
изменён вовсе** — см. High-1.
Трейлеры на всех трёх коммитах диапазона корректны: `Issue: #122`,
`User-Visible: no` (фича скрытая, публичного changelog не требует — верно).
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | green, без вывода |
| Unit | `npm test` | green, 766/766 |
| Build | `npm run build` | green, `dist/houseplan-card.js` собран |
| Синхронизация 3 копий бандла | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и то же для `demo/srv/assets/houseplan-card.js` | **FAIL** — расходятся, byte 45, `git diff --stat` 327 insertions / 209 deletions на файл; см. High-1 |
| Целевые браузерные смоки (названы в AC1/AC5/AC6/AC9/AC10/AC11 и напрямую тронуты диффом) | `node demo/smoke_isometric_contract.mjs`, `node demo/smoke_isometric_live_touch.mjs` — **после** `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js`, т.к. коммит не обновил эту копию | оба green, все проверки в JSON-выводе `true` |
| `npm run golden:verify` (диф меняет рендер/геометрию/слои — попадает в критерий «по необходимости») | `npm run golden:verify` (после того же build+copy) | 46/53 сценариев `passed`; 7 `different`: 5 ожидаемо-разных iso-сценариев без принятых эталонов (не блокер сам по себе, эталоны Stage 2 умышленно не принимаются на этом этапе — §12.3 ТЗ), но **2 сценария обязаны были остаться пиксель-идентичными и не остались** — см. High-2, Medium-1 |
| Дифференциальная проверка причинности (A/B) | те же две команды (`npm run build`, `npm run golden:verify`) в `git worktree add /tmp/dev-check origin/dev` | `isometric-no-borders-dark` и `large-house-zoom-250-dark` оба `passed` на `origin/dev` — регрессия строго локализована в этом диффе, не окружение |
| Backend | не прогонялся | правок в `custom_components/**/*.py` нет — не применимо |
| Performance (`large-house-isometric-v1`) | не прогонялся | по ТЗ §10/§12.4 и решению владельца это pre-beta гейт, заблокированный отдельно #124; в этом ревью не требуется |
**Чего не проверял и почему:** полный набор из 127 браузерных смоков (диф не
задевает весь продукт — только iso-поверхность и косвенно `wall-thickness.ts`);
`python -m pytest tests_backend` (без py-правок); performance-профиль (вне
гейта код-ревью, зависит от #124); принятие golden-эталонов (не роль
ревьюера и не требуется до pre-beta).
## Находки
### High-1 — три копии бандла не пересобраны; CI уже красный на этом SHA
Коммит `42b3f44` не трогает `dist/houseplan-card.js`,
`custom_components/houseplan/frontend/houseplan-card.js` ни
`demo/srv/assets/houseplan-card.js` — все три остаются побитово равны
промоушен-коммиту `a282f85` (`build: promote v1.63.0`), то есть Stage-1-коду
без единой строки Stage 2.
Воспроизведение:
```
$ npm run build
$ cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js differ: byte 45, line 1
$ git diff --stat dist/houseplan-card.js
dist/houseplan-card.js | 536 ++++++++++++++++++++++++++++++-------------------
1 file changed, 327 insertions(+), 209 deletions(-)
$ grep -c "invalid isometric opening input" dist/houseplan-card.js # fresh build
1
$ git show HEAD:custom_components/houseplan/frontend/houseplan-card.js | grep -c "invalid isometric opening input"
0
$ git show HEAD:demo/srv/assets/houseplan-card.js | grep -c "invalid isometric opening input"
0
```
Независимое подтверждение — CI Validate уже прогонялся на этом самом SHA и
уже красный ровно на этом шаге:
```
$ gh run view 31746904479 --repo Matysh/houseplan-card
X frontend in 36s
✓ Build
X Card bundle snapshots in sync
```
Хендофф-комментарий исполнителя утверждает «три отслеживаемые bundle-копии
byte-identical» — это не так; либо команда не выполнялась, либо результат
интерпретирован неверно. Смысл дефекта не формальный: реальный
`custom_components/houseplan/frontend/houseplan-card.js` (то, что грузит HA) и
`demo/srv/assets/houseplan-card.js` (то, что видит демо-стенд и что реально
тестируют браузерные смоки/golden в CI до пересборки) сейчас **не содержат
Stage 2 вовсе**. `smoke`/`golden`/`performance_smoke` в CI зависят от
`frontend` (`needs: frontend`) и поэтому на этом SHA не запускались вообще —
ни один из пре-релизных гейтов не имеет зелёного прогона против настоящего
кода задачи.
Блокирует. AC16 не выполнен.
### High-2 — `show_borders:false` в Iso не сохраняет пиксель-идентичный Stage-1 «no-volume» сценарий
ТЗ §6.6 и ADR «Consequences» требуют: при `show_borders:false` в Iso
Stage-2-геометрия отсутствует целиком, и AC7/AC15 требуют, чтобы
`isometric-no-borders-dark` остался неизменным. Golden показывает
значительную регрессию:
```
$ npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
$ npm run golden:verify
different isometric-no-borders-dark
```
Из `artifacts/golden/golden-report.json`:
`differingPixels: 169919, diffRatio: 0.2194 (≈22%), maxObservedDelta: 206`.
Повторный прогон — идентичные числа (детерминированно, не флейк). На
`origin/dev` (та же команда, тот же `git worktree add /tmp/dev-check
origin/dev`, тот же Chromium 151.0.7922.34) сценарий `passed` чисто.
Причина по коду (`src/houseplan-card.ts:4503-4522`, `_isoScene()`):
рамка проекции теперь всегда строится с
`floorDepth: ISO_FLOOR_EDGE_HEIGHT` и объединяется с `isoOpeningBounds()` через
`unionRect(flat, openingFrame)` **независимо** от `disp.showBorders` /
`resolveIsoDecoration().structural`. В Stage 1 рамка включала только
`wallHeight`; Stage 2 добавляет глубину пола и границы проёмов всегда, даже
когда сама геометрия стен/проёмов/края пола не рисуется (`structural=false`).
Это меняет коэффициент "сжатия" `floorView`, и весь план визуально сдвигается/
масштабируется иначе, чем в принятом Stage-1 baseline — то есть нарушение не
косметическое, это другой кадр.
Похоже на конфликт двух требований ТЗ: AC12 хочет единую рамку, не зависящую
от переключения `show_borders` (чтобы zoom не менялся), а AC7/AC15 хотят
байт-в-байт то же изображение, что и Stage 1, где такой рамки не было. Решить
это — работа автора (например, включать `floorDepth`/`openingFrame` в рамку
только когда `structural===true`, и объяснить в ADR, почему AC12 всё равно
держится). Ревьюер такие продуктовые компромиссы не решает.
Блокирует. AC7 и AC15 не выполнены буквально.
### Medium-1 — регрессия пикселей во Flat-сценарии при выключенном Labs → #134
`large-house-zoom-250-dark` (без `labs`/`projection` в `demo/golden/matrix.mjs`
— чистый Flat, Labs неактивен) на `origin/dev` проходит чисто (`passed`), на
этой ветке — нет:
```
different large-house-zoom-250-dark
```
`differingPixels: 920, diffRatio: 0.00105, maxObservedDelta: 201` —
воспроизведено дважды подряд с идентичными числами (не флейк, не дрейф
Chromium: версия в обоих прогонах `151.0.7922.34`).
Это прямое нарушение AC2 («with Labs inactive... Flat... retain their
existing state/action/pixel contract») и инварианта из `docs/ISOMETRIC.md`
(«existing Flat DOM and pixels remain the reference»). В рамках бюджета этого
ревью не удалось локализовать точный узел DOM/CSS, отвечающий за эту
конкретную (небольшую по площади, но не нулевую и стабильную) разницу;
кандидат — новая безусловная обёртка `<g class=${iso ? 'iso-floor-scene' :
nothing} transform=${iso ? isoFloorMatrixCss() : nothing}>` вокруг содержимого
плана в `src/houseplan-card.ts` (добавляется в разметку независимо от того,
активен ли `iso`), но это не доказано построчно — отметка «предположение,
требует дальнейшего разбора автором», а не факт.
Площадь и видимое воздействие малы (0.1% пикселей одного сценария на большом
зуме), поэтому находка не расширяет цикл #122 сверх уже блокирующих High-1/
High-2, но контракт заявлен как безусловный и находка воспроизводима —
заведён отдельный issue #134 со ссылкой на #122, а не оставлена как TODO
в этом документе.
## Что проверено и корректно
- **AC1** (`unit`+`smoke`+код): `iso` остаётся единственным Labs id;
`demo/smoke_isometric_contract.mjs` подтверждает Flat как дефолт,
`isometric-contract.test.mjs` подтверждает отсутствие второго флага/ключа —
прочитано и прогнано, зелёно.
- **AC5/AC6** (открывающиеся элементы): `src/iso-openings.ts` —
`buildIsoOpeningBasis`/`projectIsoOpening` алгебраически повторяют
существующий floor-symbol (поворот на 0–10° для gate, симметричные leaves
для window, один leaf с шарниром для door); `test/iso-openings.test.mjs`
проверяет неподвижность базиса при live-изменениях, `flip`-инверсию базиса,
совпадение `unavailable`≡no-contact через `openingAmount()`, границы через
`isoOpeningBounds`. Панели помечены `aria-hidden="true" pointer-events="none"`
(`src/houseplan-card.ts`, рендер `_renderIsoWalls`) — проверено чтением и
подтверждено смоком `live_touch` (`touchOpeningIsSafe`, `flatIsoActionParity`).
Тесты умеют падать: `assert.deepEqual(gateBasis.leaves.map(l =>
Math.abs(l.turnDeg)), [10, 10])` красится при любой правке угла;
аналогично для высот leaf.
- **AC4** (floor edge): `buildIsoFloorGeometry`/`floorFootprintGeometry` —
внутренние/общие границы и вложенные дырки не создают ступень (тест
«canonical adjacent room union has no edge on its shared boundary», «floor
edge follows outer components without internal or nested steps»),
независимые partition/column не расширяют footprint (тест «Stage floor
footprint excludes detached independent physical bodies»), стабильность к
порядку/winding подтверждена отдельным тестом. Прочитано и прогнано зелёно;
тесты специфичны настолько, что удаление одной внешней грани красит их.
- **AC8/AC14** (отсутствие второго свето-слоя, отсутствие новых сетевых/
конфигурационных путей): `isometric-contract.test.mjs`
(«Stage 2 adds no schema, dependency, storage, network or HA action
surface») грепает исходники на `localStorage|fetch|XMLHttpRequest|
WebSocket|callService|config|schema` и `three|babylon|webgl` — прогнано,
зелёно. `src/styles.ts` не содержит нового источника света, только матовые
градиенты/тени — прочитано.
- **AC9** (структурный кэш не растёт от HA/темы/hover): фингерпринт в
`_isoSource()` включает `rooms, walls, openCuts, openings (только
геометрия/флипы), partitions, roomDrafts, columns, cellCm, gridPitch,
wallKeyPitch, camera, wallHeight, floorEdgeHeight, algorithm: 3` и не
включает HA-состояние/тему/hover — подтверждено и тестом
`isometric-contract.test.mjs` («structural cache includes opening flips and
excludes live HA amount», прямая проверка исходного текста `_isoSource`
на отсутствие `_openingAmt|openingAmount|.hass|matchMedia|CSS.supports|
theme|hover`) и смоком `live_touch` (`sameWallFingerprint`,
`haUpdateReusesGeometry`, `contactUpdateMovesOnlyLivePanel`).
- **AC11** (деградация): `resolveIsoDecoration` — чистая функция, тест
«decoration degradation never removes structure or creates floating
panels» покрывает все 4 комбинации `filtersSupported`/`forcedColors`/
`hideOpenings`/`showBorders`; `src/styles.ts` содержит
`@media (forced-colors: active)` и `@supports not (filter: blur(1px))`,
оба убирают только тени/нюанс материала, не геометрию. Прочитано, логика
соответствует ТЗ.
- **AC13** — намеренно не проверялось: и по ТЗ (§10/§12.4), и по решению
владельца это остаётся pre-beta гейтом, заблокированным #124; хендофф
честно пишет «не запускались».
- Трейлеры коммитов, `User-Visible: no`, отсутствие изменений в
`custom_components/**/*.py`, i18n и schema — проверено чтением диффа,
корректно.
## Чего не проверял
- Полные 127 браузерных смоков — диф касается только iso-поверхности и
`wall-thickness.ts`; прогнаны только два целевых, названных в AC и прямо
изменённых в этом диффе.
- `python -m pytest tests_backend` — нет изменений в Python.
- `large-house-isometric-v1` performance — вне гейта код-ревью при открытом
#124 (см. ТЗ §10, решение владельца).
- Принятие golden-эталонов — не роль ревьюера; данные из
`artifacts/golden/golden-report.json` использованы только как
диагностика причинности регрессии, не как повод принять/отклонить
baseline.
- Точная DOM/CSS-причина Medium-1 не изолирована построчно — честно оставлено
как открытый вопрос автору в #134.
## Итог
Две High-находки блокируют цикл r1: код не собран в те артефакты, которые
реально исполняются (High-1, CI уже подтверждает это независимо), и Stage 2
в текущем виде ломает пиксельный контракт, который ТЗ объявляет безусловным —
`show_borders:false` в Iso (High-2). Третья находка (регрессия чистого Flat)
воспроизводима, но мала по воздействию — заведена как Medium #134, а не
оставлена как TODO. Работа предметно сильная — покрытие юнит-тестами
геометрии открытий и floor edge выдерживает мутации, изоляция
структурного/live кэша сделана аккуратно и подтверждена и тестами, и
смоками — но AC16, AC7 и AC15 не могут считаться выполненными до
пересборки бандла и разбора причины пиксельной регрессии `show_borders:false`.

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