Compare commits

...
Author SHA1 Message Date
claude[bot] 4b46a62503 docs: review document for #154
Validate / changes (push) Successful in 48s
Validate / provenance (push) Successful in 55s
Validate / smoke (push) Skipped
Validate / process-gate (push) Successful in 54s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Validate / frontend (push) Skipped
Validate / golden (push) Skipped
Issue: #154
User-Visible: no
2026-08-18 19:31:44 +00:00
Sergey Matyunin cd3c7752a8 docs: specify touch hover reset
Issue: #154
User-Visible: no
2026-08-15 03:55:30 +03:00
Sergey Matyunin 9f2c5f5ff4 build: promote v1.64.0 after beta.3
Validate / performance_smoke (push) Failing after 10m18s
Validate / provenance (push) Successful in 57s
Validate / process-gate (push) Failing after 1m6s
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 14m19s
Validate / golden (push) Failing after 12m25s
Validate / backend (push) Failing after 13m5s
Validate / smoke (push) Failing after 29m32s
Validate / changes (push) Successful in 44s
Full Performance / performance (push) Failing after 1h24m44s
Issue: #153
User-Visible: yes
2026-08-14 23:01:44 +03:00
Sergey Matyunin 20d7883699 Release v1.64.0-beta.3 candidate
Issue: #156
User-Visible: yes
2026-08-14 22:31:36 +03:00
Sergey Matyunin 6ebf12af1e docs: self-review performance repair
Issue: #156
User-Visible: no
2026-08-14 22:28:11 +03:00
Sergey Matyunin 09143e23a6 perf: remove v1.64 render regressions
Issue: #156
User-Visible: yes
2026-08-14 22:27:29 +03:00
Sergey Matyunin 0e6cb7570b docs: specify v1.64 performance repairs
Issue: #156
User-Visible: no
2026-08-14 22:19:50 +03:00
Sergey Matyunin 321d153c22 fix: ignore published main commits during dev reconciliation
Issue: #155
User-Visible: no
2026-08-14 21:25:45 +03:00
Sergey Matyunin 7f70b64f48 Merge main into dev for v1.64.0 2026-08-14 21:10:39 +03:00
Sergey Matyunin 6c37cd5f05 build: promote v1.64.0
Issue: #153
User-Visible: yes
2026-08-14 21:01:17 +03:00
Matysh 7642c484d2 feat: analysis proceeds on its own, questions are product-only and spec-stage
The analyst used to ask the owner to confirm every estimate and waited for an
answer on each point. Most issues are unambiguous, and most of that waiting
changed nothing — the owner's own measure is that seven issues in ten should
travel from S1-new to a finished spec without a single question.

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

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

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

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

WSL runs of the full harness and golden verify are advisory. The canon does not
move: the beta gate is CI at the exact SHA, and baselines are accepted only via
golden:accept --reviewed on a complete Linux CI artefact.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Issue: #135
User-Visible: no
2026-08-14 10:10:34 +03:00
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 7c1edbfa9b ci: realign the workflow copy in dev with main
The two copies of this file must match byte for byte; a comment line had drifted
by one character. main is the copy the issues event actually reads, so it is the
reference. Trivial in itself, and worth closing anyway: the file's own header
warns that a divergence between these two branches is one of the ways this
pipeline fails quietly.

Issue: #114
User-Visible: no
2026-08-13 20:53:35 +03:00
Matysh 024cdc0d94 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:49:04 +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 4e539b02df 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:36:25 +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 948f2848dd docs: a failed pre-release gate does not send the issue back to review
The implementation loop runs typecheck, unit and build. Golden, browser smokes,
performance and the full HA harness run before a beta — after the code review has
passed and the issue already sits in S8-merged. Some defects cannot surface any
earlier, and until now the process had nothing to say about them, so the honest
reading was a second full review cycle at the most expensive possible moment.

The owner's decision: fix it, re-run what failed, and a green run carries the
release on. The gate named the defect precisely and the same gate proves the fix,
so the check is objective and depends on nobody's judgement.

The boundary is written down with it, because "the gate found something" could
otherwise absorb an arbitrary amount of new work. A fix that changes a behaviour
contract, reaches an untouched subsystem or rivals the task in size goes through
the normal flow. Editing a test so it stops failing is concealment rather than
repair — the exception is a defect proven to be in the fixture, as on #89.

The rule also records what it costs: the author judges his own work here, which
the process refuses everywhere else. That is the price of speed at the one point
where a review cycle is dearest, and the compensation is that the re-run command
and its result are written into the issue where the release manager reads them.

Issue: #114
User-Visible: no
2026-08-13 19:36:39 +03:00
Sergey Matyunin 3270e039d8 test: accept v1.63.0-beta.1 golden baselines
Validate / provenance (push) Successful in 44s
Validate / process-gate (push) Failing after 2m46s
Validate / hassfest (push) Failing after 13s
Validate / hacs (push) Failing after 13m3s
Validate / frontend (push) Successful in 7m54s
Validate / backend (push) Failing after 8m33s
Validate / golden (push) Failing after 9m58s
Validate / performance_smoke (push) Failing after 9m24s
Validate / smoke (push) Failing after 26m9s
Issue: #89
User-Visible: no
Release: v1.63.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/31709903117
2026-08-13 17:30:50 +03:00
Sergey Matyunin 8e6b6c7ee3 Release v1.63.0-beta.1 candidate
Issue: #89
Issue: #104
Issue: #111
User-Visible: yes
2026-08-13 17:22:39 +03:00
Matysh dbe12f1a54 docs: state the invariant the pipeline was missing
A review run always moves the label. The rule is written down because its absence
cost a real stall: a green code review whose merge conflicted left the label alone,
the waiting author polled thirty times and reported the limit as exhausted, and a
verdict that existed reached nobody.

Both documents now say what S6-in-progress means when the verdict was green and
only the merge failed — rebase, not rework, and the verdict still stands. They also
say that a label which did not change means the run failed rather than the work, so
the answer is logs and the owner, not more polling. Cycles are counted per stage.

Issue: #114
User-Visible: no
2026-08-13 17:13:54 +03:00
Matysh 1e9952db35 fix: a review run always moves the label, conflict or not
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 17:03:43 +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
Sergey Matyunin 39f5312f97 Merge issue #89 into dev
Issue: #89
User-Visible: no
2026-08-13 16:44:59 +03:00
claude[bot] cc3b0f12f2 docs: review document for #89
Issue: #89
User-Visible: no
2026-08-13 13:34:19 +00:00
Matysh 316ee76a29 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:21:33 +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
Sergey Matyunin 7a2577dba0 test: align isometric sunlight fixture
Issue: #89
User-Visible: no
2026-08-13 16:14:16 +03:00
Matysh 869fe169d8 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:13:13 +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
claude[bot] e0ddbcd79e docs: review document for #89
Issue: #89
User-Visible: no
2026-08-13 12:53:16 +00:00
Sergey Matyunin 6ea3ebff17 test: complete isometric stage 1 gates
Issue: #89
User-Visible: no
2026-08-13 15:28:19 +03:00
Sergey Matyunin 22e98c5555 ci: mark the pre-push hook executable
Git skips a hook without the bit and says nothing about it, so the gate
would have reported success by being absent. The API cannot set the mode:
a file pushed that way arrives as 100644.

Issue: #121
User-Visible: no
2026-08-13 15:11:20 +03:00
Matysh d38a5be68b docs: drop the second status dictionary and the stale class note
docs/specs/README.md kept a "Статус ТЗ" column with its own vocabulary — draft,
in implementation, done — next to the labels that already hold the status. Two
dictionaries for one fact drift apart, and these had: the column still called
issues "in implementation" that were closed weeks ago. The table now says only
which issue a spec belongs to.

AGENTS.md was telling agents that PROCESS.md §1 does not cover package.json and
the rest of the configuration, and to report it as missing. It covers them now.
The same paragraph gained the rule that D beats A where paths overlap, which is
what keeps the built bundle under custom_components/houseplan/frontend/ from
reading as product source.

Issue: #119
User-Visible: no
2026-08-13 15:02:42 +03:00
Matysh 42335bc16d ci: add the pre-push gate and stop lying about it in the canon
Section 10.1 promised pre-push as the blocking gate that replaces pull requests.
The hook did not exist, so the document promised a check that was not there —
worse than saying nothing, because a promise like that gets relied on. Until now
process-gate ran only as the catch-up job in CI, which reports after the code is
already in dev.

The hook skips branch deletions and tags, and for a branch the remote has not
seen it measures from the merge-base with dev rather than from the root, or every
violation committed before the gate existed would make it impossible to pass. A
missing script does not block a push: old checkouts and worktrees have to stay
usable.

gh is optional on purpose. Reading issue status needs the network, and a hook
that cannot work on a train is a hook people switch off; offline it runs what it
can and CI does the strict pass.

The executable bit is the quiet part. Git skips a hook without +x and says
nothing — the gate reports success by being absent. Measured on a real push:
mode 644 produces zero lines from the gate and the push goes through, 755 stops
it. The API cannot set the bit, so install-hooks restores it on every install.

Issue: #121
User-Visible: no
2026-08-13 14:58:57 +03:00
Matysh a36b3129f6 ci: close the S8-merged queue when a beta is published
PROCESS.md 10.2 item 10 asks for this to happen because a beta shipped, not
because someone remembered. The manual cleanup was skipped twice and both times
it broke the invariant that a closed issue carries no status label — the one
thing `verify` leans on. A manual step that falls due right after a successful
release is the worst kind: the work already looks finished, which is precisely
why it gets forgotten.

The job comments the tag, removes the label, then closes. That order is
deliberate: dying between the two steps leaves an open issue without a status,
which is visible and fixable in the flow, where the reverse order would recreate
the breakage this exists to prevent. It ends by asserting that no closed issue
still carries S8-merged — aimed at the defect that actually recurs rather than at
the invariant in general.

The stock token is used on purpose. Events caused by GITHUB_TOKEN do not start
workflows, so stripping the label cannot wake the review pipeline; a PAT here
would turn bookkeeping into a cascade.

Issue: #120
User-Visible: no
2026-08-13 14:43:32 +03:00
Sergey Matyunin 0ef900a3ae feat: integrate isometric labs renderer
Issue: #89
User-Visible: no
2026-08-13 14:40:31 +03:00
Matysh 8cecaf2c5e fix: judge the branch rule only by the branch's own commits
Check 2 compared the Issue trailers against whatever branch the working tree
happened to be on, over whatever range it was given. Those two are not the same
set. After a rebase the CI range widens — `before` points at a discarded commit,
the merge-base slides back, and commits that belong to dev arrive carrying other
issue numbers. Every one of them then looks like a violation.

Running the gate over real history from issue/89 with a dev range produced 26
false refusals out of 26 commits, which would have reddened Validate on the next
force-push of any task branch.

The rule now reads origin/dev..HEAD for its own verdict and leaves the event
range to the other checks. A commit that genuinely carries the wrong trailer for
its branch is still caught; the integration test covers both directions.

Issue: #105
User-Visible: no
2026-08-13 14:30:13 +03:00
Matysh 5aa8771dc3 docs: bring the process canon back in line with what actually runs
The canon moved into the repository in #112 and then stood still while the
process kept moving. A document that lags is worse than no document: an agent
reading it as truth acts on rules that no longer exist. It promised a pre-push
hook that was never written, named labels in Russian that the repository has
never used, listed a status set the gate no longer applies, and said nothing at
all about the event-driven pipeline — the largest mechanism the process has.

Label names are now English throughout and S8-merged is documented. Section 1
covers the configuration files the gate kept reporting as unclassified, and
records that D beats A where paths overlap, since the built bundle lives inside
custom_components/houseplan/frontend/. Section 10.1 admits that pre-push does
not exist. Section 10.2 matches ALLOWED_STATUS in scripts/process-gate.mjs,
including the two caveats that only surfaced once the pipeline ran. Section 10.4
is new and documents the four silent-failure traps that cost a working day each.

The source-of-truth order now says that actual automation outranks its own
description — this document included.

Issue: #119
User-Visible: no
2026-08-13 14:24:50 +03:00
Sergey Matyunin 02502c990c feat: add labs and isometric geometry core
Issue: #89
User-Visible: no
2026-08-13 14:22:30 +03:00
Sergey Matyunin a841d85e40 docs: decide isometric renderer architecture
Issue: #89
User-Visible: no
2026-08-13 14:12:12 +03:00
claude[bot] 6d61529168 docs: review document for #89
Issue: #89
User-Visible: no
2026-08-13 11:05:29 +00:00
Sergey Matyunin f87d71ac18 docs: infrastructure-only work runs outside the product flow
Practice had already diverged from the documents: #105, #112, #114 and #116 were
all done without a spec and without review, and that was right. Nothing said it
was allowed.

The test is mechanical — not a single class A file — rather than left to the
executor's judgement, because a loose reading is exactly how product changes
would learn to skip review.

Issue: #118
User-Visible: no
2026-08-13 14:02:24 +03:00
Sergey Matyunin 7ba2de7c89 ci: process gate as a script and a Validate job
PROCESS.md 10.2 describes scripts/process-gate.mjs; the script never existed.
Commits go straight to dev without PRs and GitHub blocks nothing on its side, so
until now the only thing standing between the process and rule #1 was the good
faith of whoever was committing. Hooks catch a violation on the author's machine
but --no-verify walks past them; this job is the catch-up pass that cannot be
skipped locally.

Checks 1-7 offline, 8 through gh, plus the escalation of check 3: a class A
commit with neither a spec file nor the `small` label is a failure, not a
warning. Check 8 is fail closed — an unreachable or closed issue is a refusal,
never a silent pass.

Two things surfaced while wiring it up and are recorded in the script header.
S8-merged had to join the allowed statuses: the pipeline merges into dev before
it moves the label, so Validate reads the issue already advanced and a strict set
would redden every accepted task. And the status question now applies only to
class A/B commits — asking it of a review document would fail every time, since
that document lands while the issue sits in S4-spec-review or S7-code-review.

Issue: #105
User-Visible: no
2026-08-13 14:01:05 +03:00
Sergey Matyunin 74b08df88c docs: revise isometric stage 1 specification
Issue: #89
User-Visible: no
2026-08-13 13:53:54 +03:00
Sergey Matyunin 9f02d88b42 docs: the author waits for the verdict instead of ending the session
Review fires from the label and runs on its own, but nothing was picking the
result up: the author reported "handed over for review" and stopped, so the
conveyor stalled until the owner said a sentence. The author now polls the label
and continues from whatever it became.

Also drops the merge-into-dev standing permission: the pipeline does the merge
before setting S8-merged, so a hand merge would race it.

Issue: #114
User-Visible: no
2026-08-13 13:36:25 +03:00
Sergey Matyunin c18224cdd2 ci: sync the merge-before-label change into dev
Issue: #114
User-Visible: no
2026-08-13 13:24:39 +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
Sergey Matyunin ee2357b914 fix: keep opening HA references after marker deletion
Issue: #104
User-Visible: yes
2026-08-13 13:11:57 +03:00
Sergey Matyunin 9e176aa1d7 docs: finalize opening reference review decisions
Issue: #104
User-Visible: no
2026-08-13 13:11:57 +03:00
Sergey Matyunin 7a76fb78fc docs: address first review of opening references
Issue: #104
User-Visible: no
2026-08-13 13:11:57 +03:00
Sergey Matyunin c585f0268d docs: specify opening references after marker deletion
Issue: #104
User-Visible: no
2026-08-13 13:11:57 +03:00
Matysh df5be154ee ci: sync the process workflow into dev
Keeps dev identical to main so the broken revision does not come back at the
next promotion. The workflow only fires from the default branch, but a stale
copy here would overwrite the working one.

Issue: #114
User-Visible: no
2026-08-13 13:05:13 +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 39dd5de857 fix: restore the executable bit on commit-msg, mirror the turn limit
Pushing the hook through the GitHub contents API dropped its mode to 100644.
assertHookMode caught it on the next commit, which is the gate working as
intended — a non-executable commit-msg would simply never run.

Also brings dev in line with the turn-limit fix already on main.

Issue: #116
User-Visible: no
2026-08-13 12:38:13 +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 0509e1a008 docs: only product ambiguity goes to the owner
Agents were escalating technical calls. The owner answers what a person sees
or does and how much user-visible change belongs in an issue; storage, module
layout, naming, test strategy and migration mechanics are settled by the
agents, recorded as assumptions and challenged in review.

Issue: #114
User-Visible: no
2026-08-13 12:18:57 +03:00
Matysh e043974c44 docs: standing permission to merge a reviewed branch into dev
Without it S8-merged would be a lie: the label asserts the code is in dev,
while the branch-only push leaves it on the task branch. What lands is what
the reviewer just accepted, and dev is allowed to carry unreviewed code
anyway, so the merge adds no risk the branch did not already carry.

Issue: #114
User-Visible: no
2026-08-13 12:11:04 +03:00
Matysh 05b38e67c4 fix(hooks): drop basename from commit-msg
The hook parsed the message path with basename, an external command. Where
it is missing from PATH the substitution yields an empty string, set -e does
not trip on it, and the MERGE_MSG guard silently stops working — a generated
merge commit would then be rejected for missing trailers it cannot have.

POSIX parameter expansion needs no external command and behaves the same in
sh, dash, bash and Git Bash.

Issue: #116
User-Visible: no
2026-08-13 12:09:02 +03:00
Matysh b9062c1740 docs: standing permission to push the task branch
The reviewer runs in CI and can only read the remote, so an unpushed spec or
commit either stalls the review or points it at the wrong tree. Pushing
issue/<NN>-slug now needs no command; dev, main, merges, tags and releases
still do.

Issue: #114
User-Visible: no
2026-08-13 12:04:39 +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 97d932a384 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:28 +03:00
Sergey MatyuninandMatysh 30f71af200 Fix empty plan render snapshot
Issue: #111
User-Visible: yes
2026-08-13 11:41:23 +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
Matysh 495a99872b 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:33:21 +03:00
Matysh 53da8a1773 docs: align AGENTS.md and PROCESS.md with the actual process
Publishes the 538-line process canon into the repository, replacing the
51-line provenance stub that pointed at a non-existent .agents/PROTOCOL.md.
Rewrites AGENTS.md: product context first, labels as the canonical status,
rule #1 with the status check, change classes, trailers, push cadence,
Codex/Claude roles and review cycle limits.

Issue: #112
User-Visible: no
2026-08-13 10:30:00 +03:00
Matysh f339398f56 build: finalize v1.62.0
Validate / golden (push) Failing after 9m28s
Validate / provenance (push) Successful in 42s
Validate / hacs (push) Failing after 15s
Validate / hassfest (push) Failing after 16s
Validate / frontend (push) Successful in 7m39s
Validate / backend (push) Failing after 8m22s
Validate / performance_smoke (push) Failing after 12m22s
Validate / smoke (push) Failing after 23m26s
Full Performance / performance (push) Failing after 44m19s
User-Visible: no
Issue: #108
2026-08-13 01:05:25 +03:00
Matysh ab609ab165 test: honor baseline fingerprint contracts
User-Visible: no
Issue: #108
2026-08-13 01:05:19 +03:00
Matysh e2b0fbfc06 build: promote v1.62.0
User-Visible: yes
Issue: #108
2026-08-13 00:54:10 +03:00
Matysh ce40c57a3b build: prepare v1.62.0-rc.1
User-Visible: yes
Issue: #108
2026-08-13 00:35:24 +03:00
Matysh 31d81ad4ef test: accept v1.62.0-beta.10 golden matrix
Release: v1.62.0-beta.10
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/31641638463
User-Visible: no
Issue: #108
2026-08-13 00:23:00 +03:00
Matysh 37032203dd fix: harden v1.62.0-beta.10 candidate
User-Visible: yes
Issue: #108
2026-08-13 00:14:51 +03:00
Matysh cf77d7d2e1 test: accept beta.9 diagonal opening baseline
Issue: #108
User-Visible: no
Release: v1.62.0-beta.9
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/31629590600
2026-08-12 21:56:39 +03:00
Matysh 8e2973fa7a Release v1.62.0-beta.9 candidate
Issue: #108
User-Visible: yes
2026-08-12 21:49:29 +03:00
Matysh 9bb5f7c5a8 test: accept beta.8 visual baselines
Issue: #75
Issue: #90
Issue: #98
User-Visible: no
Release: v1.62.0-beta.8
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/31619095298
2026-08-12 19:51:56 +03:00
Matysh 121c9f10b9 Fix default display hint translation
Issue: #98
User-Visible: yes
2026-08-12 19:44:22 +03:00
Matysh 661eb784fb Fix beta.8 validation regressions
Issue: #75
Issue: #95
Issue: #98
User-Visible: yes
2026-08-12 19:38:34 +03:00
Matysh 9e74051652 Release v1.62.0-beta.8 candidate
Issue: #75
Issue: #76
Issue: #95
User-Visible: yes
2026-08-12 19:18:54 +03:00
Matysh 01980ac3e6 Release v1.62.0-beta.7 candidate
Validate / performance_smoke (push) Failing after 9m34s
Validate / backend (push) Failing after 4m47s
Validate / smoke (push) Failing after 19m8s
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 11s
Validate / frontend (push) Successful in 4m20s
Validate / golden (push) Failing after 6m33s
2026-08-12 15:13:38 +03:00
Matysh 36e81e9fb1 Release v1.62.0-beta.6 candidate 2026-08-12 14:07:09 +03:00
Matysh 6d0f97ef82 test: accept beta.5 visual baselines 2026-08-12 13:10:46 +03:00
Matysh bd9b6a6d75 Stabilize beta.5 transition validation 2026-08-12 13:02:22 +03:00
Matysh 4b03b888ff Release v1.62.0-beta.5 candidate 2026-08-12 12:43:27 +03:00
Matysh 58d952e94e test: align beta.4 action smokes 2026-08-12 10:36:10 +03:00
Matysh 5d3f580df0 test: accept beta.4 help icon golden baselines 2026-08-12 10:26:56 +03:00
Matysh 4dc3fdef36 Release v1.62.0-beta.4 candidate 2026-08-12 10:19:51 +03:00
Matysh 8a417539e0 test: align beta.3 action smokes
Validate / hacs (push) Failing after 8s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 3m57s
Validate / backend (push) Failing after 5m9s
Validate / performance_smoke (push) Failing after 1m40s
Validate / golden (push) Failing after 1m44s
Validate / smoke (push) Failing after 12m18s
2026-08-12 02:42:57 +03:00
Matysh c41231a7ba Release v1.62.0-beta.3 candidate 2026-08-12 02:33:54 +03:00
285 changed files with 38344 additions and 4684 deletions
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
message_file=$1
# Git-generated merge commits do not represent an independently authored
# product change and inherit provenance from their parents.
case "${message_file##*/}" in
MERGE_MSG) exit 0 ;;
esac
repo_root=$(git rev-parse --show-toplevel)
node "$repo_root/scripts/validate-commit-provenance.mjs" \
--message-file "$message_file" --staged --check-hook-mode
+80
View File
@@ -0,0 +1,80 @@
#!/bin/sh
set -eu
# PROCESS.md 10.1: the blocking process gate lives here, because commits go
# straight to dev without pull requests and GitHub blocks nothing on its side.
# CI still runs the same script (10.3), but by then the code is already in dev —
# that catch-up pass reports, it does not prevent.
#
# Git feeds one line per ref on stdin:
# <local ref> <local sha> <remote ref> <remote sha>
repo_root=$(git rev-parse --show-toplevel)
gate="$repo_root/scripts/process-gate.mjs"
zero=$(printf '%040d' 0)
# The gate reasons about commits. A repository without it — an old checkout, a
# bisect, a worktree from before the script existed — must still be pushable.
if [ ! -f "$gate" ]; then
exit 0
fi
# Reading issue status needs gh, and a hook that cannot work on a train is a
# hook people disable. Offline the checks that need no network still run, and the
# strict pass happens in CI, where gh is always present.
issues_flag=""
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
issues_flag="--issues"
else
echo "process-gate: gh недоступен, проверка статуса issue пропущена — её выполнит CI" >&2
fi
status=0
while read -r local_ref local_sha remote_ref remote_sha; do
# Deleting a remote branch pushes nothing to examine.
if [ "$local_sha" = "$zero" ]; then
continue
fi
# Tags carry no process state of their own: the commit they point at was
# already checked when it was pushed.
case "$local_ref" in
refs/tags/*) continue ;;
esac
if [ "$remote_sha" = "$zero" ]; then
# A branch that does not exist on the remote yet. Everything it adds on top
# of dev is new, so that is the range — not the whole history, which would
# drag in every violation committed before the gate existed.
base=$(git merge-base "$local_sha" refs/remotes/origin/dev 2>/dev/null || true)
if [ -z "$base" ]; then
echo "process-gate: не нашёл общего предка с origin/dev, проверяю последние 20 коммитов" >&2
base="$local_sha~20"
fi
else
base="$remote_sha"
fi
echo "process-gate: $local_ref, диапазон ${base}..${local_sha}" >&2
# shellcheck disable=SC2086
if ! node "$gate" --range "${base}..${local_sha}" --target-ref "$remote_ref" $issues_flag >&2; then
status=1
fi
done
if [ "$status" -ne 0 ]; then
cat >&2 <<'EOF'
Push остановлен: нарушен процесс (PROCESS.md §10.2).
Починить надо причину, а не симптом. Если нарушение уже опубликовано, его
исправляет следующий коммит плюс issue с меткой `process` — не force-push
(§12, правило 17).
Обойти проверку можно через `git push --no-verify`, и тогда то же самое найдёт
job `process-gate` в Validate — уже после того, как код окажется в dev.
EOF
fi
exit "$status"
+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
+6
View File
@@ -148,6 +148,10 @@ jobs:
run: |
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
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
@@ -161,6 +165,8 @@ jobs:
working-directory: candidate
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
+456
View File
@@ -0,0 +1,456 @@
name: Process
# Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это
# сообщение: она порождает событие, событие запускает следующий шаг.
#
# S4-spec-review -> ревью ТЗ -> S5-ready | S3-spec
# S7-code-review -> код-ревью -> слияние в dev -> S8-merged | S6-in-progress
#
# Три вещи, без которых конвейер молча не работает:
#
# 1. Метки переставляются токеном HP_PROCESS_TOKEN, а не GITHUB_TOKEN. GitHub
# намеренно не запускает workflow от событий, вызванных GITHUB_TOKEN, чтобы
# не было циклов — цепочка оборвалась бы после первого шага.
# 2. Этот файл обязан лежать в ветке по умолчанию (main). Для события `issues`
# GitHub берёт workflow только оттуда, независимо от того, что в dev.
# 3. Многострочный текст внутри `run:` — только через heredoc. Строка с нулевым
# отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
# Проверять не только YAML, но и каждый `run` через `bash -n`.
on:
issues:
types: [labeled]
concurrency:
# Два события по одному issue не должны запускать два прогона.
group: process-issue-${{ github.event.issue.number }}
cancel-in-progress: false
permissions:
contents: read
issues: write
# Обязательно: claude-code-action получает OIDC-токен для авторизации
# GitHub App. Без этого прогон падает с «Could not fetch an OIDC token».
id-token: write
jobs:
guard:
runs-on: ubuntu-latest
outputs:
stage: ${{ steps.decide.outputs.stage }}
cycle: ${{ steps.decide.outputs.cycle }}
limit: ${{ steps.decide.outputs.limit }}
steps:
- id: decide
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
LABEL: ${{ github.event.label.name }}
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: |
# Этап определяется первым: от него зависит, какие вердикты считать.
stage=""; marker=""
case "$LABEL" in
S4-spec-review) stage="spec"; marker="SPEC-REVIEW" ;;
S7-code-review) stage="code"; marker="CODE-REVIEW" ;;
*) echo "метка $LABEL конвейер не запускает" ;;
esac
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
limit=4
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
# первое код-ревью получило r2/4. На задаче с двумя циклами ТЗ второе
# код-ревью упиралось бы в review-4 после одной правки.
#
# Этап опознаётся по имени документа в теле комментария. Если документа
# нет, вердикт не посчитается — недосчёт даёт лишний цикл, а перерасчёт
# остановил бы работу досрочно; из двух ошибок выбрана обратимая.
done_cycles=0
if [ -n "$stage" ]; then
done_cycles=$(gh issue view "$NUM" --repo "${{ github.repository }}" \
--json comments \
-q "[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))] | length")
fi
# Отказ обязан быть виден в issue, а не только в логе прогона.
# Ревьюшная метка обещает работу; если конвейер её не начал и промолчал,
# задача стоит в этом статусе бесконечно и никто об этом не узнаёт.
# Так и вышло на #123: чужой issue довели до S4-spec-review, guard
# отказался за 9 секунд, и в issue не было ни слова.
#
# Пишем только когда пытались запустить ревью, то есть stage опознан.
# Иначе комментарий уходил бы на каждую смену любой метки.
refuse() {
echo "$1"
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
"Конвейер ревью не запущен: $2
Метка \`$LABEL\` обещает работу, которая не начнётся, поэтому статус лучше вернуть в предыдущий — иначе задача простоит здесь бесконечно. [Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
stage=""
}
# Автор issue здесь не проверяется (решение владельца 2026-08-13).
# Проверка стоит на входе в процесс, а не на каждом шаге: как только
# задача получила статусную метку, она в работе, и кто её завёл — не
# имеет значения. Само присвоение метки и есть явное подтверждение
# владельца, причём проверенное платформой: метки может ставить только
# тот, у кого есть право записи в репозиторий. Прежняя проверка здесь
# дублировала эту гарантию и заставляла переоформлять чужие отчёты
# своими issue — чистая работа впустую, как на #123.
if [ -z "$stage" ]; then
:
elif [ "$BLOCKED" = "true" ]; then
refuse "стоит blocked — конвейер не запускается" \
"на issue стоит \`blocked\` — задача ждёт внешнего решения. Снять метку, когда решение принято."
elif [ "$EXHAUSTED" = "true" ]; then
refuse "стоит review-4 — решение за владельцем" \
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
elif [ "$done_cycles" -ge "$limit" ]; then
echo "циклов этапа $stage пройдено $done_cycles из $limit — лимит исчерпан"
gh issue edit "$NUM" --repo "${{ github.repository }}" --add-label review-4
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
"Лимит циклов ревью исчерпан ($done_cycles из $limit на этапе \`$stage\`). Пятого захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
stage=""
else
echo "этап $stage, цикл $((done_cycles + 1)) из $limit"
fi
echo "stage=$stage" >> "$GITHUB_OUTPUT"
echo "cycle=$((done_cycles + 1))" >> "$GITHUB_OUTPUT"
echo "limit=$limit" >> "$GITHUB_OUTPUT"
review:
needs: guard
if: needs.guard.outputs.stage != ''
runs-on: ubuntu-latest
# Время — единственный настоящий ограничитель зациклившегося прогона.
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: dev
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
# тяжёлых job, здесь его не было.
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
# ревьюер прочтёт dev и не найдёт того, что должен оценивать.
- name: Перейти на ветку задачи
id: branch
env:
NUM: ${{ github.event.issue.number }}
run: |
branch=$(git ls-remote --heads origin "issue/${NUM}-*" \
| head -1 | sed 's|.*refs/heads/||')
if [ -n "$branch" ]; then
git checkout -q "origin/$branch"
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
echo "name=$branch" >> "$GITHUB_OUTPUT"
else
echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev"
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
with:
# Подписка, а не отдельный счёт API: токен выпускается через
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
Ты ревьюер проекта House Plan. Язык ответа — русский.
Issue: #${{ github.event.issue.number }}
Репозиторий: ${{ github.repository }}
Этап: ${{ needs.guard.outputs.stage }}
spec — ревью ТЗ (PROCESS.md §2.4)
code — код-ревью (PROCESS.md §2.7)
Прочитай в этом порядке, прежде чем судить:
1. docs/SCOPE.md — зачем продукт существует и для кого. Он
ограничитель: «features are built, improved and accepted only
if they serve a job listed here». Первый вопрос к задаче —
какую строку Core user jobs она закрывает.
2. AGENTS.md и PROCESS.md — процесс, классы изменений, трейлеры,
лимит циклов, формат вердикта.
3. Тело issue #${{ github.event.issue.number }} и все комментарии.
4. Если меняется видимое поведение — docs/USER-GUIDE.ru.md:
терминология интерфейса берётся оттуда, а не изобретается.
5. Канонический документ затронутой подсистемы: docs/SUN.md,
LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md,
CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md.
Для этапа spec: если issue помечен small, ТЗ живёт в теле issue и
файла в docs/specs/ быть не должно. Иначе ТЗ — docs/specs/<NN>-*.md.
Проверь обязательные разделы §7.1, однозначность каждого AC и
указание способа доказательства. Отдельно проверь, что автор не
выдал догадку за решение: утверждение о поведении, которого нет ни
в одном документе и которое не помечено как предположение, —
замечание. Не бывает сложной задачи без единого открытого вопроса.
Владельцу задаются только продуктовые вопросы: что человек видит или
делает и каков объём видимых изменений в этом issue. Технический
вопрос, вынесенный владельцу, — тоже замечание: ты его снимаешь и
решаешь по существу в своём вердикте.
Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD`
и `git diff origin/dev...HEAD`. Ручного тестирования в цикле нет,
поэтому именно ты отвечаешь на вопрос «оно вообще работает».
По каждому AC: либо он доказан автотестом и ты убедился, что тест
умеет падать, либо разобран по коду с явной записью «проверено
чтением, не исполнением». «Verified» без названной команды и её
результата доказательством не является. Зависимости уже установлены
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 либо тронуты
чувствительные к перфу пути.
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
тем тестам, которые ты прогонял.
**В комментарии обязателен перечень: какие гейты прогнал, какие нет и
почему.** Это условие честности такого сужения: непрогнанный гейт
становится видимым решением, а не молчаливым пропуском. Раздел «чего
не проверял» в документе ревью — не формальность, а главный его
раздел на коротких задачах.
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
Серьёзность: High блокирует; Medium обязан стать отдельным issue;
Low либо правится, либо снимается с записью. Жёлтый вердикт
допустим при полностью выполненных AC, если изменение не решает
заявленный сценарий или ухудшает смежный. Продуктовое рассуждение
расширяет вопросы, но не отменяет AC и не даёт права менять скоуп.
Каждую Medium-находку заведи отдельным issue со ссылкой на
#${{ github.event.issue.number }} и метками: тип, приоритет,
S1-new. «Оставили в тексте ревью» закрытием не считается и прямо
запрещено §12.
Напиши полный документ ревью в файл
docs/reviews/<SPEC|CODE>-REVIEW-${{ github.event.issue.number }}-r${{ needs.guard.outputs.cycle }}.md
(SPEC для этапа spec, CODE для code): скоуп, как проверялось,
находки с воспроизведением, что проверено и корректно, чего не
проверял. Каталог docs/reviews/ создай, если его нет. Больше не
пиши ничего: любой файл вне docs/reviews/ опубликован не будет.
Затем оставь в issue краткий комментарий: вердикт, ключевые находки
и ссылка на документ. Первой строкой — вердикт в формате §7.2:
`Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → #…`
Затем верни JSON по схеме. Это последнее действие и оно обязательно:
без него метка не переставится и конвейер встанет.
claude_args: |
--max-turns 150
--allowedTools Read,Write,Grep,Glob,Bash,mcp__github__add_issue_comment,mcp__github__issue_write,mcp__github__issue_read
--json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"}},"required":["verdict","high","medium","summary"]}'
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
# решает этот шаг, а не модель: всё остальное откатывается.
- name: Опубликовать документ ревью
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
run: |
# Ветки задачи может не быть: у задач, размеченных до появления
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
# комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что
# ничего не сделал. Документ ложится туда же, где лежит само ТЗ.
target="${BRANCH:-dev}"
if [ -z "$BRANCH" ]; then
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 "::warning::документ ревью не создан"
exit 0
fi
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
commit -q -F - <<EOF
docs: review document for #$NUM
Issue: #$NUM
User-Visible: no
EOF
# Публикация в 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
env:
OUT: ${{ steps.review.outputs.structured_output }}
STAGE: ${{ needs.guard.outputs.stage }}
run: |
verdict=$(echo "$OUT" | jq -r '.verdict')
high=$(echo "$OUT" | jq -r '.high')
echo "вердикт: $verdict, High: $high"
# Вперёд двигает ТОЛЬКО зелёный. Жёлтый и красный возвращают
# автору: на прогоне #111 жёлтый означал, что AC описывает неверное
# изменение контракта — реализовать такое ТЗ значит сделать ошибку
# по инструкции. Оба считаются циклом.
if [ "$verdict" = "green" ] && [ "$high" -eq 0 ]; then
green=true
case "$STAGE" in
spec) from=S4-spec-review; to=S5-ready ;;
code) from=S7-code-review; to=S8-merged ;;
esac
else
green=false
case "$STAGE" in
spec) from=S4-spec-review; to=S3-spec ;;
code) from=S7-code-review; to=S6-in-progress ;;
esac
fi
echo "green=$green" >> "$GITHUB_OUTPUT"
echo "from=$from" >> "$GITHUB_OUTPUT"
echo "to=$to" >> "$GITHUB_OUTPUT"
# S8-merged утверждает, что код в dev. Значит слияние обязано произойти
# ДО метки, иначе она врёт в промежутке.
#
# При конфликте шаг НЕ падает и метку не оставляет на месте. Первая
# редакция делала именно так, и это оказалось тупиком: автор ждёт смену
# метки, метка не менялась, и он тридцать раз опрашивал впустую, чтобы
# затем отчитаться «лимит исчерпан» — при зелёном вердикте. Инвариант
# теперь жёстче: ПОСЛЕ ПРОГОНА РЕВЬЮ МЕТКА МЕНЯЕТСЯ ВСЕГДА.
- name: Слить ветку в dev
id: merge
if: needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true'
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
run: |
if [ -z "$BRANCH" ]; then
echo "::error::ветки задачи нет — сливать нечего"
echo "merged=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git fetch -q origin dev
git checkout -q -B merge-into-dev "origin/$BRANCH"
if ! git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
rebase origin/dev; then
git rebase --abort || true
echo "merged=false" >> "$GITHUB_OUTPUT"
echo "::warning::ветка $BRANCH не сливается в dev без конфликта"
cat > /tmp/conflict.md <<EOF
**Код-ревью зелёное — вердикт выше в силе, переделывать работу не нужно.** Не удалось только слияние: ветка \`$BRANCH\` конфликтует с \`dev\`.
Задача переведена в \`S6-in-progress\`, потому что работа вернулась к автору. Осталась не правка кода, а ребейз:
1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;
2. запушить ветку;
3. вернуть метку \`S7-code-review\`.
Повторный прогон ревью — не формальность: после ребейза на новый \`dev\` это другой код, и принимать его без проверки нельзя. Цикл считается по этапу, лимит на код-ревью тратится отдельно от ревью ТЗ.
EOF
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/conflict.md
exit 0
fi
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev
echo "merged=true" >> "$GITHUB_OUTPUT"
echo "слито в dev: $(git rev-parse --short HEAD)"
- name: Переставить метку
env:
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
FROM: ${{ steps.decide.outputs.from }}
# Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к
# автору: метка утверждала бы, что код в dev, а его там нет.
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to }}
run: |
gh issue edit "$NUM" --repo "${{ github.repository }}" \
--add-label "$TO" --remove-label "$FROM"
echo "$FROM -> $TO"
- name: Позвать владельца, если ревью упало
if: failure()
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# Тело через heredoc, а не многострочный --body: строка с нулевым
# отступом обрывает блок YAML и оставляет незакрытую кавычку.
cat > /tmp/failure.md <<EOF
Автоматическое ревью не отработало: [прогон]($RUN_URL). Статусная метка не менялась, задача осталась на месте.
Если вердикт выше всё же опубликован — сбой произошёл после него. Перестановку метки в этом случае выполняет чат обслуживания или владелец, но не автор задачи: автор не толкует вердикт о своей же работе.
EOF
gh issue comment "${{ github.event.issue.number }}" \
--repo "${{ github.repository }}" --body-file /tmp/failure.md
+54
View File
@@ -191,6 +191,60 @@ jobs:
);
}
# PROCESS.md 10.2 item 10: closing issues and stripping status labels happens
# because a beta was published, not because someone remembered to do it. The
# manual step was skipped twice, and both times it broke the invariant that a
# closed issue carries no status label — the one thing `verify` relies on.
#
# A manual step after a successful release is the worst kind: by the time it is
# due, the work already looks finished, which is exactly why it gets forgotten.
close-merged:
needs: [gate, publish]
if: ${{ needs.publish.outputs.newly_published == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
# Deliberately the stock token, not a PAT: events caused by GITHUB_TOKEN do
# not start workflows, so removing the label cannot wake the review
# pipeline. A PAT here would build a cascade out of a bookkeeping step.
issues: write
steps:
- name: Close the S8-merged queue and strip status labels
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ needs.gate.outputs.tag }}
URL: ${{ needs.publish.outputs.url }}
run: |
set -euo pipefail
# Only the owner's issues take part in the process; issues filed by
# anyone else never carry status labels and are not ours to close.
numbers=$(gh issue list --repo "$REPO" --state open --label S8-merged \
--author Matysh --limit 100 --json number --jq '.[].number')
if [ -z "$numbers" ]; then
echo "the S8-merged queue is empty, nothing to close"
else
for n in $numbers; do
gh issue comment "$n" --repo "$REPO" \
--body "Выпущено в \`$TAG\` · [релиз]($URL)"
# Label first, then close. If the run dies between the two steps an
# open issue without a status is visible and fixable in the flow;
# the reverse order would recreate the exact breakage this job is
# here to prevent.
gh issue edit "$n" --repo "$REPO" --remove-label S8-merged
gh issue close "$n" --repo "$REPO" --reason completed
echo "closed #$n"
done
fi
# Targeted at the defect that actually recurs, not at the invariant in
# general: no closed issue may still carry S8-merged.
leftover=$(gh issue list --repo "$REPO" --state closed --label S8-merged \
--limit 100 --json number --jq 'length')
test "$leftover" = "0" || {
echo "::error::$leftover closed issues still carry S8-merged"
exit 1
}
announce:
needs: [gate, publish]
if: ${{ needs.publish.outputs.newly_published == 'true' }}
+98
View File
@@ -15,7 +15,99 @@ concurrency:
cancel-in-progress: true
jobs:
provenance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Validate commit trailers and hook mode
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range
# Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на
# машине автора, но их можно обойти `--no-verify`, а коммиты идут прямо в dev
# без PR — GitHub на своей стороне не блокирует ничего. Это последнее место,
# где нарушение правила №1 ловится машиной. Job независимый: краснеет сам и
# не роняет остальные, откат — удалить его отсюда, скрипт остаётся рабочим.
process-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Process gate
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
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
@@ -25,6 +117,8 @@ jobs:
category: integration
hassfest:
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -32,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
@@ -152,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
+387 -42
View File
@@ -6,60 +6,398 @@ House Plan is one HACS package with two parts plus a demo harness:
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
Standard commands live in `package.json` scripts, `CONTRIBUTING.md`, and `docs/DEVELOPMENT.md`. Read `docs/ARCHITECTURE.md` and `docs/STATUS.md` before non-trivial changes.
## Read this first
**`docs/SCOPE.md` before anything else.** It was fixed with the owner and states
its own authority: features are built, improved and accepted **only** if they
serve a job listed there. It carries the mission, the three personas, the core
user jobs and the out-of-scope list.
Its central consequence: **View mode is the product for two of the three
personas.** Editors are admin-only tools and must never leak interactions into
View.
For work that changes visible behaviour, also read `docs/USER-GUIDE.ru.md` —
interface wording comes from there and is not invented, or the UI starts speaking
developer.
Then `PROCESS.md` (the full process), `docs/STATUS.md` (where the release line
is), and for non-trivial changes `docs/ARCHITECTURE.md` plus the canonical
document of the subsystem you touch: `SUN.md`, `LIGHT.md`, `CANVAS.md`,
`WALL-THICKNESS.md`, `UX-MODES.md`, `CONFIG-COMPATIBILITY.md`,
`TOUCH-SUPPORT.md`.
Standard commands live in `package.json` scripts, `CONTRIBUTING.md` and
`docs/DEVELOPMENT.md`.
## Canonical backlog and status
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical
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. 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
step: while an issue carries no status label it is outside the process and the
invariants do not apply to it; once a label is on, the task is in flight and **who
filed it stops mattering**.
Applying that first label *is* the owner's explicit decision, and the platform
already guarantees it — only someone with write access can label. The earlier rule
made outside reports be refiled as the owner's own issues, which turned out to be
work for nothing: on #123 the spec was already written by the time the guard
refused.
Specs, audits and ADRs may live under `docs/`, but must link to their issue and
must not become a parallel task list. When repository documentation disagrees with
Issues, the issue wins.
## Rule #1
> Changing product code without an issue is forbidden. Code changes only when the
> issue exists and sits in "Ready for development" or later.
Check before touching product code:
```
gh issue view <NN> --repo Matysh/houseplan-card --json number,state,labels
```
The label must be one of `S5-ready`, `S6-in-progress`, `S7-code-review`. Anything
else — refuse and say why. "Issue #83 is in `S2-analysis`, code is off limits.
Start with the spec?" is the correct answer, not a smaller patch.
## Change classes
| Class | Paths | Issue required |
|---|---|---|
| **A — product** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, i18n, `custom_components/**/translations/**` | yes |
| **B — gates and tooling** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, `.github/workflows/**`, `rollup.config.mjs`, `tsconfig*.json` | yes; may reuse the issue it covers |
| **C — documentation** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md` | not if it is part of its issue's DoD |
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**` | never changes on its own |
The table above is a summary; `PROCESS.md` §1 is the authority and now covers the
configuration files this one omits — `package.json`, `package-lock.json`,
`pytest.ini`, `.gitignore`, `.gitattributes`, `.githooks/**` and the rest of
`.github/**` are class B. Where paths overlap, **D beats A**: the built bundle
lives inside `custom_components/houseplan/frontend/` and would otherwise read as
product source.
## Commits
Hooks install themselves: `package.json` runs `"prepare": "node
scripts/install-hooks.mjs"`, so `npm ci` sets `core.hooksPath` in every fresh
clone. Verify with `git config core.hooksPath` — expect `.githooks`.
Every non-merge commit carries **terminal** trailers:
```text
Issue: #123
User-Visible: yes
```
One `Issue:` line per issue if a commit closes several. `User-Visible: no` for
tests, refactors, tooling and documentation that does not change the product.
`User-Visible: yes` requires edits to **both** changelogs — `docs/CHANGELOG.md`
and `docs/CHANGELOG.ru.md` — in the same commit.
A commit touching `demo/golden/baselines/**` additionally requires:
```text
Release: v1.62.0-beta.9
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/<run-id>
```
Never invent a review link and never rewrite published history to satisfy
trailers. `.githooks/commit-msg` and the `provenance` CI job both run
`scripts/validate-commit-provenance.mjs`.
Branch: `issue/<NN>-slug`. Direct commits to `dev`, no PR — the owner's decision;
CI checks after the fact, and a violation is fixed with a follow-up commit, never
a force-push.
**Push after every task, not before a beta.** While work sits unpushed there is
nothing to review, and reviewing twenty tasks at once is not review. `dev` may hold
unreviewed code while a task is in flight; what matters is its state when the
reviewer says it is accepted.
**Standing permission: push `issue/<NN>-slug` without asking.** The reviewer runs
in CI and can only read what is on the remote — an unpushed spec or commit means
the review either stalls or judges the wrong tree. Pushing a task branch publishes
nothing to users and does not touch the integration branch, so it needs no command.
**Do not merge into `dev` by hand.** On a green code review the pipeline rebases
the task branch onto `dev`, pushes it, and only then sets `S8-merged` — the label
asserts the code is in `dev`, so the merge has to happen first or the label lies
in between.
If the rebase conflicts the pipeline says so in the issue and sends the task back
to `S6-in-progress`. The verdict still stands: nothing needs reviewing again, the
remaining work is the rebase. Resolve it, push the branch, re-apply
`S7-code-review`. The second review run is not a formality — after a rebase onto a
moved `dev` this is different code, and accepting it unchecked is how regressions
arrive. Cycles are counted per stage, so a code review spends its own budget.
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
House Plan is developed by two agents: **Codex is the author** (analysis,
estimate, spec, implementation) and **Claude is the reviewer** (estimate, spec,
code). They exchange remarks through a local, git-ignored message bus —
**read `.agents/PROTOCOL.md` before acting on any task**. It defines the
message format, the estimate scales every agent must use, the three stages
(`estimate` → `spec` → `code`), the three-round convergence limit and what gets
published to GitHub.
**Codex** writes analysis, specs and all product code. **Claude** reviews specs and
code and owns infrastructure and distribution. The owner rules on disputes, closes
issues and commands releases.
Two rules that matter even if you read nothing else:
Author and reviewer are different models, which is what "a fresh session without
implementation context" means in practice. The reviewer never edits product code;
the author never grades their own work.
- write only into the *other* agent's inbox, never edit a file you did not
create, and move a processed message to `.agents/archive/`;
- "verified" without a command and its output is not evidence — from either side.
**Infrastructure-only work runs outside this flow.** CI, scripts, labels, demo
stands, the landing page and distribution are Claude's alone, and running them
through spec-writing and review buys nothing: the spec would restate what is
already unambiguous, and author and reviewer would be the same role. So no spec
file, no spec review, no code review, no walk through `S1`…`S8`.
## Canonical backlog
The test for "infrastructure only" is mechanical: **not a single class A file** —
nothing under `src/**`, no `custom_components/**/*.py`, no manifests, no i18n. A
task that touches class A even once is not infrastructure and takes the full flow;
there is no such thing as "mostly infrastructure". The strictness is deliberate:
a loose reading would turn this into the route by which product changes skip
review.
GitHub is the only active backlog for House Plan:
What stays mandatory either way: an issue exists, both trailers are on every
commit, `typecheck`, `test` and `build` are green, and any non-obvious decision is
written down in the code or the issue rather than kept in someone's head.
- [GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the
canonical task records: problem, scope, acceptance criteria and discussion.
- [GitHub Projects (v2)](https://github.com/users/Matysh/projects/1) is
the canonical prioritization and workflow-status view. Every open in-scope
issue must be present there.
**Review starts by itself.** Applying `S4-spec-review` or `S7-code-review` fires the
pipeline, which reviews without anyone asking and takes ten to forty-five minutes.
Before starting planned work, find or create its issue and keep its description,
labels and Project status current as decisions and implementation state change.
Close an issue only after the result is verified. Specs, audits and ADRs may
remain under `docs/`, but must link to their issue and must not become a parallel
task list. When repository documentation disagrees with Issues or Project v2,
the GitHub backlog wins.
**Having applied one of those labels, wait for the result instead of ending the
session.** Reporting "handed over for review" stops a conveyor that could have kept
moving on its own. An agent has no clock — it exists only during its own turn — so
waiting means polling: every 90 seconds, at most 30 times. A single long sleep hits
the command timeout. Watch the **label**, not the comment: the label is the state,
the comment only explains it. Do not wait at all while `blocked` is set — the task
is waiting on the owner, not on the reviewer. On exhausting the attempts, stop and
tell the owner: a failed run leaves the label where it was, forever.
## Cursor Cloud specific instructions
What the new label means:
The startup update script already runs `npm ci`, provisions a Python 3.13 backend venv at `.venv-backend`, and installs Playwright Chromium. You do not need to reinstall dependencies.
| Now reads | What happened | What you do |
|---|---|---|
| `S5-ready` | the spec is accepted | write the code |
| `S3-spec` | the spec came back | read the verdict, revise, re-apply `S4-spec-review` |
| `S6-in-progress` | the code came back | revise, re-apply `S7-code-review` — **or**, if the verdict was green and only the merge conflicted, just rebase and re-apply. The comment says which |
| `S8-merged` | accepted and already in `dev` | nothing |
| `review-4` | the cycle limit is spent | stop, the owner decides |
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, 424 tests at v1.60.0), `npm run build`. After building, keep both committed snapshots in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/` and `cp dist/houseplan-card.js demo/srv/assets/`. CI enforces both comparisons byte-for-byte.
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (150 tests at v1.60.0: 100 pure + 50 HA harness). Running `python3 -m pytest tests_backend` without Home Assistant silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the pure set.
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed demo snapshot must remain byte-identical to `dist` (CI checks it); rebuilding first also guarantees the browser suite tests the current source in an uncommitted worktree. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
- **Golden images**: `npm run golden:capture` and `npm run golden:verify` refuse a stale demo bundle. Build and copy the current bundle first, then review `artifacts/golden/actual/` and `diff/`. Update baselines only with `npm run golden:accept -- --reviewed`, using the complete Linux CI artifact; never accept a partial scenario or images merely to make CI green. See `demo/golden/README.md`.
- **Freshness contract**: the embedded fingerprint covers `src/` plus Rollup, TypeScript and package-lock build inputs. Benchmark and golden tooling must call `assertFreshDemoBundle` before recording any result; a missing or mismatched fingerprint is a hard failure, not a warning.
- **Demo harness render quirk**: the fake `hass` in `demo.html` is set once, so opening the page directly in a browser renders the floor plan but **device icons only appear after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does not. This is a harness limitation, not a card bug.
- **Known environment-sensitive smoke**: `demo/smoke_opening_measure.mjs` fails two sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It reproduces against the pristine committed bundle, so treat it as pre-existing/pixel-precision, not a regression you introduced.
- **Owner's local Windows checkout is invisible here.** Path
`C:\Users\Sergey\Downloads\dev\houseplan-dev` (workflow notes + often
unpushed edits) is **not mounted** into managed Cloud Agent VMs. Do not
expect to `ls` or diff that folder. To bring local work into the cloud
agent: push a branch to GitHub and say its name, or run a **local** Cursor
Agent / My Machines worker inside that checkout. Day-to-day source of truth
for cloud sessions remains **`origin/dev`** (minors) and **`origin/main`**
(releases) — see `docs/STATUS.md`.
**After a review run the label always changes.** If it did not, the run itself
failed rather than the work — say so to the owner instead of polling on.
**A failed pre-release gate does not send the issue back to review.** The
implementation loop runs only typecheck, unit and build; golden, browser smokes,
performance and the full HA harness run before a beta, which is after the code
review has passed and the issue sits in `S8-merged`. Some defects cannot surface
any earlier.
Fix it, re-run what failed, and a green run is enough for the release to continue.
The issue stays in `S8-merged`. Record the **exact command and its result** in the
issue — "verified" without a command proves nothing. Trailers as usual, and
`User-Visible: yes` still means both changelogs in the same commit.
The exception covers repairing the defect the gate named, not carrying on
development under the name of a repair. It goes through the normal flow — a new
issue, or back to `S6-in-progress` — if the fix changes a behaviour contract, gives
the user something new, reaches a subsystem the task never touched, or is
comparable in size to the task itself. And editing the gate so it stops failing is
concealment, not repair; the exception is a defect proven to be **in the fixture**,
as on #89, where the sun sat at azimuth 180° and the only window faced north, so no
ray was ever built.
Baselines are still accepted only via `npm run golden:accept -- --reviewed` on a
complete Linux CI artefact. "So the gate goes green" is not a reason.
The exchange happens in **issue comments** — there is no local message bus. Verdict
format:
```text
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → #… · Document: …
```
High blocks. Medium must become its own issue. Low is fixed or waived with a note
in the review document. A yellow verdict is legitimate even when every acceptance
criterion passes, if the change does not solve the stated scenario or degrades a
neighbouring one.
**Four review cycles** (two on the light track). The counter lives in the document
name, `-r1`…`-r4`; the fourth adds the `review-4` label. There is no fifth attempt:
the owner splits the task, rejects it, or arbitrates.
On the light track (`small`: complexity ≤3, one surface, no config migration, no
new UX contract, no perf or touch impact — all at once) the spec lives in the issue
body and the spec review is a comment. Code review is never skipped.
## Specs
`docs/specs/<NN>-<slug>.md`, linked to its issue in both directions. Required
sections are in `PROCESS.md` §7.1, plus two product ones: which persona meets this,
on which surface, at what moment; and what the person sees before and after, in one
sentence without implementation terms.
**Ambiguity is asked, not guessed — but only product ambiguity.** A guess written as
fact is the worst kind of defect: it passes review because it looks like a decision.
The owner answers exactly two kinds of question: **what a person sees or does**, and
**how much user-visible change belongs in this issue**. Behaviour in a boundary case,
which persona wins when two conflict, what counts as acceptable degradation, whether
a neighbouring behaviour is in scope here or becomes its own issue.
Everything a user cannot observe is yours to settle: where state is stored, which
module carries the guard, naming, file layout, test strategy, migration mechanics,
development policy. Decide it, record it in an explicit "assumed, change freely"
block, and let the reviewer challenge it. A technical disagreement between author and
reviewer is settled by the verdict, not by the owner; it reaches him only when the
cycle limit is exhausted.
Split a mixed question instead of escalating all of it. "Where does this state live"
is technical. "Does it survive a page reload and follow the plan across screens" is
product. Ask the second, decide the first.
Ask in one batched issue comment, each question carrying a proposed default, and put
`blocked` on top of `S3-spec` while waiting. A question with a default costs the
owner seconds; one without costs him minutes.
## Gates
```
npm run typecheck
npm test
npm run build
npm run inventory # the only correct way to get test counts
```
Never copy test counts into documents by hand; they go stale in days.
After building, keep all three bundle snapshots in sync — CI compares them
byte-for-byte:
```
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 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.
Locally only the pure subset runs; `python -m pytest tests_backend/ -q` without
Home Assistant **silently skips** `test_ha_*.py` (`conftest.py` ignores them when
`homeassistant` is not importable), so a green result proves nothing. Say so in the
report instead of claiming the backend was verified. Cloud agents have the harness
at `.venv-backend/bin/python`.
**Running the app / smoke suite**: build a fresh bundle and copy it into the demo
assets first, then run `node demo/smoke_*.mjs`. No real Home Assistant server is
required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
**Golden images**: `npm run golden:capture` and `npm run golden:verify` refuse a
stale demo bundle. Build and copy first, then review `artifacts/golden/actual/` and
`diff/`. Update baselines only with `npm run golden:accept -- --reviewed`, using the
complete Linux CI artifact; never accept a partial scenario or images merely to make
CI green. See `demo/golden/README.md`.
**Freshness contract**: the embedded fingerprint covers `src/` plus Rollup,
TypeScript and package-lock build inputs. Benchmark and golden tooling must call
`assertFreshDemoBundle` before recording any result; a missing or mismatched
fingerprint is a hard failure, not a warning.
**CI is pinned to an exact SHA.** The release gate accepts only a `completed
success` run for the candidate's SHA, not "the last green one"; a new push cancels
an unfinished Validate for the same branch. Jobs: `provenance`, `hacs`, `hassfest`,
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`.
**"Verified" without a named command and its result is not evidence.**
## Environments
**Local Windows checkout** is the day-to-day environment: Node 22 as in CI, Python
3.13 in a venv, `gh` authenticated. `.venv-backend` does **not** exist there — it is
provisioned only by cloud agent startup scripts, which also run `npm ci` and install
Playwright Chromium.
Known environment-sensitive smoke: `demo/smoke_opening_measure.mjs` fails two
sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned
Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It
reproduces against the pristine committed bundle, so treat it as
pre-existing/pixel-precision, not a regression you introduced.
## Labs flags
`src/labs.ts` is the single registry and resolver for hidden presentation
experiments. Activate a live flag through `?hp-labs=<id>` or the shared hash
grammar, remove it with `-<id>`, and use `off` to clear the set. Do not add a
YAML/config switch for a Labs-only experiment. A new entry needs a unique
lowercase id, issue, numeric-core `since`, numeric-core `expires`, summary and
unit/browser coverage. Invalid or duplicate registry entries fail closed.
Expiry is exclusive and ignores prerelease suffixes: an entry expiring at
`1.65.0` is unavailable in `1.65.0-beta.1`. Before that cycle, either remove the
experiment or graduate it through its own reviewed issue; never extend expiry as
an incidental change. Labs may alter presentation only and must not gate data,
migrations, stores, HA actions or network calls. Current renderer details are in
`docs/ISOMETRIC.md`.
Demo harness render quirk: the fake `hass` in `demo.html` is set once, so opening the
page directly in a browser renders the floor plan but **device icons only appear
after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The
smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does
not. This is a harness limitation, not a card bug.
## Promotion rule
@@ -69,3 +407,10 @@ stable release commit is promotion-only: version fields, generated bundle
snapshots and changelog/release metadata. Do not add feature source code in
that commit. An explicit owner-requested emergency hotfix is the only exception
and must be called out in the release handoff.
A `Release vX.Y.Z-beta.N candidate` commit is **not** promotion-only: it carries
the work itself and follows the ordinary rules, trailers included.
Issues are closed in a batch when a beta ships, not when implementation ends: that
way a bug found in the beta returns to the same task, and the beta announcement can
list what went in. Status labels are stripped as the issues close.
+9 -8
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
@@ -35,6 +34,7 @@ npm run typecheck # tsc --noEmit (strict)
npm test # node:test — pure logic, i18n parity, tap-action security
npm run build # tsc + rollup → dist/houseplan-card.js
pip install pytest voluptuous && python -m pytest tests_backend -q # pure backend tests
npm install # also installs .githooks through the prepare script
```
The HA-harness backend tests (`tests_backend/test_ha_*.py`) need Python ≥3.13 and
@@ -50,7 +50,8 @@ every push — locally they are skipped when `homeassistant` is not importable.
- The built card must be committed in sync: `cp dist/houseplan-card.js
custom_components/houseplan/frontend/` (CI compares them byte-for-byte).
- Tap actions have a security model (locks/alarms never toggle from the plan) —
see `resolveTapAction` in `src/logic.ts`; don't weaken it.
see `resolveToggleIntent` in `src/device-toggle.ts`; don't weaken it.
- Every commit follows the issue and trailer contract in `PROCESS.md`.
- Follow the Integration Quality Scale where applicable —
`custom_components/houseplan/quality_scale.yaml` tracks the self-assessment.
+884
View File
@@ -0,0 +1,884 @@
# Процесс работы над House Plan
> **Статус документа: канон** (редакция 2026-08-13). Решения владельца, на
> которых он стоит: прямые коммиты в `dev` **без PR** · канон статуса — **метки**,
> имена английские · лёгкий трек **включён** · автор и ревьюер — разные модели ·
> инфраструктурные задачи идут **вне** флоу.
>
> **Область действия:** обязателен для владельца и для любого агента. Читается
> сразу после `docs/SCOPE.md` и `AGENTS.md`, до `docs/STATUS.md`. Живёт в
> репозитории: до августа 2026 канон лежал только в папке владельца, и свежий клон
> его не содержал вовсе.
>
> **Приоритет источников.** Канонический бэклог — GitHub Issues; статус живёт в
> метках и больше нигде: Project v2 не используется. При расхождении
> документации с GitHub побеждает GitHub. При расхождении этого документа с
> `.github/workflows/*.yml` и `scripts/*` побеждает **фактическая автоматизация**:
> она исполняется, а описание — нет. Расхождение при этом не игнорируется, а
> заводится issue с меткой `process`.
>
> При расхождении процесса и привычки побеждает процесс.
---
## 1. Основное правило
**Изменение продуктового кода без issue запрещено.** Код меняется только тогда,
когда issue существует и находится в статусе «Готово к разработке» или дальше.
Исключения — только §11, и каждое оставляет след.
Правило работает лишь при точной границе «продуктового кода», иначе спор
переносится на границу:
| Класс | Что входит | Нужен ли issue |
|---|---|---|
| **A. Продукт** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, `src/i18n/*.json`, `custom_components/**/translations/*` | **Да, обязательно.** Только из «Готово к разработке» или дальше |
| **B. Гейты и инструменты** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, весь `.github/**`, `.githooks/**`, `rollup.config.mjs`, `tsconfig*.json`, `package.json`, `package-lock.json`, `pytest.ini`, `.gitignore`, `.gitattributes` | **Да.** Может использовать issue того изменения, которое покрывает; самостоятельная работа над гейтом получает свой issue (тип `tech-debt`) |
| **C. Документация** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md`, `CONTRIBUTING.md`, `PROCESS*.md`, `LICENSE`, `(CODE\|SPEC)-REVIEW-*.md` | Документирование A/B в том же коммите — часть DoD своего issue. Самостоятельная работа над документацией — свой issue |
| **D. Сгенерированное** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**` | Никогда не меняется само по себе. Коммит **только** класса D допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью |
Практический смысл таблицы: «я только поправил тест» и «я только пересобрал
бандл» перестают быть лазейками.
Классы неупорядочены, но при пересечении путей **D сильнее A**: собранный бандл
лежит внутри `custom_components/houseplan/frontend/`, и без этого правила он
считался бы продуктовым исходником.
**Инфраструктурная задача идёт вне флоу** (решение владельца 2026-08-13,
issue #118). Признак механический: **ни одного файла класса A**. Такая задача
делается без ТЗ, ревью ТЗ, код-ревью и без прохода по статусам — флоу построен
для изменений, у которых есть персона и видимое поведение, а в инфраструктуре ТЗ
пересказывало бы очевидное, и автор с ревьюером оказались бы одной ролью.
Проверкой служат гейты и CI. Обязательным остаётся issue, трейлеры и зелёные
`typecheck`, `test`, `build`.
Задача, задевающая класс A хотя бы одним файлом, инфраструктурной **не
является** и идёт полным флоу. «В основном инфраструктурная» не бывает: иначе
это дорога, по которой продуктовые правки минуют ревью. Признак задан через
класс файлов, а не через самоощущение исполнителя, именно поэтому.
---
## 2. Жизненный цикл
Восемь рабочих статусов и два служебных. Фазы тестирования в цикле сознательно
**нет**: найденные позже дефекты заводятся отдельными issue и проходят цикл
заново. Issue закрывается после выпуска беты.
```
S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
→ S6-in-progress → S7-code-review ⟲ → S8-merged → закрыт при выпуске беты
служебные: blocked (поверх статуса) rejected (закрыт)
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
```
Переходы `S4-spec-review` и `S7-code-review` выполняются **автоматически**: метка
порождает событие, событие запускает ревью (§10.4). Остальные ставит исполнитель.
### 2.1 Новое — заведение задачи
- **Кто:** любой — владелец, агент, пользователь (Telegram, GitHub).
- **Вход:** проблема в пользовательских терминах; как проявляется или зачем нужно.
Решение **не требуется** и не приветствуется.
- **Запрещено:** ставить приоритет, оценивать, писать ТЗ, начинать код.
### 2.2 Аналитика и оценка
Задача разбирается — и разобранная **сама идёт дальше**. Умолчание изменено
решением владельца 2026-08-14: раньше аналитика ждала подтверждения по каждому
пункту, и большинство ожиданий ничего не меняло — issue в основном описаны
однозначно.
- **Кто:** агент-аналитик. Владелец не утверждает переход — он правит асинхронно.
- **Чек-лист**, результат — комментарием в issue:
1. дубликаты проверены (ссылки на похожие issue);
2. в скоупе по `docs/SCOPE.md` и `docs/TOUCH-SUPPORT.md`;
3. **пользовательская ценность 1–10** и **ценность для разработки** — что
упрощает или разблокирует;
4. **сложность и риск 1–10** — трудоёмкость плюс вероятность задеть смежное;
5. приоритет **P1/P2/P3**;
6. тип: баг / фича / техдолг;
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
8. трек: обычный / `small` / `trivial` по критериям §5 и §5.1.
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
согласие**, несогласие он выражает правкой меток или комментарием, и это не
останавливает работу. Право отклонить задачу (`rejected`) остаётся за
владельцем на любой стадии.
- **Вопросов владельцу на этом этапе нет.** Единственный класс вопросов, который
вообще задаётся владельцу, — продуктовые (§7.1: что человек видит или делает,
объём видимых изменений), и их место — этап ТЗ, пачкой, с вариантами по
умолчанию и `blocked`. Вопрос, который можно отложить до ТЗ, не задаётся в
аналитике; вопрос, не блокирующий написание ТЗ, не задаётся вовсе — вместо
него в ТЗ пишется блок принятых предположений.
- **Выход:** `S3-spec` — переход выполняет сам аналитик, не дожидаясь ответа.
Либо, при явном конфликте со `SCOPE.md`, — предложение отклонить с причиной:
это единственный случай, когда аналитика останавливается и ждёт владельца.
### 2.3 ТЗ в работе — написание ТЗ
- **Кто:** автор ТЗ, назначает себя. Статус означает «занято».
- **Артефакт:** `docs/specs/<NN>-<slug>.md`, где `NN` — **номер issue**.
Многоэтапная задача: `<NN>-<slug>-stage<N>.md`.
- **Лёгкий трек:** ТЗ пишется в теле issue, файл не создаётся (§5).
- **Выход:** полная первая редакция по §7.
### 2.4 ТЗ на ревью
- **Ревьюер ≠ автор.** Ревьюер получает issue и ТЗ, без устных пояснений автора.
Его задача — не согласиться, а найти, где ТЗ не выполнимо или не проверяемо.
- **Артефакт:** `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, вердикт
зелёный / жёлтый / красный. Лёгкий трек — комментарий в issue.
- **High-находки блокируют.** Medium/Low — либо правятся, либо становятся
отдельными issue со ссылкой; «оставили в тексте ревью» не считается закрытием.
- **Выход:** «Готово к разработке» либо возврат в «ТЗ в работе» — не более
4 циклов (§4).
### 2.5 Готово к разработке (DoR)
Не работа, а **очередь**: единственный статус, из которого можно трогать код.
Все пункты обязательны:
- ТЗ существует, ревью ТЗ зелёное, ссылки issue ↔ ТЗ на месте;
- **AC1…ACn** — пронумерованные проверяемые критерии приёмки; у каждого указано,
чем он доказывается: `unit` / `backend` / `smoke` / `golden` / «ревью кода»;
- перечислены затронутые файлы и модули;
- i18n: ключи en + ru перечислены;
- миграция и compatibility-поля решены по `docs/CONFIG-COMPATIBILITY.md`;
- влияние на производительность и бюджеты названо (или явно «нет»);
- влияние на touch по `docs/TOUCH-SUPPORT.md` (View и киоск — блокирующие);
- release-артефакты по правилу `docs/specs/README.md` (changelog RU+EN,
документация, golden/скриншоты, performance/security);
- **откат**: как выключить или вернуть назад (флаг Labs, обратная миграция);
- открытых продуктовых вопросов нет; риски перечислены.
Если хоть один пункт не выполнен — статус не «Готово к разработке», как бы ни
хотелось начать.
### 2.6 В разработке — реализация
- **Занятие (claim):** назначить себя, поставить метку, комментарий
«Взял: <роль> · сессия <id> · ветка `issue/<NN>-<slug>`».
- **WIP-лимиты:** не более **1** issue в «В разработке» на исполнителя, не более
**3** одновременно на цикл релиза, не более **2** в «Код-ревью».
- **Трассируемость:** ветка `issue/<NN>-<slug>`; каждый коммит несёт трейлеры
`Issue: #<NN>` и `User-Visible: yes|no`.
- **Автотесты — часть реализации, а не отдельная фаза.** Каждый AC, помеченный
`unit`/`backend`/`smoke`/`golden`, получает свою проверку здесь же.
«Тестирование вне жизненного цикла» означает отсутствие фазы ручного
тестирования, а не отсутствие тестов.
- **Скоуп не расширяется.** Найденное по пути становится новым issue в «Новое».
Если находка блокирует — текущий issue уходит в «Заблокировано» со ссылкой.
Попутных правок «раз уж я здесь» не бывает.
- **Документация — в том же коммите,** что и поведение (действующая политика
`docs/STATUS.md`): changelog RU+EN для пользовательского, `STATUS.md` для
состояния, `DEVELOPMENT.md` для новых грабель, `ARCHITECTURE.md` для дизайна.
- **Выход:** локальный гейт зелёный (§8), хендофф-комментарий (§7.2).
### 2.7 Код-ревью
- **Ревьюер ≠ исполнитель**, свежая сессия без контекста реализации.
- **Артефакт:** `docs/reviews/CODE-REVIEW-<tag|NN>-r<N>.md` в действующем
формате: скоуп, как проверялось (таблица гейтов с результатами), находки
High/Medium/Low с воспроизведением, что проверено и корректно, чего не проверял.
- **Ревьюер отвечает за AC.** Раз ручного тестирования в цикле нет, именно ревью
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
коду с явной записью «проверено чтением, не исполнением».
- **High блокируют.** Medium **обязаны** превратиться в issue.
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
4 циклов (§4).
### 2.8 Закрытие после выпуска беты
- **Вход:** изменение вошло в опубликованную бету/RC, CI Validate зелёный на
**точном SHA** тега (промоушен-правило: ни одна фича не попадает в стабильный
релиз, не побывав в бете).
- **Закрывает** релиз-менеджер, не исполнитель. Комментарий закрытия: тег беты,
ссылка на прогон CI, ссылка на бюллетень changelog.
- **Стабильный релиз статусов не двигает** — issue уже закрыты; релизный коммит
promotion-only, changelog ссылается на закрытые issue.
- **Что приходит потом:** дефект, найденный на стенде, дома или пользователем, —
**новый issue** типа «баг» со ссылкой на исходный. Исходный не переоткрывается.
### 2.9 Заблокировано / Отклонено
- **Заблокировано:** обязательна ссылка на блокирующий issue или внешнюю причину
и дата пересмотра. Без причины статус не ставится.
- **Отклонено:** закрытие с записанной причиной (вне скоупа, дубликат, цена не
оправдана). Тихое закрытие без причины запрещено.
---
## 3. Правила
Продолжение черновика владельца. Каждое правило проверяемо — глазами или машиной.
1. **Никаких изменений в код, если нет issue** и он не помечен «Готово к
разработке» или дальше.
2. **Issue не может быть взят в разработку**, пока у него нет ТЗ с зелёным ревью,
пронумерованных AC с указанием доказательства и назначенного исполнителя.
3. **Issue не может быть взят дважды.** Занятие фиксируется назначением, меткой и
комментарием с именем ветки. У одного исполнителя одновременно не более одного
issue в разработке.
4. **Статус меняется до действия, а не после.** Взял — поставил метку; отдал на
ревью — поставил метку. Метка, поставленная задним числом, — дефект процесса.
5. **Ровно одна метка статуса** на issue в любой момент. Ноль или две — дефект,
еженедельная гигиена его показывает.
6. **Автор не ревьюит своё** — ни ТЗ, ни код. Никто не переводит свою работу через
ревью-гейт.
7. **Ревью возвращает не более 4 раз.** Пятый заход — решение владельца: разделить,
отклонить или арбитраж (§4).
8. **High блокирует. Medium становится issue.** Low либо правится, либо снимается
решением ревьюера с записью в документе.
9. **Скоуп не расширяется.** Всё найденное вне ТЗ — новый issue, а не попутная
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
`issue/NN-slug`, а `User-Visible: yes` требует правок в **обоих** changelog в
том же коммите.
11. **Документация — в том же коммите, что поведение.** Отдельным «допишу потом»
коммитом документация не бывает.
12. **Сгенерированное не коммитится само по себе.** Только релизный промоушен или
принятие эталонов со ссылкой на прогон CI.
13. **Golden-эталоны принимаются только** `npm run golden:accept -- --reviewed` по
полному Linux-артефакту. Принятие ради зелёного CI — нарушение процесса.
14. **Issue закрывается после выпуска беты** с зелёным CI на точном SHA. Не
раньше, не «по факту наличия кода», не исполнителем.
15. **Закрытый issue не переоткрывается.** Новый дефект — новый issue со ссылкой.
16. **Стабильный релиз — promotion-only:** версии, сгенерированные бандлы,
changelog и release-метаданные. Продуктового кода там нет.
17. **История `dev` не перезаписывается.** На неё ссылаются теги. Нарушение
исправляется следующим коммитом плюс issue с меткой `process` — не
force-push'ем.
18. **AC доказывает автотест или запись ревьюера.** Фразы «проверил локально, всё
работает» в процессе не существует: либо тест, который умеет падать, либо
честное «проверено чтением, не исполнением».
19. **Параллельных бэклогов нет.** Планы, разборы и приоритеты живут в issue;
файловые отчёты — разовые и датированные.
20. **Аварийный хотфикс — только решением владельца** и только по §11.2.
---
## 4. Лимит циклов ревью: 4
Оба ревью-гейта возвращают задачу на правки не более **4 раз**. Счётчик виден в
имени документа: `-r1` … `-r4`; на четвёртом заходе ставится метка `review-4`.
- **Что считается циклом:** отправка на ревью → вердикт с блокирующими находками
→ возврат. Уточняющий вопрос без вердикта циклом не считается.
- **Исчерпание лимита — не «пятая попытка», а разбор.** Задача уходит владельцу,
решение одно из трёх:
1. **разделить** — issue закрывается как «заменён», вместо него 2–3 меньших с
ясным скоупом (частый настоящий диагноз: ТЗ было слишком большим);
2. **отклонить** — цена решения оказалась выше ценности;
3. **арбитраж владельца** — владелец фиксирует решение в issue, оно принимается
как есть; несогласие ревьюера записывается, но не блокирует.
- **Граница между «циклом» и «новым багом»:** до закрытия беты находка ревьюера —
возврат на правки; после закрытия — новый issue. Иначе лимит 4 обходится
заведением issue вместо возврата.
- Для лёгкого трека лимит ревью ТЗ — **2** цикла: задача на три часа, которую
переписывают трижды, лёгкой не была.
---
## 5. Лёгкий трек (метка `small`)
**Критерии — все одновременно:**
- сложность и риск ≤ 3;
- одна поверхность (один диалог, один модуль, один эндпоинт);
- нет миграции конфига и новых compatibility-полей;
- нет нового UX-контракта — меняется поведение в рамках уже описанного;
- нет влияния на производительность и на touch-контракт.
**Что упрощается:**
- ТЗ пишется **в теле issue** по шаблону: проблема · контракт · AC1…ACn с
доказательством · откат. Файл в `docs/specs/` не создаётся;
- ревью ТЗ — комментарий второго агента, отдельный документ не нужен;
- лимит ревью ТЗ — 2 цикла.
**Что не упрощается:** issue, оценка, статусы, трейлеры коммитов, changelog,
**код-ревью и его документ**, закрытие после беты. Код-ревью не пропускается
никогда — именно оно в этом процессе заменяет тестирование. Единственное
исключение — починка упавшего предрелизного гейта, §11.4.
Если по ходу выясняется, что критерий нарушен (появилась миграция, задело второй
модуль) — метка `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. Роли
Один агент может исполнять несколько ролей в разных issue, но **не две роли в
одном артефакте**.
| Роль | Делает | Не имеет права |
|---|---|---|
| Аналитик | разбор, оценки, поверхности | окончательно ставить приоритет |
| Автор ТЗ | `docs/specs/NN-*.md` или ТЗ в issue | ревьюить своё ТЗ |
| Ревьюер ТЗ | `docs/reviews/SPEC-REVIEW-NN-rN.md` | править ТЗ вместо автора |
| Разработчик | код, автотесты, документация, changelog | ревьюить свой код, принимать golden |
| Ревьюер кода | `docs/reviews/CODE-REVIEW-*-rN.md`, проверка AC | править продуктовый код |
| Релиз-менеджер | пре-релиз, стабильный релиз, закрытие issue | добавлять код в релизный коммит |
| Владелец | приоритет, ценность, скоуп, отклонение, арбитраж, хотфикс | — |
**Правило разделения:** ревьюер работает состязательно. Ему передаётся тег или
диапазон коммитов и ТЗ — не рассказ автора о том, как всё хорошо.
**Роли закреплены за исполнителями** (решение владельца 2026-08-12):
| Исполнитель | Роли |
|---|---|
| **Codex** | аналитик, автор ТЗ, разработчик, релиз-инженер по команде владельца |
| **Claude** | ревьюер ТЗ, ревьюер кода, вся инфраструктура и дистрибуция |
| **Владелец** | приоритет, скоуп, арбитраж, закрытие issue, команда на выпуск |
Автор и ревьюер — **разные модели**, и это сильнее требования «другая сессия»:
одна модель, читая свой же артефакт заново, повторяет свои же слепые пятна.
Ревью ТЗ и код-ревью держатся в **разных сессиях** Claude: ревьюер кода не должен
приходить с контекстом того, как обсуждали ТЗ.
---
## 7. Артефакты и трассируемость
### 7.1 Цепочка
```
issue #NN
↔ ТЗ docs/specs/NN-slug.md (или тело issue при `small`)
↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (или комментарий при `small`)
↔ ветка issue/NN-slug
↔ коммиты трейлеры Issue: #NN · User-Visible: yes|no
↔ ревью кода docs/reviews/CODE-REVIEW-<tag|NN>-rN.md
↔ changelog бюллетень RU+EN со ссылкой на #NN
↔ бета тег, зелёный CI на точном SHA → закрытие
```
Обязательные разделы ТЗ: **сценарий** · **что человек увидит до и после** ·
проблема · скоуп и **не-скоуп** · контракт поведения · UX · модель данных и
миграция · i18n · критерии приёмки AC1…ACn с указанием доказательства · план
автотестов · риски · откат · release-артефакты.
Два первых раздела — продуктовые, и они идут первыми не случайно. **Сценарий:**
какая персона (`docs/SCOPE.md`), на какой поверхности, в какой момент это
встретит. **Что человек увидит:** одной фразой, без терминов реализации. ТЗ,
которое не может ответить на эти два вопроса, описывает работу, а не изменение
продукта.
**Размытое место не додумывается, а выносится владельцу.** Догадка, записанная
как факт, — худший вид дефекта: она проходит ревью, потому что выглядит решением.
Но спрашивать обо всём нельзя: владелец один, и анкета из двадцати пунктов хуже
угадывания. Порог такой (решение владельца 2026-08-13).
**Владельцу задаются только продуктовые вопросы** — что человек видит или делает
и какой объём видимых изменений входит в этот issue. Поведение в пограничном
случае; какая из персон важнее в конфликте; что считать приемлемой деградацией;
относится ли смежное поведение сюда или становится отдельной задачей.
**Всё, чего пользователь не наблюдает, агенты решают сами** либо согласовывают
между собой: где хранится состояние, в каком модуле стоит гвард, именование,
раскладка файлов, стратегия тестов, механика миграции. Решение записывается явным
блоком в конце ТЗ — «принято предположительно, поменять свободно», и ревьюер
вправе его оспорить. Технический спор автора и ревьюера решается вердиктом, а не
владельцем; до него он доходит только при исчерпании лимита циклов (§4).
**Смешанный вопрос делится, а не эскалируется целиком.** «Где живёт это
состояние» — техническое. «Переживает ли оно перезагрузку страницы и общее ли оно
для всех экранов» — продуктовое.
Вопросы задаются **одним комментарием, пачкой**, каждый в форме: что неясно ·
что изменится от ответа · **предлагаемый вариант по умолчанию**. Вопрос с готовым
вариантом стоит владельцу пяти секунд, вопрос без него — пяти минут. Пока ждём
ответа, issue остаётся в `S3-spec` и получает `blocked`: статус не подменяется,
`blocked` его дополняет, иначе конвейер считает задачу в работе, а она стоит.
### 7.2 Шаблоны комментариев
Короткие и однообразные, чтобы читались и человеком, и машиной.
- **Аналитика:** `Оценка: ценность N/10 · сложность N/10 · P<1-3> · тип ·
поверхности: … · дубликаты: … · лёгкий трек: да/нет`
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · цикл r<N>/<лимит> ·
High: N · Medium: N → #… · Документ: docs/reviews/…`
- **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>`
**Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору;
разница между ними содержательна для человека, но не для маршрута. Первая
редакция конвейера (§10.4) пропускала жёлтый при `High: 0`, и первый же живой
прогон показал, почему это неверно: жёлтый там означал, что AC описывает неверное
изменение контракта — реализовать такое ТЗ значило бы сделать ошибку по инструкции.
### 7.3 Расхождения с текущим состоянием, которые надо закрыть
1. **Статус ТЗ дублирует статус issue.** `docs/specs/README.md` держит колонку
«Статус ТЗ» со своим словарём («черновик решения», «в реализации»,
«реализовано»). Два источника статуса уже расходятся. Колонку убрать, оставить
таблицу «issue ↔ ТЗ».
2. **Ревью до релиза 1.62 живут вне репозитория.** Документы `CODE-REVIEW-*.md` и
`SPEC-REVIEW-*.md` за прежний период лежат в папке владельца, и переносить их
задним числом смысла нет: они описывают код, которого уже нет. Новые документы
ревью кладёт в `docs/reviews/` сам конвейер, в ветку задачи.
---
## 8. Гейты
**Локальный гейт перед выходом из «В разработке»** — минимальный набор,
покрывающий изменённые поверхности (действующее правило владельца):
```
npx tsc --noEmit
npm test
npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js \
&& cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
node demo/smoke_<целевые>.mjs
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 тега.
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
Упавший предрелизный гейт автор чинит и повторно прогоняет; зелёный прогон
достаточен для продолжения релиза, повторное код-ревью не требуется — §11.4.
**Гейт стабильного релиза:** полный локальный прогон плюс Validate и Full
Performance зелёные на точном SHA; статусов issue не касается.
---
## 9. Метки — канонический статус
Статус читается из меток: их видно в списке issue, их читает любой токен с
доступом к Issues, и по ним же работает конвейер — смена метки порождает событие
(§10.4). **Project v2 не используется** (решение владельца 2026-08-14): второе
представление статуса рядом с метками требовало отдельного скоупа токена,
синхронизации и внимания, а давало вид доски. Два источника одного факта
расходятся — это уже случалось с колонкой «Статус ТЗ» в `docs/specs/README.md`.
**Имена меток английские** (решение владельца 2026-08-12). Русские имена в этом
документе были только на бумаге; репозиторий с самого начала жил на английских.
| Метка | Статус |
|---|---|
| `S1-new` | Новое, не разобрано |
| `S2-analysis` | Аналитика и оценка |
| `S3-spec` | ТЗ в работе |
| `S4-spec-review` | ТЗ на ревью |
| `S5-ready` | Готово к разработке — единственный статус, из которого можно начать трогать код |
| `S6-in-progress` | В разработке, занято исполнителем |
| `S7-code-review` | Код-ревью |
| `S8-merged` | Ревью пройдено, код в `dev`, ждёт беты. Issue закрывается пачкой при выпуске |
| `blocked` | Ждём внешнего или владельца, **поверх** статусной метки |
| `rejected` | Отклонено, issue закрыт |
Модификаторы: `small` (лёгкий трек, сложность ≤3), `trivial` (короткий трек,
§5.1), `hotfix`, `process`, `review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
ортогональны процессу.
Инварианты: **ровно одна `S*`-метка** на открытом issue; закрытый issue статусных
меток не несёт; `blocked` не заменяет статус, а дополняет его.
**Чужой issue берётся в работу так же, как свой — после явного решения
владельца** (решение владельца 2026-08-13, уточнено в тот же день). Репозиторий
публичный, отчёты заводят и посторонние; проверка стоит **на входе**, а не на
каждом шаге.
Входом служит присвоение первой статусной метки: пока меток нет, issue вне
процесса и инварианты на него не распространяются. Как только метка стоит, задача
в работе, и **кто её завёл, дальше не имеет значения** — статусы, ревью и лимиты
работают одинаково.
Присвоение метки и есть то самое явное решение, причём проверенное платформой:
метки может ставить только тот, у кого есть право записи в репозиторий. Прежняя
редакция требовала переоформлять чужой отчёт своим issue со ссылкой на исходный;
это оказалось работой впустую — на #123 к моменту отказа ТЗ уже было написано.
`S8-merged` появился позже остальных и закрывает разрыв, который раньше
закрывался памятью человека: код принят, но бета ещё не вышла, и issue закрывать
рано. Без него принятая задача либо висела в `S7-code-review`, либо закрывалась
досрочно.
---
## 10. Механизация при прямых коммитах в `dev`
Решение владельца — работать без PR. Значит, GitHub не может ничего заблокировать
на своей стороне: **основной гейт переезжает на клиента, CI остаётся страховкой.**
### 10.1 Хуки, которые невозможно забыть поставить
`.githooks/` в репозитории, `core.hooksPath` выставляется автоматически при
установке зависимостей:
```json
"scripts": { "prepare": "node scripts/install-hooks.mjs" }
```
`npm ci` вызывает `prepare` сам — значит, хуки появляются в каждом окружении,
включая свежий контейнер облачного агента, без отдельного шага в инструкции.
- **`commit-msg`** — есть, работает. Отклоняет коммит без терминального
`Issue: #NN`, требует ровно один `User-Visible: yes|no`, а для коммитов,
трогающих `demo/golden/baselines/**`, — `Release:` плюс `Baseline-Reviewed:`.
Реализация — `scripts/validate-commit-provenance.mjs`, тот же скрипт вызывается
job `provenance` в `validate.yml`.
- **`pre-push`** — есть, работает. Прогоняет `scripts/process-gate.mjs` по каждому
пушимому ref и останавливает push при нарушении. Это и есть блокирующий гейт
вместо PR. Удаление ветки и теги пропускаются: в первом случае проверять нечего,
во втором коммит уже проверен, когда его пушили. Для новой ветки диапазон
считается от `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`, поэтому при его отсутствии хук печатает
предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук,
который не работает в самолёте, отключают целиком, а строгий проход всё равно
делает CI.
**Хук обязан быть исполняемым, и это тише всего ломается.** Git **молча** не
запускает файл без бита `+x`: гейт сообщает об успехе тем, что его нет. Проверено
на настоящем push — при `644` от гейта ноль строк и push проходит, при `755` он
останавливается.
Через GitHub API режим не выставляется: файл, отправленный так, приезжает
`100644`. Поэтому `scripts/install-hooks.mjs` восстанавливает бит при каждой
установке зависимостей, а `assertHookMode` дополнительно проверяет бит
`.githooks/commit-msg` в индексе. Правится вручную:
`git update-index --chmod=+x .githooks/<хук>`.
### 10.2 Что проверяет `process-gate.mjs`
Реализовано, `scripts/process-gate.mjs`, issue #105. Офлайн, без GitHub API:
1. трейлер `Issue: #NN` у каждого коммита класса A/B, допускается несколько;
2. имя ветки `issue/NN-slug` соответствует трейлерам;
3. для класса A существует `docs/specs/NN-*.md` — **или** issue помечен `small`.
Офлайн это предупреждение: лёгкий трек держит ТЗ в теле issue, и без чтения
меток «ТЗ в issue» неотличимо от «ТЗ не написано». С `--issues` — отказ;
4. `User-Visible: yes` → правки в обоих changelog в том же коммите;
5. коммит только класса D невалиден без `Release: vX.Y.Z` либо
`Baseline-Reviewed: <ссылка на прогон CI>`;
6. релизный коммит не содержит изменений в `src/` и `custom_components/**/*.py`;
7. документов ревью на один issue не больше четырёх (`-r1`…`-r4`).
С токеном GitHub:
8. `--issues` тянет каждый упомянутый issue и требует метку из
{`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`}; закрытый,
недоступный или помеченный `blocked` — отказ (**fail closed**).
Три оговорки к проверке 8 выяснились при реализации.
**`S8-merged` входит в множество**, хотя по смыслу задача уже принята. Причина
механическая: конвейер (§10.4) сливает ветку в `dev` **раньше**, чем ставит метку,
Validate стартует от этого push и успевает прочитать issue уже в `S8-merged`.
Строгое множество красило бы каждую принятую задачу. Локальная строгость
возвращается флагом `--no-merged`.
**Статус спрашивается только у коммитов класса A/B.** Правило №1 говорит о
продуктовом коде и инструментах, а не о документации. Иначе краснел бы каждый
документ ревью: он ложится в ветку задачи, пока та в `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
зелёный вердикт код-ревью;
10. закрытие issue и снятие статусных меток при публикации беты делаются руками —
`node process-labels/apply.mjs cleanup --apply`, а не `publish-prerelease.yml`.
Пропуск этого шага уже ломал инвариант «закрытый issue без статусной метки».
### 10.3 Страховка и разбор
- **`process-gate.mjs` — job `process-gate` в `validate.yml`**, без `needs`:
краснеет сам и не роняет остальные. При прямом push проверка догоняющая: код уже
в `dev`, CI краснеет после. Это принятая цена отказа от PR: `pre-push` ловит
нарушение до отправки, а этот job — то, что прошло мимо хука, включая
`--no-verify` и окружение без установленных зависимостей.
- **Нарушение не откатывается force-push'ем** (правило 17): исправляющий коммит
плюс issue с меткой `process`. Починить надо проверку, а не только симптом.
- **Еженедельная гигиена** (workflow): issue в `S1-new` дольше 14 дней и в
`S6-in-progress` дольше 7; issue класса A в `S5-ready` без ТЗ; issue с нулём или
двумя `S*`-метками; коммиты без трейлера за неделю — **цель 0**; rework rate и
число issue, дошедших до `review-4`; **баги, заведённые после закрытия беты** —
прямая цена отказа от фазы тестирования.
### 10.4 Событийный конвейер: метка как триггер
`.github/workflows/process.yml`, issue #114. Смена статусной метки — не запись в
журнал, а **сообщение**: она порождает событие, событие запускает следующий шаг.
```
S4-spec-review → ревью ТЗ → S5-ready либо возврат в S3-spec
S7-code-review → код-ревью → слияние в dev → S8-merged либо возврат в S6-in-progress
```
Ревьюер — `anthropics/claude-code-action`. Он читает `docs/SCOPE.md`, `AGENTS.md`,
этот документ и тело issue, публикует разбор комментарием, заводит issue на каждую
Medium-находку, кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
структурированным JSON. **Метку переставляет отдельный детерминированный шаг по
вердикту, а не модель.**
Четыре вещи, без которых конвейер молча не работает:
1. метки переставляет **PAT**, а не `GITHUB_TOKEN`: GitHub намеренно не порождает
события от `GITHUB_TOKEN`, чтобы не было циклов, и цепочка обрывалась бы после
первого шага без ошибок в логах;
2. `process.yml` обязан лежать в **ветке по умолчанию**: для события `issues`
GitHub берёт workflow только оттуда, независимо от содержимого `dev`;
3. слияние в `dev` происходит **до** простановки `S8-merged`, иначе метка врёт в
промежутке — она утверждает, что код в `dev`;
4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым
отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
**Автор обязан дождаться вердикта, а не заканчивать сессию.** Ревью идёт от десяти
минут до сорока пяти. Отчёт «передал на ревью» останавливает конвейер там, где он
мог идти сам: вердикт придёт, а подхватить его будет некому. У агента нет часов —
он существует только в момент своего хода, поэтому ожидание это опрос: раз в 90
секунд, не более 30 попыток. Смотреть на метку, а не на комментарий: метка и есть
состояние. При `blocked` не ждать — задача ждёт владельца.
**После прогона ревью метка меняется всегда.** Инвариант появился не сразу: первая
редакция при конфликте слияния оставляла метку на месте, и это оказалось тупиком —
автор ждёт смену метки, метка не менялась, и он тридцать раз опрашивал впустую,
чтобы отчитаться «лимит исчерпан» при зелёном вердикте. Состояние, из которого
никто не может выйти и о котором никто не узнает, для конвейера хуже громкой
ошибки.
Поэтому зелёное код-ревью с неудавшимся слиянием ведёт не в `S8-merged`, а в
`S6-in-progress`: работа действительно вернулась к автору, только осталась не
правка кода, а ребейз. Вердикт при этом в силе, переделывать нечего. После ребейза
метка `S7-code-review` возвращается и ревью идёт заново — не формальность:
после ребейза на ушедший вперёд `dev` это другой код.
Если метка не сменилась, значит упал сам прогон, а не работа: смотреть логи и
сообщать владельцу, а не продолжать опрос.
Цикл считается **по этапу**: вердикт по ТЗ не расходует бюджет код-ревью. Раньше
считались все вердикты подряд, и первое код-ревью #89 получило `r2/4`.
---
## 11. Исключения
### 11.1 Лёгкий трек
См. §5 — это не исключение из правила №1, а более дешёвый путь по тем же статусам.
### 11.2 Аварийный хотфикс (метка `hotfix`, решение владельца)
Разрешено писать код до появления issue. Обязательно:
- issue создан в **той же сессии до коммита**, метка `hotfix`;
- ТЗ «как сделано» + раздел «почему нельзя было ждать»;
- в течение 24 часов задача ретроспективно проходит код-ревью;
- аварийность названа явно в релизном хендоффе (действующее правило `AGENTS.md`).
### 11.3 Гигиена репозитория
Механические изменения без изменения поведения (форматирование, мёртвые файлы)
идут под квартальный umbrella-issue «Гигиена репозитория»; каждый коммит
ссылается на него. Трассируемость 1:1 сохраняется.
### 11.4 Починка предрелизных гейтов без повторного код-ревью
Решение владельца 2026-08-13.
В цикле реализации гоняется только лёгкий набор — typecheck, unit, build (§8).
Golden, браузерные смоки, performance и полный HA-харнесс запускаются перед бетой,
то есть **после** того, как код-ревью пройдено и issue в `S8-merged`. Часть
проблем физически не может быть найдена раньше.
**Если предрелизный гейт упал, автор правит, повторно прогоняет упавшее, и
зелёного прогона достаточно, чтобы релиз продолжился.** Issue остаётся в
`S8-merged` и на повторное код-ревью не отправляется.
Причина: полный цикл ревью в момент выпуска стоит дороже, чем риск, который он
здесь снимает. Гейт уже назвал дефект точно, а исправление проверяется тем же
гейтом — то есть проверка объективна и не зависит от чьего-либо суждения.
**Что при этом обязательно:**
- прогон упавшего гейта записан в issue: **точная команда и её результат**.
«Verified» без команды доказательством не является (§8);
- трейлеры на коммите как обычно, `Issue: #NN` того же issue;
- при `User-Visible: yes` — правки в оба changelog в том же коммите;
- эталоны golden принимаются только через `npm run golden:accept -- --reviewed`
на полном артефакте Linux CI. «Чтобы гейт позеленел» основанием не является.
**Границы, за которыми исключение не действует.** Оно про починку названного
гейтом дефекта, а не про продолжение разработки под видом починки. Правка идёт
обычным путём — новым issue либо возвратом в `S6-in-progress` — если она:
- меняет контракт поведения или добавляет пользователю что-то новое;
- задевает подсистему, которой в исходной задаче не было;
- по объёму сопоставима с самой задачей;
- меняет сам гейт вместо кода — правка теста, чтобы он перестал падать, это не
починка, а сокрытие. Исключение — когда дефект **в фикстуре** и это доказано
разбором, как на #89: солнце на азимуте 180° и единственное окно на северной
стене, поэтому луч честно не строился.
Границу определяет автор, и здесь процесс сознательно отдаёт ему то, что в
остальных местах не доверяет — оценку собственной работы. Плата за скорость в
единственной точке, где цикл ревью стоит дороже всего. Компенсируется тем, что
запись в issue публична и релиз-менеджер видит, что именно было сделано перед
выпуском.
Это исключение из правила «код-ревью не пропускается никогда» (§5, §7.1) —
единственное, и относится только к окну между `S8-merged` и выпуском.
---
## 12. Запрещено
- код без issue или из статуса раньше «Готово к разработке»;
- ТЗ, написанное после кода (кроме §11.2, и тогда с пометкой «как сделано»);
- ревью своей работы; перевод своей работы через ревью-гейт;
- пятый цикл ревью вместо разбора по §4;
- заведение issue вместо возврата на правки, чтобы обойти лимит циклов;
- принятие golden-эталонов ради зелёного CI или по частичному артефакту;
- закрытие issue до выпуска беты с зелёным CI;
- переоткрытие закрытого issue вместо нового бага;
- Medium-находки, оставленные как TODO в документе ревью;
- **параллельные бэклоги** в файлах (`BACKLOG-*.md`, «планы» в docs);
- ревью-документы вне репозитория;
- попутные правки «раз уж я здесь»;
- фича или материальное изменение поведения в стабильном релиз-коммите;
- force-push в `dev`;
- ручное копирование на домашний инстанс.
**Нарушение процесса — тоже issue** (метка `process`): если правило удалось
нарушить незаметно, виновата проверка.
---
## 13. Внедрение
Состояние на 2026-08-13.
1. ✅ **Метки созданы, бэклог размечен.** У всех открытых issue владельца ровно
одна `S*`-метка, инварианты чистые.
2. ⏳ **Колонку «Статус ТЗ» из `docs/specs/README.md` убрать** — не сделано, §7.3
п.1. Перенос старых документов ревью в `docs/reviews/` отменён: они описывают
код, которого уже нет.
3. ✅ **Гейт написан** — `scripts/process-gate.mjs` плюс job в `validate.yml`,
issue #105. Прошёл **вне** флоу как инфраструктурная задача (§1, issue #118), а
не через ТЗ и ревью, как предполагала прежняя редакция этого пункта.
4. ✅ **Долг ревью списан решением владельца.** Беты `beta.2`…`beta.10` сделаны по
прежнему процессу и не пересматриваются. Точка отсчёта — релиз 1.62.0; отсчёт
начинается с первой беты следующей линии.
5. ⏳ Завести issue на находку «смок `visual_continuity` не умеет падать» — это
ровно тот класс дефектов, который в процессе без ручного тестирования стоит
дороже всего.
6. ✅ `BACKLOG-2026-08-11.md` — разовый отчёт, решения живут в issue.
7. ✅ `AGENTS.md` переписан целиком, шире блока §14.
8. ✅ **Канон перенесён в репозиторий** (issue #112). До этого полный процесс жил
только в папке владельца, а в репозитории лежал файл на 51 строку про трейлеры
коммитов — из свежего клона канон не был виден вообще.
9. ✅ **`pre-push` написан** (§10.1, issue #121). Блокирующая проверка на клиенте
есть; обойти её можно только `--no-verify`, и тогда то же найдёт CI.
---
## 14. Блок для AGENTS.md
```markdown
## Процесс: код только через issue
Изменение продуктового кода без issue запрещено. Код меняется только из статуса
«Готово к разработке» или дальше. Полные правила, критерии статусов и гейты —
`docs/PROCESS.md`, читать до начала работы.
Жизненный цикл (статус = метка issue): `S1-new` → `S2-analysis` → `S3-spec` →
`S4-spec-review` → `S5-ready` → `S6-in-progress` → `S7-code-review` → `S8-merged`
→ закрытие пачкой при выпуске беты. Оба ревью возвращают на правки не более 4
циклов; пятый заход — разбор у владельца (разделить / отклонить / арбитраж).
Ревью запускается **само** от меток `S4-spec-review` и `S7-code-review` и идёт до
45 минут. Поставив такую метку, автор не заканчивает работу, а ждёт смены метки
опросом и продолжает по тому, чем она стала.
- ветка `issue/<NN>-<slug>`, коммиты с трейлерами `Issue: #NN` и `User-Visible: yes|no`;
- работаем прямыми коммитами в `dev`, без PR: блокирующий гейт — локальный
`pre-push` (ставится автоматически через `npm ci`), CI — страховка. Force-push
в `dev` запрещён;
- автор ≠ ревьюер, ни для ТЗ, ни для кода;
- фазы ручного тестирования нет: автотесты пишутся в реализации, AC проверяет
код-ревью, найденные позже дефекты — новые issue типа «баг»;
- мелкие задачи (метка `small`, сложность ≤3): ТЗ в теле issue, ревью ТЗ
комментарием, код-ревью — как обычно;
- найденное вне скоупа — новый issue, а не попутная правка;
- issue закрывает релиз-менеджер после выпуска беты, не исполнитель.
```
+20 -6
View File
@@ -278,12 +278,15 @@ Switch to the **Devices** tab to arrange icons: drag them with the mouse, click
### Tap actions: control devices from the plan
By default a tap on an icon opens its info card. In the card settings you can switch
**Tap on a device** to *Toggle* — a tap then switches lights, sockets, fans and
humidifiers directly on the plan (wall-tablet style). For safety, a card-wide toggle
never affects locks, alarms, covers or valves; you can consciously enable toggle for a
specific device (except locks and alarms — those never toggle from the plan) in its
edit dialog. A **long press** always opens the info card.
By default a tap on an icon opens its info card. A device can instead use the
universal **Toggle state** action. Its editor shows the exact entity or configured
group, the current state and what the next tap will do; when nothing can be toggled,
it says so and the tap is a quiet no-op rather than an unexpected info-card fallback.
Lights keep their convenient toggle default. Covers and valves use open/close/stop
semantics automatically, while locks, alarm panels and secure garage/door/gate covers
remain blocked. An exact entity binding never falls through to a sibling switch, and
temporarily unavailable group members are skipped without erasing the configuration.
A **long press** still opens the info card and right-click still opens HA more-info.
### Icon rules
@@ -309,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.62.0-beta.2"
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
+20 -17
View File
@@ -86,27 +86,30 @@ class HouseplanImportPreviewView(HomeAssistantView):
blocks.append(block)
owner_id = str(getattr(user, "id", ""))
try:
# Hold the global writer only while taking one coherent store
# snapshot. Parsing up to 8 MiB, schema validation and space remap
# are CPU work and apply will revalidate both revisions anyway.
async with runtime.write_lock:
config_data = await runtime.config_store.async_load() or {}
layout_data = await runtime.store.async_load() or {}
try:
registry_snapshot = import_registry_snapshot(hass)
except Exception: # noqa: BLE001 - summary must not block a valid backup
_LOGGER.debug("House Plan import registry summary unavailable", exc_info=True)
registry_snapshot = None
result = await hass.async_add_executor_job(
partial(
create_preview,
runtime,
b"".join(blocks),
owner_id=owner_id,
duplicate_policy=policy,
current_config_data=config_data,
current_layout_data=layout_data,
config_root=Path(hass.config.path("")),
registry_snapshot=registry_snapshot,
)
try:
registry_snapshot = import_registry_snapshot(hass)
except Exception: # noqa: BLE001 - summary must not block a valid backup
_LOGGER.debug("House Plan import registry summary unavailable", exc_info=True)
registry_snapshot = None
result = await hass.async_add_executor_job(
partial(
create_preview,
runtime,
b"".join(blocks),
owner_id=owner_id,
duplicate_policy=policy,
current_config_data=config_data,
current_layout_data=layout_data,
config_root=Path(hass.config.path("")),
registry_snapshot=registry_snapshot,
)
)
except ImportFailure as err:
status = 413 if err.code == "too_large" else 400
return web.json_response({"error": err.code, "message": err.message}, status=status)
@@ -45,6 +45,7 @@ from .validation import (
sanitize_filename,
sanitize_marker_id,
validate_marker_controls,
validate_marker_light_entities,
validate_marker_value_badges,
MarkerControlError,
)
@@ -72,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)
@@ -238,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 = ""
@@ -246,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()
@@ -357,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:
@@ -559,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 []
@@ -719,6 +758,7 @@ def build_space_merge(
raise ImportFailure("invalid_config", str(err)) from err
try:
validate_marker_controls(merged_config, current_config)
validate_marker_light_entities(merged_config, current_config)
validate_marker_value_badges(merged_config, current_config)
except MarkerControlError as err:
raise ImportFailure(err.code, str(err)) from err
@@ -891,6 +931,7 @@ def create_preview(
details["dropped_marker_links"] = dropped_marker_links
try:
validate_marker_controls(incoming_config, validate_all=True)
validate_marker_light_entities(incoming_config, validate_all=True)
validate_marker_value_badges(incoming_config, validate_all=True)
except MarkerControlError as err:
raise ImportFailure(err.code, str(err)) from err
@@ -1036,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.62.0-beta.2"
"version": "1.64.0"
}
+91 -6
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,
),
)
@@ -88,6 +132,7 @@ def get_entry(hass: HomeAssistant) -> ConfigEntry | None:
OPTIMIZE_BACKUP = "optimize_backup"
OPTIMIZE_PENDING = "optimize_pending"
LAYOUT_STORE_CORE_KEYS = frozenset({"layout", "rev"})
def layout_store_payload(
@@ -106,7 +151,7 @@ def layout_store_payload(
the next drag. All writers now express only the metadata they intentionally
add/remove and this helper preserves the rest.
"""
excluded = {"layout", "rev", *remove}
excluded = {*LAYOUT_STORE_CORE_KEYS, *remove}
out = {} if replace_metadata else {
key: value for key, value in stored.items() if key not in excluded
}
@@ -138,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
+36 -4
View File
@@ -41,6 +41,7 @@ VALUE_BADGE_POSITIONS = {"right", "bottom", "left", "top"}
VALUE_BADGE_SOURCE_KINDS = {
"entity_state", "entity_attribute", "derived_lqi", "derived_marker_state",
}
_LIGHT_ENTITY_RE = re.compile(r"^(?:light|switch)\.[a-z0-9_]+\Z")
def _matching_previous_marker(
@@ -131,6 +132,38 @@ def validate_marker_value_badges(
raise MarkerControlError("value_badge_marker_not_light", "Marker value badge target is not a forced light")
def validate_marker_light_entities(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate new/changed leading-light choices without rejecting dormant data.
The top-level schema must stay lossless: an old or future literal that the
current frontend cannot edit may round-trip unchanged. Imports validate the
whole incoming document because every imported value is new to this plan.
"""
markers = config.get("markers") or []
old_markers = (previous or {}).get("markers") or []
old_by_id = {str(marker.get("id")): marker for marker in old_markers}
new_ids = {str(marker.get("id")) for marker in markers}
consumed_old_ids: set[str] = set()
for marker in markers:
marker_id = str(marker.get("id"))
old_marker = _matching_previous_marker(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
value = marker.get("light_entity")
old_value = None if validate_all else (old_marker or {}).get("light_entity")
if not validate_all and value == old_value:
continue
if value is None:
continue
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
raise MarkerControlError(
"invalid_light_entity", "Leading light entity must be light.* or switch.*"
)
def validate_marker_controls(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
@@ -849,10 +882,9 @@ MARKER_SCHEMA = vol.Schema(
# Explicit leading entity for composite Always sources. It is kept
# literally when temporarily absent; runtime falls back without
# deleting the user's choice.
vol.Optional("light_entity"): vol.Any(
None,
vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(?:light|switch)\.[a-z0-9_]+\Z")),
),
# Semantic delta validation below the schema preserves unknown/future
# literals until that exact field is edited (lossless config doctrine).
vol.Optional("light_entity"): object,
vol.Optional("value_badge"): vol.Any(
None,
vol.Schema(
@@ -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"],
}
+135 -15
View File
@@ -28,6 +28,7 @@ from .const import (
from .auth import may_write
from .import_export import (
ImportFailure,
content_manifest,
create_export,
get_candidate,
live_layout,
@@ -39,18 +40,26 @@ from .plans import (
plan_basename, plan_refs, reserve_filename,
)
from .store import (
LAYOUT_STORE_CORE_KEYS,
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,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, sanitize_filename,
validate_marker_controls, validate_marker_value_badges, valid_space_id,
validate_marker_controls, validate_marker_light_entities,
validate_marker_value_badges, valid_space_id,
)
@@ -123,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)
@@ -167,7 +177,7 @@ def _layout_metadata(stored: dict[str, Any]) -> dict[str, Any]:
"""Return the exact non-layout portion of a layout-store document."""
return {
key: value for key, value in stored.items()
if key not in {"layout", "rev"}
if key not in LAYOUT_STORE_CORE_KEYS
}
@@ -191,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,
@@ -397,6 +408,14 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
"missing_plan",
"Plan file no longer exists: " + ", ".join(sorted(missing)),
)
missing_attachments = _missing_internal_attachments(
Path(hass.config.path("")), target_config, config_data.get("config")
)
if missing_attachments:
raise ImportFailure(
"missing_content",
"Attachment no longer exists: " + ", ".join(sorted(missing_attachments)),
)
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
backup = None
@@ -410,9 +429,12 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
"after_layout_rev": new_layout_rev,
}
original_metadata = _layout_metadata(layout_data)
replaced_metadata = {_OPTIMIZE_PENDING, _OPTIMIZE_BACKUP}
if kind == "full":
replaced_metadata.update({"repair_backup", "geom_pending"})
final_metadata = {
key: value for key, value in original_metadata.items()
if key not in {_OPTIMIZE_PENDING, _OPTIMIZE_BACKUP}
if key not in replaced_metadata
}
if backup is not None:
final_metadata[_OPTIMIZE_BACKUP] = backup
@@ -621,6 +643,7 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
vol.Optional("dry_run"): bool,
vol.Optional("undo"): bool,
vol.Optional("expected_rev"): int,
}
)
@websocket_api.async_response
@@ -653,6 +676,12 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
data = await rt.store.async_load() or {}
layout = data.get("layout") or {}
current_rev = int(data.get("rev", 0))
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
connection.send_error(
msg["id"], "conflict",
f"Layout was changed in another window (rev {current_rev} != {msg['expected_rev']})",
)
return
if msg.get("undo"):
backup = data.get("repair_backup")
if not isinstance(backup, dict) or backup.get("space") != space_id:
@@ -1037,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),
@@ -1052,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.
@@ -1093,6 +1172,30 @@ def _missing_internal_plans(
}
def _missing_internal_attachments(
config_root: Path, config: dict[str, Any], previous: dict[str, Any] | None = None
) -> set[str]:
"""New local attachments that vanished between import preview and apply.
Existing broken references stay writable for the same reason as historical
plan URLs: refusing every unrelated write would prevent the user from
detaching or repairing them.
"""
known = {
str(item["url"])
for item in content_manifest(previous or {}, config_root)
if item.get("kind") == "attachment" and item.get("storage") == "internal"
}
return {
str(item["url"])
for item in content_manifest(config, config_root)
if item.get("kind") == "attachment"
and item.get("storage") == "internal"
and item.get("exists_at_export") is False
and str(item["url"]) not in known
}
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/config/set",
@@ -1147,6 +1250,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
# write so an unrelated edit can still round-trip a legacy broken ref.
try:
validate_marker_controls(msg["config"], data.get("config"))
validate_marker_light_entities(msg["config"], data.get("config"))
validate_marker_value_badges(msg["config"], data.get("config"))
except MarkerControlError as err:
connection.send_error(msg["id"], err.code, str(err))
@@ -1169,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
@@ -1251,6 +1360,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
# as config/set; otherwise a crafted client can persist a new cycle.
try:
validate_marker_controls(msg["config"], config_data.get("config"))
validate_marker_light_entities(msg["config"], config_data.get("config"))
validate_marker_value_badges(msg["config"], config_data.get("config"))
except MarkerControlError as err:
connection.send_error(msg["id"], err.code, str(err))
@@ -1293,11 +1403,16 @@ 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},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup", "geom_pending"),
)
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
@@ -1363,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"),
+145 -5
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/** Reproducible browser benchmark and report producer for HP-PERF-01. */
import { mkdirSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { launch } from './serve.mjs';
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
@@ -13,7 +13,27 @@ const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const targetRoot = resolve(valueArg('target-root') ?? '.');
const profile = valueArg('profile') ?? 'large-house-v1';
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
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(
@@ -43,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 }) => {
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();
@@ -98,15 +120,25 @@ try {
wallUnion: card._wallUnionCache ? 1 : 0,
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
isoGeometry: card._isoGeometryCache?.size ?? 0,
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
});
window.__card?.remove?.();
localStorage.clear();
if (isometric) {
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(['iso']));
localStorage.setItem('houseplan_card_view_v1', JSON.stringify(Object.fromEntries(
fixture.config.spaces.map((space) => [space.id, 'iso']),
)));
history.replaceState(null, '', '?hp-labs=iso');
} else history.replaceState(null, '', location.pathname);
const host = document.getElementById('host');
const card = document.createElement('houseplan-card');
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,
@@ -121,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')
@@ -144,6 +177,10 @@ try {
host.replaceChildren(card);
card.hass = hassFor(fixture.states);
window.__hpAssertCardContract(card, cardContract);
if (requiresIsometric && (typeof card._setProjection !== 'function'
|| !(card._isoGeometryCache instanceof Map))) {
throw new Error('large-house-isometric-v1 candidate has no renderer contract');
}
await until(() => card._loadOk && card._model?.length === fixture.counts.floors);
await card.updateComplete;
await frame();
@@ -152,6 +189,18 @@ try {
await frame();
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
const loadLongTaskResult = await loadLongTasks.stop();
const viewToggle = isometric ? await duration(async () => {
if (typeof card._setProjection === 'function') {
card._setProjection('flat');
await card.updateComplete;
card._setProjection('iso');
await card.updateComplete;
} else {
// Comparison SHAs before #89 intentionally ignore the Labs operation.
card.requestUpdate();
await card.updateComplete;
}
}) : null;
const spaceSwitch = await duration(async () => {
card._pickSpace('perf-floor-2');
await card.updateComplete;
@@ -167,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';
@@ -249,6 +377,11 @@ try {
sample,
modelReadyMs,
firstStableRenderMs,
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
...(planSnapPointer ? {
planSnapPointerMs: planSnapPointer.ms,
planSnapDiagnostics,
} : {}),
spaceSwitchMs: spaceSwitch.ms,
stateUpdateMs: stateUpdate.ms,
resizePreviewMs: resizePreview.ms,
@@ -257,6 +390,8 @@ try {
switchCycleMs: switchCycle.ms,
longTasks: {
load: loadLongTaskResult,
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
spaceSwitch: spaceSwitch.longTasks,
stateUpdate: stateUpdate.longTasks,
resizePreview: resizePreview.longTasks,
@@ -273,7 +408,10 @@ try {
card.remove();
await frame();
return result;
}, { fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT });
}, {
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
isometric, requiresIsometric, planSnap, requiresPlanSnap,
});
if (measuredSample >= 0) rows.push(row);
}
} finally {
@@ -284,9 +422,11 @@ const metricNames = [
'modelReadyMs', 'firstStableRenderMs', 'spaceSwitchMs', 'stateUpdateMs',
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
];
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
const report = {
schema: 2,
profile: 'large-house-v1',
profile,
generatedAt: new Date().toISOString(),
buildFingerprint,
runtime: {
@@ -304,7 +444,7 @@ const report = {
summary: summarizeTimings(rows, metricNames),
longTasks: summarizeLongTasks(rows),
rows,
note: 'Compare with a base-SHA report captured by the same runner and evaluate demo/performance/budgets.json.',
note: `Compare with a base-SHA report captured by the same runner and evaluate the ${profile} budget.`,
};
const text = `${JSON.stringify(report, null, 2)}\n`;
+17 -1
View File
@@ -1,8 +1,24 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
const fingerprintForTree = async (root) => {
const modulePath = resolve(root, 'scripts/source-fingerprint.mjs');
if (!existsSync(modulePath)) return sourceFingerprint(root);
const module = await import(pathToFileURL(modulePath).href);
if (typeof module.sourceFingerprint !== 'function') {
throw new Error(`${modulePath} does not export sourceFingerprint`);
}
return module.sourceFingerprint(root);
};
/** Refuse measurements/screenshots made by a committed bundle from old source. */
export async function assertFreshDemoBundle(page, root = process.cwd()) {
const expected = sourceFingerprint(root);
// A comparative performance run may load an older tree whose fingerprint
// contract is intentionally different from the candidate's. Validate that
// tree with the implementation that built it, not with today's algorithm.
const expected = await fingerprintForTree(root);
const loaded = await page.evaluate(() => globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__ ?? null);
if (loaded !== expected) {
throw new Error(
+3 -3
View File
@@ -131,9 +131,9 @@ const runtime = () => {
attributes: { azimuth: 180, elevation: 24 },
},
};
entities['sun.sun'] = {
entity_id: 'sun.sun', device_id: null, platform: 'sun', disabled_by: null,
};
// Keep sun.sun state-only on purpose. Core/runtime entities and YAML
// entities without unique_id may have a live state without a registry row.
// The production projection must preserve them.
const layout = {};
const areas = Object.fromEntries(
[...geometryRooms, ...lightingRooms].map((room) => [room.area, { area_id: room.area, name: room.name }]),
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

+45 -23
View File
@@ -1,26 +1,48 @@
{
"schema": 1,
"matrixVersion": 12,
"acceptedAt": "2026-08-11T19:20:51.270Z",
"sourceFingerprint": "2fa0c824eaf356e9d909f54eba8ebddb92d6957c1163e2576986a29cdf497d46",
"matrixVersion": 22,
"acceptedAt": "2026-08-14T16:03:22.017Z",
"sourceFingerprint": "24117bcfb4c94d5421e0304612089743615233eab3911f065e8cfa003f48fa7d",
"chromium": "151.0.7922.34",
"scenarios": {
"geometry-view-dark-fit": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
"geometry-view-light-fit": "0c57dee930f30a1f9c16e4e704675156f57a7623a4edf839c14a4fecbf12881c",
"geometry-plan-editor-dark": "14e63fcdaef876ba68c601227b5e4de9a9818bd92228f6d91c680582570daf27",
"geometry-devices-editor-dark": "959321fc043f9754ea311ef92f00791f08b5a83c58b929166fb5ef71380337b6",
"geometry-decor-editor-dark": "cee7ffe6670ac91b4af814b222fda5251c77fa20ad4c2ddd4920b71aeb4d7a39",
"tray-wide-selection-en": "1c601a16298f24c6d6803125d8e9441958a0b34440ae02c9b727c3249314a232",
"tray-wide-tool-ru": "5591428022d32af54709f853da15a5d111304031ba4c94ca52c6cb5cd5c8068e",
"tray-medium-group-en": "f99a52346bdc3a12471325d1d3749d816f340c79b2817b4e6cd1ec2a250f4b97",
"tray-medium-selection-ru": "6f54ab6a4139de3f8310dad0669c0092fbff3df478a461a273d2e04c2660884d",
"tray-narrow-palette-en": "d1fc7a4bf0c973d71ee49e24c421f48ea776f7c1cab5e35f9561e36f0efeca8d",
"tray-narrow-tool-ru": "9d00dcb692350b05602d31938f5f8fd1eadfbe584972a2f40c904457635a64ff",
"geometry-diagonal-45-opening-dark": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
"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": "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": "074fb859d2ae1dc2ccd2045e2d6fb6d8389e430cbef20287419b8840cbbd5094",
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
"lighting-glow-sun-dark": "a98eee332f25a43c8d9d126c118c8cfea4ee73752b1060227548efedaf0efcdd",
"device-value-badge-positions-dark": "1ad43f2bd866733aa75c34de38fb97d469661799b540a8ae22150067d788cec8",
"lighting-sun-window-state-only-dark": "3bd581a23a2e0ebba58530db5182adea5cba6ec10bc032bee024415c19108a17",
"lighting-fill-light-axis-split-dark": "4f867528aeb9124229f81659876b03ff297a3a7a92bc57ffb32d5c24913c4938",
"lighting-fill-temp-axis-split-dark": "e0535b70701c9fe6753f74c943b9f288a0fb1a23a9cfc8590d4945e0a1724eb5",
"lighting-fill-lqi-axis-split-dark": "485ab183144913569ddc11154553ab4ac7db522ab188de74d652b717dc786d9c",
@@ -33,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": "250a7d586676baa861bb3dc9e3d6e02d324213b191eaa86791fc491c7c93c5be",
"device-dialog-mobile-ru": "31a13cd6c42f8a1be48d2bc5b7b1a5361c3df2e91145680c9133753434c758e6",
"device-help-popover-light-ru": "e463c5361ebc546d217debafb317761a0e25804a04ab41023a10025c48478e37",
"decor-color-popover-mobile-ru": "16d2859d1ed3715c1c4d3d451a8428c37e91ba00da17272c59cf83420a7b6c31",
"backup-full-preview-desktop-en": "7f12943d1027c89f6fe46978fa1f4e1bcdbf85a1216281d850d467e68f52cbda",
"backup-space-preview-mobile-ru": "998c6b52c1cc95109feb440a9966cade738154ceeae387246d40f8c56f9e8a3a"
"device-dialog-desktop-en": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
"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: 349 KiB

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 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: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 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.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 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: 148 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: 100 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 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

+240 -6
View File
@@ -1,5 +1,5 @@
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
import { makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
@@ -51,6 +51,64 @@ async function stableEnvironment(page, scenario) {
/** Apply every data-only scenario override before the fixture crosses into the browser. */
export function prepareGoldenFixture(scenario) {
const fixture = fixtureFor(scenario.fixture);
if (scenario.cornerSplitWall) {
const stage = scenario.cornerSplitWall;
if (!['before', 'thin', 'thick'].includes(stage))
throw new Error(`unknown cornerSplitWall stage: ${stage}`);
const a = [0.10, 0.10], tr = [0.90, 0.10], split = [0.90, 0.50];
const br = [0.90, 0.90], bl = [0.10, 0.90];
const entry = (from, to, cm) => ({
key: fixtureWallKey(from, to), a: [...from], b: [...to], cm,
});
const before = stage === 'before';
fixture.config.spaces.push({
id: scenario.space,
name: 'Corner Split',
rooms: before
? [{ id: 'corner-source', name: 'Before Split', area: null, poly: [a, tr, br, bl] }]
: [
{ id: 'corner-source', name: 'Main room', area: null, poly: [a, tr, split] },
{ id: 'corner-fresh', name: 'New room', area: null, poly: [split, br, bl, a] },
],
walls: before
? [entry(a, tr, 15), entry(tr, br, 15), entry(br, bl, 15), entry(bl, a, 15)]
: [
entry(a, tr, 15), entry(tr, split, 15), entry(split, br, 15),
entry(br, bl, 15), entry(bl, a, 15), entry(a, split, stage === 'thin' ? 15 : 100),
],
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}`);
@@ -61,14 +119,16 @@ export function prepareGoldenFixture(scenario) {
throw new Error(`golden deviceName references missing device: ${scenario.deviceId || '<empty>'}`);
fixture.devices[scenario.deviceId].name = scenario.deviceName;
}
if (scenario.fillMode || typeof scenario.glowEnabled === 'boolean'
|| typeof scenario.sunRays === 'boolean') {
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 } : {}),
...(scenario.customFill ? { custom_fill: scenario.customFill } : {}),
};
}
@@ -84,6 +144,22 @@ export function prepareGoldenFixture(scenario) {
}
space.openings = [...(space.openings || []), ...structuredClone(scenario.extraOpenings)];
}
if (scenario.openingGeometry) {
const space = requireSpace();
const opening = (space.openings || []).find(
(item) => item.id === scenario.openingGeometry.id,
);
if (!opening || opening.type !== scenario.openingGeometry.type
|| Math.abs(Number(opening.angle) - scenario.openingGeometry.angle) > 0.001) {
throw new Error(
`golden openingGeometry references a missing/mismatched opening: `
+ `${scenario.openingGeometry.id}`,
);
}
// This scenario must not remain byte-identical to the generic geometry
// capture: isolate the intended diagonal symbol in the rendered fixture.
space.openings = [opening];
}
if (scenario.wallReplacements?.length) {
const space = requireSpace();
const samePoint = (a, b) => Array.isArray(a) && Array.isArray(b)
@@ -143,7 +219,13 @@ export function prepareGoldenFixture(scenario) {
}
if (scenario.markerOverrides) {
const ids = new Set(scenario.markerOverrides.map((marker) => marker.id));
const known = new Set((fixture.config.markers || []).map((marker) => marker.id));
// Runtime devices without explicit marker settings are still valid saved
// marker targets. A visual scenario may materialize their first setting,
// just like the real device dialog does on save.
const known = new Set([
...(fixture.config.markers || []).map((marker) => marker.id),
...Object.keys(fixture.devices || {}),
]);
const missing = [...ids].filter((id) => !known.has(id));
if (missing.length) throw new Error(`golden markerOverrides reference missing marker(s): ${missing.join(', ')}`);
fixture.config.markers = [
@@ -174,13 +256,29 @@ export async function prepareGoldenScenario(page, scenario) {
await wait(15);
}
};
const settleMode = async (card) => {
await until(() => !card._modeTransitionBusy);
await card.updateComplete;
await frame();
};
window.__goldenCard?.remove?.();
window.__card?.remove?.();
localStorage.clear();
history.replaceState(null, '', scenario.labs?.length
? `?hp-labs=${encodeURIComponent(scenario.labs.join(','))}` : location.pathname);
if (scenario.labs?.length) {
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(scenario.labs));
}
if (scenario.projection && scenario.space) {
localStorage.setItem('houseplan_card_view_v1', JSON.stringify({
[scenario.space]: scenario.projection,
}));
}
const host = document.getElementById('host');
const cardConfig = {
type: 'custom:houseplan-card', title: `Golden ${scenario.id}`, icon_size: 3.4,
language: scenario.language || 'en',
...(scenario.kiosk ? { kiosk: true } : {}),
};
const hassFor = () => ({
language: scenario.language || 'en', locale: { language: scenario.language || 'en' },
@@ -239,12 +337,115 @@ export async function prepareGoldenScenario(page, scenario) {
if (scenario.mode) {
card._setMode(scenario.mode);
await card.updateComplete;
await settleMode(card);
}
if (scenario.projection === 'iso' && typeof card._setProjection === 'function') {
card._setProjection('iso');
await card.updateComplete;
await frame();
}
if (Number.isFinite(scenario.zoom)) {
card._applyView(scenario.zoom, 500, 500);
card.requestUpdate();
await card.updateComplete;
}
if (scenario.wallJunctionPreview) {
const { path, pointer, cms, cm } = scenario.wallJunctionPreview;
const validPoint = (point) => Array.isArray(point) && point.length === 2
&& point.every(Number.isFinite);
if (!Array.isArray(path) || !path.length || !path.every(validPoint)
|| !validPoint(pointer) || !Array.isArray(cms) || !cms.every(Number.isFinite)
|| !(Number(cm) > 0)) {
throw new Error(`invalid golden wallJunctionPreview contract: ${scenario.id}`);
}
card._tool = 'draw';
card._activeDraftId = null;
card._path = path.map((point) => [point[0] * 1000, point[1] * card._spaceH]);
card._draftSegmentCms = [...cms];
card._drawWallField = String(cm);
card._cursorPt = [pointer[0] * 1000, pointer[1] * card._spaceH];
card.requestUpdate();
await card.updateComplete;
await frame();
if (!card.renderRoot.querySelector('.drawwall-preview'))
throw new Error(`golden wall junction preview did not render: ${scenario.id}`);
}
if (scenario.planSnap) {
const { tool, anchor, pointer, expectedKind } = scenario.planSnap;
const validPoint = (point) => Array.isArray(point) && point.length === 2
&& point.every(Number.isFinite);
if (!['draw', 'partition'].includes(tool) || !validPoint(pointer)
|| (anchor != null && !validPoint(anchor))
|| !['endpoint', 'line'].includes(expectedKind)) {
throw new Error(`invalid golden planSnap contract: ${scenario.id}`);
}
card._tool = tool;
card._activeDraftId = null;
card._path = anchor ? [[anchor[0] * 1000, anchor[1] * card._spaceH]] : [];
card._clearPlanSnapHover();
card.requestUpdate();
await card.updateComplete;
await frame();
const svgRoot = card.renderRoot.querySelector('.stage svg');
const stage = card.renderRoot.querySelector('.stage');
const screen = new DOMPoint(pointer[0] * 1000, pointer[1] * card._spaceH)
.matrixTransform(svgRoot.getScreenCTM());
stage.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, composed: true, pointerId: 992, pointerType: 'mouse',
clientX: screen.x, clientY: screen.y,
}));
await card.updateComplete;
await frame();
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
const active = overlay?.querySelector('.plan-snap-node[data-active="true"]');
if (!overlay || active?.getAttribute('data-kind') !== expectedKind
|| overlay.querySelectorAll('.plan-snap-node[data-active="true"]').length !== 1) {
throw new Error(`golden plan snap candidate did not render: ${scenario.id}`);
}
}
if (scenario.openingPreview) {
const { type, pointer } = scenario.openingPreview;
if (!['window', 'door', 'gate'].includes(type)
|| !Array.isArray(pointer) || pointer.length !== 2
|| !pointer.every(Number.isFinite)) {
throw new Error(`invalid golden openingPreview: ${scenario.id}`);
}
card._activateOpeningPlacement(type);
card.requestUpdate();
await card.updateComplete;
await frame();
// Exercise the production pointer path after the toolbar update has
// settled. Writing `_cursorPt` before that update is racy: replacing the
// stage under Chromium's real pointer legitimately emits pointerleave
// and clears the preview before capture.
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: 991, pointerType: 'mouse',
clientX: screen.x, clientY: screen.y,
}));
await card.updateComplete;
await frame();
const preview = card.renderRoot.querySelector(`.opening-preview[data-kind="${type}"]`);
if (!preview || !preview.querySelector('.op-leaf')) {
const intervals = card._openingPlacementIntervalsCache?.value || [];
const nearest = intervals.map((interval) => {
const [px, py] = card._cursorPt || [0, 0];
const [ax, ay] = interval.a, [bx, by] = interval.b;
const dx = bx - ax, dy = by - ay, length2 = dx * dx + dy * dy || 1;
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / length2));
return {
a: interval.a, b: interval.b, cm: interval.cm, open: interval.open,
kind: interval.kind,
distance: Math.hypot(px - (ax + dx * t), py - (ay + dy * t)),
};
}).sort((a, b) => a.distance - b.distance).slice(0, 3);
throw new Error(`golden opening preview did not render: ${scenario.id}; `
+ `cursor=${JSON.stringify(card._cursorPt)} nearest=${JSON.stringify(nearest)}`);
}
}
if (scenario.editorTray) {
let expectedKind = '';
if (scenario.editorTray === 'plan-selection') {
@@ -304,6 +505,7 @@ export async function prepareGoldenScenario(page, scenario) {
if (scenario.dialog === 'device') {
card._setMode('devices');
await card.updateComplete;
await settleMode(card);
const device = card._devices.find((item) => item.id === scenario.deviceId);
if (!device) throw new Error(`golden device missing: ${scenario.deviceId}`);
card._openMarkerDialog(device);
@@ -380,6 +582,7 @@ export async function prepareGoldenScenario(page, scenario) {
card._setMode('decor');
card._decorTool = 'select';
await card.updateComplete;
await settleMode(card);
const shape = card._decorList.find((item) => item.kind === 'line');
if (!shape) throw new Error('golden decor line missing');
card._decorShapeDbl(new MouseEvent('dblclick'), shape);
@@ -392,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 {
@@ -403,22 +617,42 @@ export async function prepareGoldenScenario(page, scenario) {
.some((help) => help.renderRoot?.querySelector('.trigger')?.getAttribute('aria-expanded') === 'true'),
editorTray: card.renderRoot.querySelector('.editor-secondary-host.open .editor-secondary')
?.className || '',
...(scenario.sunRayPixels ? { sun: {
raw: card.hass?.states?.['sun.sun']?.attributes || null,
plan: card._planHass?.states?.['sun.sun']?.attributes || null,
render: card._renderPlanHass?.states?.['sun.sun']?.attributes || null,
north: card._effNorth(),
enabled: card._effSunRays(),
editing: card._editing,
cachedRays: card._sunRaysCache?.rays?.length || 0,
} } : {}),
};
}, { fixture, scenario });
}
export async function goldenClip(page, capture) {
if (capture === 'page') return null;
return page.evaluate(() => {
return page.evaluate((captureKind) => {
const card = window.__goldenCard;
const target = card?.renderRoot?.querySelector('.stage');
if (!target) throw new Error('golden stage capture target missing');
const rect = target.getBoundingClientRect();
if (captureKind === 'sun-window') {
// Stable crop around the exterior window and the first part of its ray:
// deliberately excludes room labels and device markers, whose font/icon
// rasterisation would add noise unrelated to the visual contract.
return {
x: Math.max(0, Math.floor(rect.left + rect.width * 0.10)),
y: Math.max(0, Math.floor(rect.top + rect.height * 0.02)),
width: Math.max(1, Math.ceil(rect.width * 0.35)),
height: Math.max(1, Math.ceil(rect.height * 0.25)),
};
}
const pad = 2;
const x = Math.max(0, Math.floor(rect.left - pad));
const y = Math.max(0, Math.floor(rect.top - pad));
const right = Math.min(window.innerWidth, Math.ceil(rect.right + pad));
const bottom = Math.min(window.innerHeight, Math.ceil(rect.bottom + pad));
return { x, y, width: Math.max(1, right - x), height: Math.max(1, bottom - y) };
});
}, capture);
}
+110 -2
View File
@@ -1,18 +1,91 @@
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 = 12;
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 } };
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'split-corner-wall-before-dark', fixture: 'visual', space: 'golden-corner-split',
cornerSplitWall: 'before', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'split-corner-wall-thin-dark', fixture: 'visual', space: 'golden-corner-split',
cornerSplitWall: 'thin', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'split-corner-wall-thick-dark', fixture: 'visual', space: 'golden-corner-split',
cornerSplitWall: 'thick', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: '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 },
{ id: 'isometric-live-layers-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-no-borders-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
labs: ['iso'], projection: 'iso', showBorders: false, fillMode: 'custom',
customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-touch-kiosk-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
labs: ['iso'], projection: 'iso', kiosk: true,
theme: 'dark', viewport: { width: 390, height: 760 }, ...stage },
{ id: 'isometric-large-warm-remount-dark', fixture: 'large', space: 'perf-floor-2', mode: 'view',
labs: ['iso'], projection: 'iso', warmRemount: true,
theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
{ id: 'geometry-view-dark-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-view-light-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: '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
// offset and ruler placement on a thick wall.
mode: 'plan', openingPreview: { type: 'door', pointer: [0.48, 0.65] },
openingPreviewPixels: { minPixels: 150, minInsideWallPixels: 8, minChannelDelta: 4 },
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'geometry-decor-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
@@ -38,6 +111,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
editorTray: 'decor-tool', language: 'ru', theme: 'dark',
viewport: { width: 390, height: 760 }, ...page },
{ id: 'geometry-diagonal-45-opening-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
openingGeometry: { id: 'geo-diagonal-window', type: 'window', angle: 45 },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'openings-thick-wall-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'none', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
@@ -51,12 +125,46 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ key: fixtureWallKey([0.5, 0.5], [0.5, 0.88]), a: [0.5, 0.5], b: [0.5, 0.88], cm: 32 },
],
}],
tunnelContinuity: { openingId: 'light-door', insetPx: 2, maxChannelJump: 3 },
tunnelContinuity: { openingId: 'light-door', insetPx: 2, maxChannelJump: 3, dpr2: true },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'openings-hidden-view-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'none', glowEnabled: false, hideOpenings: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-glow-sun-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'device-value-badge-positions-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
glowEnabled: false, sunRays: false,
markerOverrides: [
{ id: 'golden-light-one', binding: 'device:golden-light-one', value_badge: {
enabled: true, source: { kind: 'entity_state', entity_id: 'light.golden_light_one' }, position: 'right',
} },
{ id: 'golden-light-two', binding: 'device:golden-light-two', value_badge: {
enabled: true, source: { kind: 'entity_state', entity_id: 'light.golden_light_two' }, position: 'left',
} },
{ id: 'golden-presence', binding: 'device:golden-presence', value_badge: {
enabled: true, source: { kind: 'entity_state', entity_id: 'binary_sensor.golden_presence' }, position: 'top',
} },
{ id: 'golden-climate', binding: 'device:golden-climate', value_badge: {
enabled: true,
source: { kind: 'entity_attribute', entity_id: 'climate.golden_climate', attribute: 'current_temperature' },
position: 'bottom',
} },
],
stateOverrides: { 'climate.golden_climate': { attributes: { lqi: 190 } } },
layoutOverrides: {
'golden-light-one': { s: 'golden-lighting', x: 0.20, y: 0.32 },
'golden-light-two': { s: 'golden-lighting', x: 0.20, y: 0.72 },
'golden-presence': { s: 'golden-lighting', x: 0.80, y: 0.68 },
'golden-climate': { s: 'golden-lighting', x: 0.80, y: 0.28 },
},
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-sun-window-state-only-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
// The golden screenshot is backed by a second, sun-layer-hidden capture.
// A real painted ray must account for enough changed pixels; DOM-only
// presence or an accidentally accepted empty baseline is not sufficient.
glowEnabled: false, allLightsOff: true,
stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: 24 } } },
sunRayPixels: { minPixels: 500, minChannelDelta: 4 },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...sunWindow },
{ id: 'lighting-fill-light-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'light', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-fill-temp-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
+347 -20
View File
@@ -110,6 +110,174 @@ async function comparePng(page, actual, baseline, threshold) {
});
}
/** Capture the identical visual frame with only the rendered sun-ray SVG
* hidden. Comparing this control frame with the reviewed golden proves that
* the layer changes real browser pixels, not merely that its DOM exists. */
async function captureWithoutSunRays(page, screenshotOptions) {
const layerState = await page.evaluate(async () => {
const layer = window.__goldenCard?.renderRoot?.querySelector('.sunlayer');
if (!layer) throw new Error('semantic golden sun layer is missing');
const shapes = layer.querySelectorAll('path, polygon').length;
if (!shapes) throw new Error('semantic golden sun layer has no painted shapes');
const previous = layer.style.visibility;
layer.style.visibility = 'hidden';
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
return { previous, shapes };
});
try {
return { png: await page.screenshot(screenshotOptions), shapes: layerState.shapes };
} finally {
await page.evaluate(async (previous) => {
const layer = window.__goldenCard?.renderRoot?.querySelector('.sunlayer');
if (layer) layer.style.visibility = previous;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
}, layerState.previous);
}
}
/** Capture a control frame with only the transient opening symbol hidden.
* Comparing it with the actual frame proves the preview paints browser pixels;
* the smoke test separately locks its DOM order above the wall body. */
async function captureWithoutOpeningPreview(page, screenshotOptions) {
const layerState = await page.evaluate(async () => {
const card = window.__goldenCard;
const parts = [...(card?.renderRoot?.querySelectorAll(
'.opening-preview, .opening-preview-dot',
) || [])];
if (!parts.length) throw new Error('semantic golden opening preview is missing');
const previous = parts.map((part) => part.style.visibility);
for (const part of parts) part.style.visibility = 'hidden';
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
return { previous, count: parts.length };
});
try {
return { png: await page.screenshot(screenshotOptions), parts: layerState.count };
} finally {
await page.evaluate(async (previous) => {
const card = window.__goldenCard;
const parts = [...(card?.renderRoot?.querySelectorAll(
'.opening-preview, .opening-preview-dot',
) || [])];
parts.forEach((part, index) => { part.style.visibility = previous[index] || ''; });
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
}, layerState.previous);
}
}
async function countChangedPixels(page, actual, control, spec) {
return page.evaluate(async ({ actual64, control64, minChannelDelta }) => {
const decode = async (base64) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
};
const [actualImage, controlImage] = await Promise.all([decode(actual64), decode(control64)]);
if (actualImage.width !== controlImage.width || actualImage.height !== controlImage.height)
throw new Error('semantic golden control frame has different dimensions');
const canvas = document.createElement('canvas');
const controlCanvas = document.createElement('canvas');
canvas.width = controlCanvas.width = actualImage.width;
canvas.height = controlCanvas.height = actualImage.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
const controlContext = controlCanvas.getContext('2d', { willReadFrequently: true });
context.drawImage(actualImage, 0, 0);
controlContext.drawImage(controlImage, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
const controlPixels = controlContext.getImageData(0, 0, canvas.width, canvas.height).data;
let changed = 0;
let maxDelta = 0;
for (let offset = 0; offset < pixels.length; offset += 4) {
const delta = Math.max(
Math.abs(pixels[offset] - controlPixels[offset]),
Math.abs(pixels[offset + 1] - controlPixels[offset + 1]),
Math.abs(pixels[offset + 2] - controlPixels[offset + 2]),
);
maxDelta = Math.max(maxDelta, delta);
if (delta >= minChannelDelta) changed++;
}
return { changed, maxDelta, size: [canvas.width, canvas.height] };
}, {
actual64: actual.toString('base64'),
control64: control.toString('base64'),
minChannelDelta: spec.minChannelDelta,
});
}
/** Count preview pixels which are actually painted over the physical wall
* fill. A global changed-pixel threshold can pass even when the complete
* symbol is accidentally hidden below masonry because its swing arc remains
* outside the body. */
async function countOpeningPreviewPixelsInsideWall(
page, actual, control, clip, minChannelDelta,
) {
return page.evaluate(async ({ actual64, control64, clipRect, minDelta }) => {
const decode = async (base64) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
};
const [actualImage, controlImage] = await Promise.all([decode(actual64), decode(control64)]);
const canvas = document.createElement('canvas');
const controlCanvas = document.createElement('canvas');
canvas.width = controlCanvas.width = actualImage.width;
canvas.height = controlCanvas.height = actualImage.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
const controlContext = controlCanvas.getContext('2d', { willReadFrequently: true });
context.drawImage(actualImage, 0, 0);
controlContext.drawImage(controlImage, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
const controls = controlContext.getImageData(0, 0, canvas.width, canvas.height).data;
const card = window.__goldenCard;
const preview = card?.renderRoot?.querySelector('.opening-preview');
const walls = [...(card?.renderRoot?.querySelectorAll('.wallbody-fill') || [])]
.map((wall) => {
const matrix = wall.getScreenCTM?.();
return matrix ? { wall, rect: wall.getBoundingClientRect(), inverse: matrix.inverse() } : null;
})
.filter(Boolean);
if (!preview || !walls.length) return { changed: 0, sampled: 0 };
const previewRect = preview.getBoundingClientRect();
const candidates = walls.filter(({ rect }) => rect.right >= previewRect.left
&& rect.left <= previewRect.right && rect.bottom >= previewRect.top
&& rect.top <= previewRect.bottom);
if (!candidates.length) return { changed: 0, sampled: 0 };
const left = Math.ceil(previewRect.left);
const top = Math.ceil(previewRect.top);
const right = Math.floor(previewRect.right);
const bottom = Math.floor(previewRect.bottom);
const originX = clipRect?.x || 0;
const originY = clipRect?.y || 0;
let changed = 0;
let sampled = 0;
for (let y = top; y <= bottom; y++) {
for (let x = left; x <= right; x++) {
const screenPoint = new DOMPoint(x + 0.5, y + 0.5);
const insideWall = candidates.some(({ wall, rect, inverse }) => {
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return false;
return wall.isPointInFill(screenPoint.matrixTransform(inverse));
});
if (!insideWall) continue;
const px = Math.round(x - originX);
const py = Math.round(y - originY);
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) continue;
sampled += 1;
const offset = (py * canvas.width + px) * 4;
const delta = Math.max(
Math.abs(pixels[offset] - controls[offset]),
Math.abs(pixels[offset + 1] - controls[offset + 1]),
Math.abs(pixels[offset + 2] - controls[offset + 2]),
Math.abs(pixels[offset + 3] - controls[offset + 3]),
);
if (delta >= minDelta) changed += 1;
}
}
return { changed, sampled };
}, {
actual64: actual.toString('base64'),
control64: control.toString('base64'),
clipRect: clip || null,
minDelta: minChannelDelta,
});
}
/** Assert scenario semantics against the actual capture, not only its data.
* This protects a reviewed-but-empty baseline from becoming the reference. */
async function countWarmPixels(page, png, region) {
@@ -174,10 +342,10 @@ async function countHelpTextPixels(page, png, clip, spec) {
}, { png64: png.toString('base64'), clip, spec });
}
/** Detect one-pixel SVG seams inside a room-coloured opening tunnel. The
* opening symbol is hidden by the scenario, so both centre lines should be a
* locally constant translucent surface. Sampling both axes catches the wall
* centre split as well as boundaries between atomic wall-profile pieces. */
/** Detect one-pixel SVG seams inside a room-coloured opening tunnel. Sample a
* narrow strip around local y=0: this crosses the join between both tunnel
* half-faces at any opening angle while excluding legitimate outer-profile
* steps where adjacent wall intervals have different physical thicknesses. */
async function inspectTunnelContinuity(page, png, clip, spec) {
return page.evaluate(async ({ png64, clip, spec }) => {
const card = window.__goldenCard;
@@ -186,6 +354,10 @@ async function inspectTunnelContinuity(page, png, clip, spec) {
);
if (!tunnel) throw new Error(`semantic golden tunnel missing: ${spec.openingId}`);
const rect = tunnel.getBoundingClientRect();
const matrix = tunnel.getScreenCTM();
if (!matrix) throw new Error(`semantic golden tunnel has no screen transform: ${spec.openingId}`);
const inverse = matrix.inverse();
const localBounds = tunnel.getBBox();
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
const canvas = document.createElement('canvas');
@@ -194,10 +366,19 @@ async function inspectTunnelContinuity(page, png, clip, spec) {
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0);
const originX = clip?.x || 0, originY = clip?.y || 0;
const left = Math.max(0, Math.ceil(rect.left - originX) + spec.insetPx);
const top = Math.max(0, Math.ceil(rect.top - originY) + spec.insetPx);
const right = Math.min(image.width - 1, Math.floor(rect.right - originX) - spec.insetPx);
const bottom = Math.min(image.height - 1, Math.floor(rect.bottom - originY) - spec.insetPx);
// Screenshots may be captured at DPR > 1. DOMRect/clip are CSS pixels,
// image coordinates are device pixels, so derive the scale instead of
// silently sampling the wrong strip at high DPI.
const cssWidth = clip?.width || document.documentElement.clientWidth;
const cssHeight = clip?.height || document.documentElement.clientHeight;
const scaleX = image.width / Math.max(1, cssWidth);
const scaleY = image.height / Math.max(1, cssHeight);
const insetX = Math.max(1, Math.round(spec.insetPx * scaleX));
const insetY = Math.max(1, Math.round(spec.insetPx * scaleY));
const left = Math.max(0, Math.ceil((rect.left - originX) * scaleX) + insetX);
const top = Math.max(0, Math.ceil((rect.top - originY) * scaleY) + insetY);
const right = Math.min(image.width - 1, Math.floor((rect.right - originX) * scaleX) - insetX);
const bottom = Math.min(image.height - 1, Math.floor((rect.bottom - originY) * scaleY) - insetY);
if (right - left < 3 || bottom - top < 3)
throw new Error(`semantic golden tunnel is too small: ${left},${top},${right},${bottom}`);
const pixels = context.getImageData(0, 0, image.width, image.height).data;
@@ -205,17 +386,45 @@ async function inspectTunnelContinuity(page, png, clip, spec) {
const offset = (y * image.width + x) * 4;
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]];
};
let maxJump = 0, samplePairs = 0;
const compare = (a, b) => {
maxJump = Math.max(maxJump,
let maxJump = 0, maxPair = null, samplePairs = 0;
const compare = (a, b, x, y, direction) => {
const jump = Math.max(
Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), Math.abs(a[2] - b[2]));
if (jump > maxJump) {
maxJump = jump;
maxPair = { x, y, direction, a, b };
}
samplePairs++;
};
const centreX = Math.round((left + right) / 2);
const centreY = Math.round((top + bottom) / 2);
for (let x = left + 1; x <= right; x++) compare(rgb(x - 1, centreY), rgb(x, centreY));
for (let y = top + 1; y <= bottom; y++) compare(rgb(centreX, y - 1), rgb(centreX, y));
return { maxJump, samplePairs, bounds: [left, top, right, bottom] };
const localXScale = Math.max(1e-6, Math.hypot(matrix.a, matrix.b));
const localYScale = Math.max(1e-6, Math.hypot(matrix.c, matrix.d));
const endInset = Math.max(1, spec.insetPx) / localXScale;
const axisBand = Math.max(1.5, spec.axisBandPx || 2.5) / localYScale;
const insideAxisBand = (x, y) => {
const cssX = originX + (x + 0.5) / scaleX;
const cssY = originY + (y + 0.5) / scaleY;
const local = new DOMPoint(cssX, cssY).matrixTransform(inverse);
return local.x >= localBounds.x + endInset
&& local.x <= localBounds.x + localBounds.width - endInset
&& Math.abs(local.y) <= axisBand;
};
for (let y = top; y <= bottom; y++) {
for (let x = left + 1; x <= right; x++) {
if (insideAxisBand(x - 1, y) && insideAxisBand(x, y))
compare(rgb(x - 1, y), rgb(x, y), x, y, 'horizontal');
}
}
for (let x = left; x <= right; x++) {
for (let y = top + 1; y <= bottom; y++) {
if (insideAxisBand(x, y - 1) && insideAxisBand(x, y))
compare(rgb(x, y - 1), rgb(x, y), x, y, 'vertical');
}
}
if (!samplePairs) throw new Error(`semantic golden tunnel axis strip is empty: ${spec.openingId}`);
return {
maxJump, maxPair, samplePairs, bounds: [left, top, right, bottom],
scale: [scaleX, scaleY],
};
}, { png64: png.toString('base64'), clip, spec });
}
@@ -226,11 +435,13 @@ if (existsSync(baselineManifestPath)) {
catch { baselineManifest = { invalid: true }; }
}
const browserArgs = ['--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text'];
const browserContext = { locale: 'en-US', timezoneId: 'UTC', colorScheme: 'dark', reducedMotion: 'reduce' };
const { page, browser } = await launch(
{ width: 1000, height: 900 },
1,
['--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text'],
{ locale: 'en-US', timezoneId: 'UTC', colorScheme: 'dark', reducedMotion: 'reduce' },
browserArgs,
browserContext,
);
const chromium = await browser.version();
const results = [];
@@ -250,17 +461,99 @@ try {
pageErrors.length = 0;
result.runtime = await prepareGoldenScenario(page, scenario);
if (pageErrors.length) throw new Error(`browser exception: ${pageErrors.join(' | ')}`);
if (scenario.openingGeometry) {
result.openingGeometry = await page.evaluate((expected) => {
const card = window.__goldenCard;
const opening = card?.renderRoot?.querySelector(
`.opening[data-id="${CSS.escape(expected.id)}"]`,
);
if (!opening) return null;
const transform = opening.getAttribute('transform') || '';
const angle = Number(transform.match(/rotate\(([-+0-9.eE]+)\)/)?.[1]);
const painted = [...opening.querySelectorAll('.op-leaf, .op-arc, .op-glass')]
.map((part) => part.getBoundingClientRect())
.filter((rect) => rect.width > 0 || rect.height > 0);
const bounds = painted.length ? {
width: Math.max(...painted.map((rect) => rect.right))
- Math.min(...painted.map((rect) => rect.left)),
height: Math.max(...painted.map((rect) => rect.bottom))
- Math.min(...painted.map((rect) => rect.top)),
} : { width: 0, height: 0 };
return {
type: opening.getAttribute('data-kind'), angle,
width: bounds.width, height: bounds.height,
visibleParts: opening.querySelectorAll('.op-leaf, .op-arc, .op-glass').length,
};
}, scenario.openingGeometry);
const expected = scenario.openingGeometry;
const actualOpening = result.openingGeometry;
if (!actualOpening || actualOpening.type !== expected.type
|| Math.abs(actualOpening.angle - expected.angle) > 0.001
|| actualOpening.width <= 0 || actualOpening.height <= 0
|| actualOpening.visibleParts <= 0) {
throw new Error(
`semantic golden opening geometry failed for ${expected.id}: `
+ JSON.stringify(actualOpening),
);
}
}
const clip = await goldenClip(page, scenario.capture);
const actual = await page.screenshot({
const screenshotOptions = {
...(clip ? { clip } : {}),
animations: 'disabled',
caret: 'hide',
scale: 'css',
});
};
const actual = await page.screenshot(screenshotOptions);
const actualPath = resolve(actualRoot, `${scenario.id}.png`);
writeFileSync(actualPath, actual);
result.actualSha256 = sha256(actual);
result.actual = actualPath;
if (scenario.sunRayPixels) {
const control = await captureWithoutSunRays(page, screenshotOptions);
const sample = await countChangedPixels(page, actual, control.png, scenario.sunRayPixels);
result.sunRayShapes = control.shapes;
result.sunRayChangedPixels = sample.changed;
result.sunRayMaxChannelDelta = sample.maxDelta;
if (sample.changed < scenario.sunRayPixels.minPixels) {
throw new Error(
`semantic golden assertion failed: sun rays paint ${sample.changed} pixels, expected at least `
+ `${scenario.sunRayPixels.minPixels}`,
);
}
}
if (scenario.openingPreviewPixels) {
const control = await captureWithoutOpeningPreview(page, screenshotOptions);
const sample = await countChangedPixels(
page, actual, control.png, scenario.openingPreviewPixels,
);
result.openingPreviewParts = control.parts;
result.openingPreviewChangedPixels = sample.changed;
result.openingPreviewMaxChannelDelta = sample.maxDelta;
const insideWall = await countOpeningPreviewPixelsInsideWall(
page, actual, control.png, clip, scenario.openingPreviewPixels.minChannelDelta,
);
result.openingPreviewPixelsInsideWall = insideWall.changed;
result.openingPreviewWallSamples = insideWall.sampled;
if (control.parts < 2) {
throw new Error(
`semantic golden assertion failed: opening preview is incomplete (${control.parts} parts)`,
);
}
if (sample.changed < scenario.openingPreviewPixels.minPixels) {
throw new Error(
`semantic golden assertion failed: opening preview paints ${sample.changed} pixels, `
+ `expected at least ${scenario.openingPreviewPixels.minPixels}`,
);
}
if (insideWall.changed < scenario.openingPreviewPixels.minInsideWallPixels) {
throw new Error(
`semantic golden assertion failed: opening preview paints ${insideWall.changed} pixels `
+ `inside the wall body, expected at least `
+ `${scenario.openingPreviewPixels.minInsideWallPixels}`,
);
}
}
if (scenario.warmPixelRegion) {
const sample = await countWarmPixels(page, actual, scenario.warmPixelRegion);
result.warmPixels = sample.warm;
@@ -288,8 +581,10 @@ try {
page, actual, clip, scenario.tunnelContinuity,
);
result.tunnelMaxChannelJump = sample.maxJump;
result.tunnelMaxJumpPair = sample.maxPair;
result.tunnelSamplePairs = sample.samplePairs;
result.tunnelPixelBounds = sample.bounds;
result.tunnelImageScale = sample.scale;
if (sample.maxJump > scenario.tunnelContinuity.maxChannelJump) {
throw new Error(
`semantic golden assertion failed: opening ${scenario.tunnelContinuity.openingId} `
@@ -297,6 +592,38 @@ try {
+ `${scenario.tunnelContinuity.maxChannelJump}`,
);
}
if (scenario.tunnelContinuity.dpr2) {
// A CSS-pixel capture cannot prove that half-device-pixel joins are
// clean. Run the same semantic assertion once at DPR 2 without
// adding a second reviewed baseline to the matrix.
const highDpi = await launch(
scenario.viewport, 2, browserArgs, browserContext,
);
try {
await assertFreshDemoBundle(highDpi.page, ROOT);
await prepareGoldenScenario(highDpi.page, scenario);
const highDpiClip = await goldenClip(highDpi.page, scenario.capture);
const highDpiPng = await highDpi.page.screenshot({
...(highDpiClip ? { clip: highDpiClip } : {}),
animations: 'disabled', caret: 'hide', scale: 'device',
});
const highDpiSample = await inspectTunnelContinuity(
highDpi.page, highDpiPng, highDpiClip, scenario.tunnelContinuity,
);
result.tunnelDpr2MaxChannelJump = highDpiSample.maxJump;
result.tunnelDpr2SamplePairs = highDpiSample.samplePairs;
result.tunnelDpr2PixelBounds = highDpiSample.bounds;
if (highDpiSample.maxJump > scenario.tunnelContinuity.maxChannelJump) {
throw new Error(
`semantic golden assertion failed at DPR 2: opening ${scenario.tunnelContinuity.openingId} `
+ `has a ${highDpiSample.maxJump}-channel local jump, expected at most `
+ `${scenario.tunnelContinuity.maxChannelJump}`,
);
}
} finally {
await highDpi.browser.close();
}
}
}
const baselinePath = resolve(baselineRoot, `${scenario.id}.png`);
result.baseline = baselinePath;
+19
View File
@@ -4,6 +4,24 @@
fixture. The fixture has 60 rooms, 200 devices, 100 openings, 60 partitions,
40 columns and 500 decor objects on three floors.
Issue #89 adds `large-house-isometric-v1` through the same runner. It enables
`hp-labs=iso`, selects iso per fixture space, measures the View toggle and
records the capped `isoGeometry` cache. A comparison SHA predating #89 remains
flat while reporting the same profile. The dedicated
`budgets-large-house-isometric.json` applies the reviewed 20% relative allowance
plus absolute noise/ceiling checks. Only the exact-SHA Linux workflow is gate
evidence; a local report is diagnostic.
Issue #137 adds `large-house-plan-snap-v1` without changing the meaning or
budgets of the original profile. The same 60-room/60-partition fixture gains
six saved open outlines, renders the Plan snap overlay, and sends 120 real
pointer moves across endpoint, line and miss targets. Candidate bundles fail
inside the runner if the static DOM or geometry cache grows, more than one
active node appears, endpoint/line paths are not both exercised, or config and
websocket traffic change. Its dedicated budget retains every original timing,
heap and cache ceiling and adds the measured pointer series plus a one-entry
snap-geometry cache cap. Exact-SHA Linux output is the only gate evidence.
The runner records seven measured samples after one discarded warm-up. With
this intentionally small CI sample, the nearest-rank `p95` is the observed
maximum; reports keep the conventional field name but should be read as a
@@ -102,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,50 @@
{
"schema": 1,
"profile": "large-house-isometric-v1",
"minimumSamples": 7,
"timings": {
"modelReadyMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 200, "hardMaxMs": 3000 },
"firstStableRenderMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 3500 },
"viewToggleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
"spaceSwitchMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1800 },
"stateUpdateMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
"resizePreviewMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
"panZoomMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 60, "hardMaxMs": 600 },
"settingsDialogMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1200 },
"switchCycleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 8000 }
},
"longTasks": {
"maxSingleMs": 3000,
"maxSingleRegressionRatio": 0.2,
"maxSingleNoiseAllowanceMs": 250,
"maxCountP95": 30,
"maxCountRegressionRatio": 0.2,
"countNoiseAllowance": 3,
"maxTotalP95Ms": 12000,
"maxTotalRegressionRatio": 0.2,
"noiseAllowanceMs": 150
},
"heap": {
"required": true,
"hardMaxGrowthBytes": 67108864,
"maxRegressionRatio": 0.2,
"noiseAllowanceBytes": 16777216
},
"cacheEntries": {
"cleanFloor": 160,
"glowClip": 200,
"wallUnion": 1,
"openingTunnel": 1,
"openingWallIndex": 1,
"isoGeometry": 8
},
"cacheGrowth": {
"cleanFloor": 0,
"glowClip": 0,
"wallUnion": 0,
"openingTunnel": 0,
"openingWallIndex": 0,
"isoGeometry": 0
},
"renderedDevices": 200
}
@@ -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
}
+10 -1
View File
@@ -33,11 +33,15 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
'_gridPitch',
'_loadOk',
'_model',
'_path',
'_rszDrag',
'_serverCfg',
'_settingsDialog',
'_tool',
]),
optionalFields: Object.freeze([]),
// 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', '_planSnapGeometryCache', '_setProjection']),
fieldTypes: Object.freeze({
_booting: 'boolean',
_cleanFloorCache: 'map',
@@ -46,6 +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',
}),
});
+1 -1
View File
@@ -17,7 +17,7 @@ await page.evaluate(async () => {
c.requestUpdate();
await c.updateComplete;
const st = document.createElement('style');
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
st.textContent = '.device-pulse.continuous.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
});
await page.waitForTimeout(400);
+1 -1
View File
@@ -38,7 +38,7 @@ await page.evaluate(async (STATES) => {
c.requestUpdate();
await c.updateComplete;
const st = document.createElement('style');
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
st.textContent = '.device-pulse.continuous.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
}, STATES);
await page.waitForTimeout(500);
+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');
+1 -1
View File
@@ -17,7 +17,7 @@ await page.evaluate(async () => {
await set('on');
// freeze the pulse at a visible frame for a deterministic capture
const st = document.createElement('style');
st.textContent = '.activity-ring.event i{animation-delay:-0.4s!important;animation-play-state:paused!important;}';
st.textContent = '.device-pulse.short.event i{animation-delay:-0.4s!important;animation-play-state:paused!important;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
});
await page.waitForTimeout(300);
+1 -1
View File
@@ -6,7 +6,7 @@ await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.openings = [];
c._setMode('plan'); c._tool = 'opening';
c._setMode('plan'); c._activateOpeningPlacement('door');
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
});
+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 {
+67 -16
View File
@@ -8,6 +8,12 @@ const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const stageBg = async () => {
c.requestUpdate();
await c.updateComplete;
@@ -90,11 +96,32 @@ const res = await page.evaluate(async () => {
// 8) editors keep their own canvas (inline bg only in view/kiosk)
c._setMode('decor');
await settleMode();
const editorBg = await waitStageBg((value) => value !== rgb('#0a2a4a'));
out.editorUnpainted = editorBg !== rgb('#0a2a4a');
c._setMode('view');
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 });
@@ -104,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);
@@ -132,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 };
@@ -158,13 +198,19 @@ 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;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#ff00ff' };
c._cfgRev = (c._cfgRev || 0) + 1;
await upd();
@@ -183,28 +229,31 @@ 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');
// editors keep the paper too (their canvas ignores bg_color anyway)
c._setMode('decor');
await upd();
await new Promise((resolve) => setTimeout(resolve, 220));
await upd(); await settleMode();
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
c._setMode('view');
await upd();
await new Promise((resolve) => setTimeout(resolve, 220));
// daynight night: the paper dims via the brightness filter ONLY, never alpha
await upd(); await settleMode();
// 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;
@@ -253,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
+36 -10
View File
@@ -19,7 +19,7 @@ const res = await page.evaluate(async () => {
c._openMarkerDialog();
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
c._markerDialog = { ...c._markerDialog, name: 'Выключатель', binding: 'virtual',
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights,
room: c._space + '#' + room.area, tapAction: 'toggle', tapActionTouched: true, controls: lights,
icon: 'mdi:light-switch', display: 'icon_ripple' };
await c._saveMarker(); await c.updateComplete;
const dev = c._devices.find((d) => d.name === 'Выключатель');
@@ -59,8 +59,28 @@ const res = await page.evaluate(async () => {
out.aggregateActivityRuns = c._stateClass(dev2()).includes('activity-running');
await setSt({ [lights[0]]: 'off' });
out.stateOff = c._stateClass(dev2()) === '';
// 4) без tap_action=toggle клик НЕ переключает (инфо)
// #94/#73 boundary: a complete visual frame may temporarily retain the old
// DevItem while live config has already changed. The click must resolve the
// current marker by id and never call the controls captured by that frame.
const cfg = c._serverCfg.markers.find((m) => m.name === 'Выключатель');
const staleSource = dev2();
const visuallyStale = {
...staleSource,
controls: [...(staleSource.controls || [])],
marker: staleSource.marker ? {
...staleSource.marker,
controls: [...(staleSource.marker.controls || [])],
} : null,
};
cfg.controls = [lights[1]];
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
await setSt({ [lights[0]]: 'off', [lights[1]]: 'off' });
c._clickDevice(new MouseEvent('click'), visuallyStale);
out.staleFrameUsesCurrentControls = JSON.stringify(calls.at(-1))
=== JSON.stringify(['homeassistant', 'turn_on', [lights[1]]]);
cfg.controls = [...lights];
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
// 4) без tap_action=toggle клик НЕ переключает (инфо)
cfg.tap_action = 'info'; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
const n = calls.length;
c._clickDevice(new MouseEvent('click'), dev2());
@@ -96,17 +116,23 @@ const res = await page.evaluate(async () => {
// operates the entity, and an ON hood/fan still has working-state yellow.
const own = Object.keys(c.hass.states).find((eid) => eid.startsWith('switch.'));
out.hasStandaloneSwitch = !!own;
const self = own && {
id: 'self-control-regression', name: 'Hood', icon: 'mdi:fan',
space: c._space, area: room.area, entities: [own], primary: own,
bindingKind: 'entity', bindingRef: own, tapAction: 'toggle',
marker: { id: 'self-control-regression', binding: `entity:${own}`, controls: [own] },
};
const selfId = 'self-control-regression';
if (own) {
c._serverCfg.markers.push({
id: selfId, name: 'Hood', icon: 'mdi:fan', binding: `entity:${own}`,
space: c._space, area: room.area, tap_action: 'toggle', controls: [own],
});
c._regSignature = '';
c._maybeRebuildDevices();
await c.updateComplete;
}
const self = c._devices.find((device) => device.id === selfId);
if (self) {
await setSt({ [own]: 'off' });
c._clickDevice(new MouseEvent('click'), self);
out.selfControlUsesDirectToggle = JSON.stringify(calls.at(-1))
=== JSON.stringify(['homeassistant', 'toggle', own]);
const selfCall = calls.at(-1);
out.selfControlUsesDirectToggle = ['switch', 'homeassistant'].includes(selfCall?.[0])
&& selfCall?.[1] === 'turn_on' && selfCall?.[2] === own;
c._openMarkerDialog(self); await c.updateComplete;
out.selfControlAbsentFromDialog = c._markerDialog?.controls.length === 0;
c._markerDialog = null;
+2 -2
View File
@@ -103,9 +103,9 @@ const out = await page.evaluate(async () => {
plateIsNeutral('opening');
o.openingRings = cls().includes('activity-transition');
o.openingRingIsTheBreathingOne = (() => {
const ring = devEl()?.querySelector('.activity-ring.transition i:first-child');
const ring = devEl()?.querySelector('.device-pulse.continuous.transition i:first-child');
const cs = ring ? getComputedStyle(ring) : null;
return !!cs && cs.animationName === 'hp-activity-breathe' && cs.animationDuration === '2.2s';
return !!cs && cs.animationName === 'hp-pulse-continuous' && cs.animationDuration === '2.4s';
})();
await setCover('closing', { device_class: 'curtain' });
+30 -8
View File
@@ -34,7 +34,7 @@ const out = await page.evaluate(async () => {
};
const coverState = (state, attrs = {}) => ({
entity_id: 'cover.office_curtain', state,
attributes: { friendly_name: 'Office curtain', ...attrs },
attributes: { friendly_name: 'Office curtain', supported_features: 11, ...attrs },
});
const push = async (state, attrs = {}) => {
c.hass = {
@@ -80,17 +80,21 @@ const out = await page.evaluate(async () => {
o.primaryIsTheCover = curtain()?.primary === 'cover.office_curtain';
o.coverIsAmongTheEntities = (curtain()?.entities || []).includes('cover.office_curtain');
// ---- the dialog offers the action and SAVES it -------------------------
// ---- the dialog offers the unified action and SAVES an intentional edit
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(curtain()); await c.updateComplete;
const optionValues = () => [...sr().querySelectorAll('hp-dialog option')].map((e) => e.value);
o.dialogOffersCover = optionValues().includes('cover');
c._markerDialog = { ...c._markerDialog, tapAction: 'cover', display: 'icon_ripple' };
o.dialogOffersUnifiedToggle = optionValues().includes('toggle')
&& !optionValues().includes('cover');
c._markerDialog = {
...c._markerDialog,
tapAction: 'toggle', tapActionTouched: true, display: 'icon_ripple',
};
await c.updateComplete;
await c._saveMarker();
await c.updateComplete;
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
o.markerSaved = saved?.tap_action === 'cover';
o.markerSavedAsUnifiedToggle = saved?.tap_action === 'toggle';
// ---- and the tap on THAT marker opens the curtain -----------------------
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
@@ -100,8 +104,13 @@ const out = await page.evaluate(async () => {
await c.updateComplete;
await new Promise((r) => setTimeout(r, 10));
};
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'cover';
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'toggle';
o.coverServicesDeclared = ['open_cover', 'close_cover', 'stop_cover']
.every((service) => Object.hasOwn(c.hass.services?.cover || {}, service));
calls.length = 0;
const closedIntent = c._toggleIntent(curtain());
o.closedIntentResolvesExactTarget = closedIntent?.command?.service === 'open_cover'
&& closedIntent.command.data.entity_id === 'cover.office_curtain';
await tap();
o.closedOpensTheCover = JSON.stringify(calls[calls.length - 1] || [])
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
@@ -122,12 +131,12 @@ const out = await page.evaluate(async () => {
// the service switch, which used to be the whole story, is never touched
o.serviceSwitchNeverCalled = !calls.some((x) => String(x[2]?.entity_id).startsWith('switch.'));
// ---- the guarded classes still degrade, read off the COVER --------------
// ---- guarded classes are an explicit secure no-op -----------------------
await push('closed', { device_class: 'garage' });
calls.length = 0;
await tap();
o.garageCallsNothing = calls.length === 0;
o.garageShowsInfo = !!c._infoCard;
o.garageDoesNotFallbackToInfo = !c._infoCard;
c._infoCard = null; await c.updateComplete;
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(curtain()); await c.updateComplete;
@@ -192,6 +201,19 @@ const out = await page.evaluate(async () => {
await setSwitch('on');
o.withoutTheActionResolvedCoverSpeaks = clsOf(curtain()).includes('activity-transition')
&& !clsOf(curtain()).includes('on');
calls.length = 0;
c._infoCard = null;
// A local card must not depend on a second registry read at click time. The
// already-rendered curtain remains a valid informational snapshot while HA's
// registry is transiently unavailable; service-backed actions stay gated.
const liveBindingStatus = c._bindingStatus;
c._bindingStatus = () => ({
kind: 'unverified', reason: 'registry_unavailable', enabledEntityIds: [], allEntityIds: [],
});
try { await tap(); } finally { c._bindingStatus = liveBindingStatus; }
o.infoActionOpensTheHousePlanCard = c._infoCard?.id === curtain()?.id;
o.infoActionCallsNoHaService = calls.length === 0;
c._infoCard = null;
savedMarker.tap_action = 'cover';
await push('closed', { device_class: 'curtain' });
await setSwitch('off');
+30 -19
View File
@@ -1,5 +1,5 @@
// Covers: the «Open/close» tap action + the travelling indication
// (owner's contract 2026-08-03).
// Covers after issue #94: the unified «Toggle state» action + the travelling
// indication. Persisted `cover` remains a lossless legacy runtime token.
//
// closed -> cover.open_cover
// open / ajar -> cover.close_cover
@@ -7,9 +7,8 @@
// the next tap simply travels the other way)
// unknown -> cover.toggle, no morphing, no pulse
//
// The option is offered ONLY for a binding that HAS a cover entity, and never
// for the guarded classes (garage/door/gate) — for those a value smuggled into
// the config degrades to 'info', exactly like a card-wide toggle does.
// The editor never offers a separate cover option. Secure cover classes
// (garage/door/gate) resolve to a quiet no-op rather than an info-card fallback.
// With «Icon + activity», travelling breathes a soft activity ring (the
// vacuum puck's 2.2s period) and the plate stays NEUTRAL — yellow means work.
// Since 2026-08-04 the plate is neutral in EVERY cover state, the «открыто»
@@ -26,7 +25,10 @@ const out = await page.evaluate(async () => {
const gateEntity = (state, attrs) => ({
entity_id: 'cover.gate', state,
attributes: { friendly_name: 'Gate', ...attrs },
// Declare the capabilities exercised below. Issue #94 intentionally
// resolves services fail-closed, so an entity without feature metadata is
// not a valid fixture for open / close / stop dispatch.
attributes: { friendly_name: 'Gate', supported_features: 11, ...attrs },
});
/** Push a cover state into hass keeping the service recorder in place. */
const setCover = async (state, attrs = {}) => {
@@ -71,26 +73,35 @@ const out = await page.evaluate(async () => {
c.requestUpdate();
await c.updateComplete;
// ---- the dialog offers the option only where it makes sense ------------
// ---- the dialog offers one universal action -----------------------------
await setCover('closed', { device_class: 'curtain' });
await setMarker('cover');
c._setMode('devices'); await c.updateComplete;
const optionValues = () => [...sr().querySelectorAll('hp-dialog option')].map((e) => e.value);
c._openMarkerDialog(gate()); await c.updateComplete;
o.dialogOffersCoverForCurtain = optionValues().includes('cover');
// …and the confirmation checkbox follows the choice, like it does for run/toggle
const rowsBefore = sr().querySelectorAll('hp-dialog .srcrow').length;
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' }; await c.updateComplete;
o.confirmCheckboxShownForCover = sr().querySelectorAll('hp-dialog .srcrow').length > rowsBefore;
o.dialogOffersOnlyUnifiedToggle = optionValues().includes('toggle')
&& !optionValues().includes('cover');
// Opening a legacy marker projects it to the unified selector without
// rewriting the persisted token until the user intentionally changes it.
o.legacyCoverProjectsToToggle = c._markerDialog?.tapAction === 'toggle'
&& c._markerDialog?.tapActionTouched === false;
o.untouchedLegacyCoverRemainsLossless =
c._markerTapActionFields(c._markerDialog)?.tap_action === 'cover';
o.intentionalEditWritesUnifiedToggle = c._markerTapActionFields({
...c._markerDialog, tapAction: 'toggle', tapActionTouched: true,
})?.tap_action === 'toggle';
c._markerDialog = null; await c.updateComplete;
// a GARAGE door never gets the option (owner: «нет, только шторы/жалюзи»)
// The same universal option is visible for a garage; runtime safety decides.
await setCover('closed', { device_class: 'garage' });
c._openMarkerDialog(gate()); await c.updateComplete;
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
o.garageAlsoShowsUnifiedToggle = optionValues().includes('toggle')
&& !optionValues().includes('cover');
c._markerDialog = null; await c.updateComplete;
// …and neither does a device without a cover entity at all
c._openMarkerDialog(c._devices.find((x) => x.bindingRef === 'd_mower')); await c.updateComplete;
o.dialogHidesCoverWithoutCover = !optionValues().includes('cover');
o.virtualAlsoShowsUnifiedToggle = optionValues().includes('toggle')
&& !optionValues().includes('cover');
c._markerDialog = null; await c.updateComplete;
c._setMode('view'); c._space = 'garden'; c.requestUpdate(); await c.updateComplete;
@@ -138,19 +149,19 @@ const out = await page.evaluate(async () => {
await new Promise((r) => setTimeout(r, 10));
o.confirmOkOpens = calls.length === 1 && calls[0][1] === 'open_cover';
// ---- a saved 'cover' on a GARAGE degrades to the info card -------------
// ---- a saved 'cover' on a GARAGE is a secure, quiet no-op ---------------
await setMarker('cover');
await setCover('closed', { device_class: 'garage' });
calls.length = 0;
await tap();
await new Promise((r) => setTimeout(r, 10));
o.garageSavedCoverCallsNothing = calls.length === 0;
o.garageSavedCoverShowsInfo = !!c._infoCard;
o.garageSavedCoverDoesNotFallbackToInfo = !c._infoCard;
c._infoCard = null; await c.updateComplete;
// ---- the indication ----------------------------------------------------
const afterAnim = () => {
const el = devEl()?.querySelector('.activity-ring.transition i:first-child');
const el = devEl()?.querySelector('.device-pulse.continuous.transition i:first-child');
if (!el) return null;
const cs = getComputedStyle(el);
return { name: cs.animationName, dur: cs.animationDuration };
@@ -163,7 +174,7 @@ const out = await page.evaluate(async () => {
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.dev').length === 1;
o.pulseWhileOpening = sr().querySelectorAll('.dev.activity-transition').length === 1;
const a1 = afterAnim();
o.pulseIsBreathingRing = a1?.name === 'hp-activity-breathe' && a1?.dur === '2.2s';
o.pulseIsBreathingRing = a1?.name === 'hp-pulse-continuous' && a1?.dur === '2.4s';
// the plate stays NEUTRAL while travelling: yellow is reserved for «включено»
o.movingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
+11 -2
View File
@@ -4,11 +4,17 @@ const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
// 1) третья вкладка есть
const tabs = [...sr().querySelectorAll('.modetab')];
out.threeTabs = tabs.length === 3;
tabs[2].click(); await c.updateComplete;
tabs[2].click(); await settleMode();
out.decorMode = c._mode === 'decor';
out.decorBar = !!sr().querySelector('.editbar.decorbar');
// The compact colour/opacity control must escape the clipped editor/dialog
@@ -253,7 +259,10 @@ const res = await page.evaluate(async () => {
c._redoGeometry(); await c.updateComplete;
out.lineStyleUndoRedo = undoSolid
&& c._decorList.find((x) => x.id === 'dcprobe')?.line_style === 'dashed';
const selectHit = sr().querySelector('.decorlayer line.dselecthit');
// Selection handles intentionally sit above the hit proxy. Clear the frame
// so this assertion measures the dashed line's gap, not an endpoint handle.
c._decorSel = null; c.requestUpdate(); await c.updateComplete;
const selectHit = sr().querySelector('.decorlayer line.dselecthit[data-id="dcprobe"]');
const dashedPaint = probe();
let gapPicked = null;
if (selectHit && dashedPaint) {
+7 -1
View File
@@ -21,6 +21,12 @@ const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
// null-safe: на сборке ДО этих правок у надписи нет ни data-id, ни tspan —
// смок должен показать список провалов, а не упасть с исключением
const el = (id) => sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
@@ -41,7 +47,7 @@ const res = await page.evaluate(async () => {
cancelable: true, pointerId: 7, clientX, clientY, button: 0, isPrimary: true, ...extra }));
};
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
sr().querySelectorAll('.modetab')[2].click(); await settleMode();
c._curSpaceCfg.decor = [];
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
+96
View File
@@ -20,6 +20,9 @@ const res = await page.evaluate(async () => {
scale: node.style.getPropertyValue('--dev-scale'),
rippleScale: node.style.getPropertyValue('--ripple-scale'),
rippleColor: node.style.getPropertyValue('--ripple-color'),
pulse: [...(node.querySelector('.device-pulse')?.classList || [])]
.filter((name) => ['alarm', 'short', 'continuous', 'event', 'presence', 'transition', 'running'].includes(name)),
reducedMotionDot: !!node.querySelector('.activity-dot'),
};
};
@@ -37,6 +40,45 @@ const res = await page.evaluate(async () => {
const previewFace = face(preview?.renderRoot?.querySelector('.dev'));
const providerShown = /demo/i.test(preview?.renderRoot?.querySelector('.previewfacts')?.textContent || '');
// Dynamic option creation must not leave a freshly mounted native select on
// its first row when a different display/room/badge choice is persisted.
// Unmount the dialog first: replacing one open marker can leave old option
// nodes around and would miss the real first-open regression.
c._markerDialog = null;
await c.updateComplete;
const badgeDevice = c._devices.find((item) => item.id === 'd_temp');
c._openMarkerDialog({
...badgeDevice,
marker: {
...(badgeDevice.marker || {}),
display: 'value',
value_badge: {
enabled: true,
source: { kind: 'derived_lqi' },
position: 'left',
},
},
});
await c.updateComplete;
const displaySelect = sr().querySelector('#marker-display');
const roomSelect = sr().querySelector('#marker-room');
const badgeSourceSelect = sr().querySelector('#marker-value-badge-source');
const badgePositionSelect = sr().querySelector('#marker-value-badge-position');
const savedBadgePreview = sr().querySelector('hp-device-preview');
await savedBadgePreview?.updateComplete;
const savedDisplaySelected = displaySelect?.value === 'value'
&& displaySelect?.selectedOptions?.[0]?.value === 'value';
const expectedRoom = c._markerDialog?.room || '';
const savedRoomSelected = expectedRoom !== '' && roomSelect?.value === expectedRoom
&& roomSelect?.selectedOptions?.[0]?.value === expectedRoom;
const savedBadgeSourceSelected = badgeSourceSelect?.value === 'derived:lqi'
&& badgeSourceSelect?.selectedOptions?.[0]?.value === 'derived:lqi';
const savedBadgePositionSelected = badgePositionSelect?.value === 'left'
&& badgePositionSelect?.selectedOptions?.[0]?.value === 'left';
const savedBadgePreviewMatches = savedBadgePreview?.renderRoot
?.querySelector('.value-badge')?.textContent?.trim() === '154';
const savedBadgeUntouched = c._markerDialog?.valueBadgeTouched === false;
// Issue #90: every explicit anchor, including a long value and the largest
// activity ring, stays inside the preview safe area with a real measured gap.
c.hass.states['sensor.hp_preview_long'] = {
@@ -63,9 +105,53 @@ const res = await page.evaluate(async () => {
&& badge.top >= stage.top + gap && badge.bottom <= stage.bottom - gap;
}
// One persisted bottom badge is then projected through all three renderers.
// It intentionally coexists with LQI to guard the vertical stack contract.
const persistedBadge = {
id: 'd_temp', binding: 'device:d_temp',
value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: 'sensor.living_temp' },
position: 'bottom',
},
};
c._serverCfg.markers = [
...(c._serverCfg.markers || []).filter((marker) => marker.id !== 'd_temp'),
persistedBadge,
];
c._regSignature = '';
c._maybeRebuildDevices();
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
const planBadgeNode = sr().querySelector('.dev[data-id="d_temp"]');
const planBadgeFace = face(planBadgeNode);
const planBadgeRect = planBadgeNode?.querySelector('.value-badge')?.getBoundingClientRect();
const planLqiRect = planBadgeNode?.querySelector('.lqi')?.getBoundingClientRect();
const bottomBadgeLqiStacked = !!planBadgeRect && !!planLqiRect
&& planLqiRect.top >= planBadgeRect.bottom - 1;
c._openMarkerDialog(c._devices.find((item) => item.id === 'd_temp'));
await c.updateComplete;
const persistedPreview = sr().querySelector('hp-device-preview');
await persistedPreview?.updateComplete;
const persistedPreviewFace = face(persistedPreview?.renderRoot?.querySelector('.dev'));
await customElements.whenDefined('houseplan-space-card');
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
// A real static card reads the latest complete server/cache snapshot. Inject
// that same revision explicitly here so this smoke compares renderers, not
// the demo server's intentionally non-persistent websocket fixture.
card._snap = {
config: structuredClone(c._serverCfg),
rev: c._cfgRev,
configFingerprint: `fixture-${c._cfgRev}`,
layout: structuredClone(c._layout),
layoutRev: c._layoutRev,
layoutFingerprint: `fixture-${c._layoutRev}`,
};
card._loadedOnce = true;
card.hass = c.hass;
document.body.appendChild(card);
const started = Date.now();
@@ -73,13 +159,23 @@ const res = await page.evaluate(async () => {
await card.updateComplete;
const staticNode = card.renderRoot.querySelector('.dev[data-id="d_light1"]');
const staticFace = face(staticNode);
const staticBadgeFace = face(card.renderRoot.querySelector('.dev[data-id="d_temp"]'));
return {
allFacesPresent: !!planFace && !!previewFace && !!staticFace,
planPreviewEqual: JSON.stringify(planFace) === JSON.stringify(previewFace),
planStaticEqual: JSON.stringify(planFace) === JSON.stringify(staticFace),
providerShown,
savedDisplaySelected,
savedRoomSelected,
savedBadgeSourceSelected,
savedBadgePositionSelected,
savedBadgePreviewMatches,
savedBadgeUntouched,
badgeBounds,
valueBadgePlanPreviewParity: JSON.stringify(planBadgeFace) === JSON.stringify(persistedPreviewFace),
valueBadgePlanStaticParity: JSON.stringify(planBadgeFace) === JSON.stringify(staticBadgeFace),
bottomBadgeLqiStacked,
staticBindingHook: staticNode?.getAttribute('data-binding-status') === 'active',
};
});
+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,
});
+55 -17
View File
@@ -5,6 +5,12 @@ const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const tabs = () => [...sr().querySelectorAll('.modetab')];
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
// 1) две вкладки, Просмотра нет, крестиков в неактивных нет
out.twoTabs = tabs().length === 3; // третья — Редактор подложки (v1.33.0)
out.labels = tabs().map((t) => t.textContent.trim());
@@ -19,6 +25,22 @@ const res = await page.evaluate(async () => {
const barCloseIcon = barClose?.querySelector('ha-icon');
const barCloseRect = barClose?.getBoundingClientRect();
const barCloseIconRect = barCloseIcon?.getBoundingClientRect();
const enteringChrome = sr().querySelector('.editorchrome');
// Regression #95 follow-up: the bar is already painted during the 220 ms
// mode transition, so its explicit Close command must not be inert together
// with the editing tools.
out.closeRemainsInteractiveDuringEnter = c._modeTransitionBusy
&& enteringChrome?.inert === false
&& sr().querySelector('.editbar-tools')?.inert === true
&& getComputedStyle(barClose).pointerEvents === 'auto';
const enteringStage = sr().querySelector('.stage');
const editorSceneLayer = enteringStage?.querySelector('.hp-editor-only-layer');
const enteringVisual = c._modeTransitionVisual;
out.sceneLayersUseTransitionWeights = c._modeTransitionBusy && !!enteringVisual
&& !!editorSceneLayer
&& Math.abs(Number(editorSceneLayer.getAttribute('opacity')) - enteringVisual.editorWeight) < 0.001
&& Math.abs(Number(getComputedStyle(enteringStage).getPropertyValue('--hp-mode-view-weight'))
- enteringVisual.viewWeight) < 0.001;
out.planBarCloseIconIsCentered = !!barCloseRect && !!barCloseIconRect
&& Math.abs((barCloseIconRect.left + barCloseIconRect.width / 2)
- (barCloseRect.left + barCloseRect.width / 2)) <= 1
@@ -28,10 +50,7 @@ const res = await page.evaluate(async () => {
// 3) повторный клик по активной вкладке — ничего
tabs()[0].click(); await c.updateComplete;
out.reclickNoop = c._mode === 'plan';
// Let the View -> editor grid transition finish first. A real user cannot
// switch editors in the same zero-layout frame, and competing WAAPI/CSS
// height animations in that frame are intentionally suppressed.
await new Promise((resolve) => setTimeout(resolve, 220));
await settleMode();
// HP-UX-11: transient tool controls live in a stage overlay. Opening the
// thickness controls must not resize/refit the stage or move pinned Close.
const stageBeforeContext = sr().querySelector('.stage').getBoundingClientRect();
@@ -51,8 +70,9 @@ const res = await page.evaluate(async () => {
out.contextDoesNotMoveClose = Math.abs(closeWithContext.left - closeBeforeContext.left) <= 1
&& Math.abs(closeWithContext.top - closeBeforeContext.top) <= 1;
out.closeHasPinnedEndCap = !!sr().querySelector('.editbar > .editbar-end > .barclose');
// No current tools are grouped automatically. Inject one declarative group
// to pin the generic launcher/keyboard/restore contract for future editors.
// Replace the real Opening group with a side-effect-free command group to
// pin the generic launcher/keyboard/restore contract independently of any
// one editor tool's business logic.
let groupInvokes = 0;
Object.defineProperty(c, '_editorToolbarGroups', { configurable: true, get: () => [{
id: 'smoke-group', label: 'Smoke group', icon: 'mdi:dots-grid', items: [{
@@ -104,6 +124,23 @@ const res = await page.evaluate(async () => {
out.dismissTailDoesNotEatUnrelatedClick = unrelatedInvokes === 1;
outsideTool.remove();
unrelatedTool.remove();
// An open grouped submenu still consumes ordinary outside clicks, but an
// explicit editor navigation command has higher priority and must close both
// surfaces in the same pointer sequence.
groupLauncher?.click(); await c.updateComplete;
const closeWithOpenGroup = sr().querySelector('.editbar .barclose');
closeWithOpenGroup?.dispatchEvent(new PointerEvent('pointerdown', {
pointerId: 302, pointerType: 'mouse', button: 0,
bubbles: true, composed: true, cancelable: true,
}));
closeWithOpenGroup?.dispatchEvent(new MouseEvent('click', {
button: 0, bubbles: true, composed: true, cancelable: true,
}));
await c.updateComplete;
out.editorNavigationOutranksGroupDismiss = c._mode === 'view'
&& c._editorSecondary.hasOpenGroup === false;
await settleMode();
tabs()[0].click(); await settleMode();
delete c._editorToolbarGroups;
c._tool = 'select'; c.requestUpdate(); await c.updateComplete;
// 4) прямое переключение План → Устройства
@@ -117,30 +154,31 @@ const res = await page.evaluate(async () => {
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 3; // add/show-all/rules (v1.33.2: Reset removed)
const swapChrome = sr().querySelector('.editorchrome');
const swapInner = swapChrome.querySelector('.editorchrome-inner');
out.editorSwapAnimatesHeight = swapChrome.getAnimations()
.some((animation) => animation.id === 'hp-editor-height-swap');
out.editorSwapAnimatesContent = swapInner.getAnimations()
.some((animation) => animation.id === 'hp-editor-content-swap');
out.editorSwapAnimatesHeight = c._modeTransitionBusy
&& sr().querySelector('.stage').classList.contains('mode-transition');
out.editorSwapAnimatesContent = c._modeTransitionBusy && !!swapInner
&& (c._modeTransitionVisual?.toolbarContentOpacity ?? 1) < 1;
await settleMode();
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
// 6) крестик на панели → Просмотр
sr().querySelector('.editbar .barclose').click(); await c.updateComplete;
const chrome = sr().querySelector('.editorchrome');
out.barCloseStarts = c._mode === 'view' && !chrome.classList.contains('open')
&& chrome.getAttribute('aria-hidden') === 'true';
// The last bar deliberately remains mounted while its grid row collapses.
await new Promise((resolve) => setTimeout(resolve, 220));
out.barCloseStarts = c._mode === 'view' && c._modeTransitionBusy
&& sr().querySelector('.stage').inert
&& sr().querySelector('.stage').classList.contains('mode-transition');
await settleMode();
out.barCloseWorks = c._mode === 'view'
&& getComputedStyle(chrome).visibility === 'hidden'
&& chrome.getBoundingClientRect().height < 1;
// 7) крестик в самой вкладке → Просмотр (и не переключает режим)
tabs()[1].click(); await c.updateComplete;
tabs()[1].querySelector('.closex').click(); await c.updateComplete;
tabs()[1].click(); await settleMode();
tabs()[1].querySelector('.closex').click(); await settleMode();
out.tabCrossWorks = c._mode === 'view';
// Layout-independent editor history: physical KeyZ works when `key` is the
// Russian «я», while an input keeps its native browser history.
tabs()[0].click(); await c.updateComplete;
tabs()[0].click(); await settleMode();
c._tool = 'draw'; c.requestUpdate(); await c.updateComplete;
const realUndo = c._undoGeometry;
const realRedo = c._redoGeometry;
+16
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;
@@ -38,6 +45,13 @@ const res = await page.evaluate(async () => {
settings: { ...(s.settings||{}), show_lqi: false } })) };
c.requestUpdate(); await c.updateComplete;
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
// The space value is an explicit override, not another condition ANDed with
// the card default. This is the projection shared by preview/static cards.
c._config = { ...c._config, show_signal: false };
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
settings: { ...(s.settings||{}), show_lqi: true } })) };
c.requestUpdate(); await c.updateComplete;
out.spaceLqiOverridesCardDefault = sr().querySelectorAll('.dev .lqi').length > 0;
return out;
});
// CARD_VERSION из собранного бандла (тот же текст уходит в console-баннер).
@@ -60,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,
});
+59 -3
View File
@@ -282,6 +282,20 @@ const rasterFixture = await page.evaluate(async () => {
if (!source || !sourceEid || !current) return { ready: false };
const edgeKey = (a, b) => `${a.join(',')}/${b.join(',')}`;
const wallKey = (a, b) => {
const pitch = 1 / 240;
const q = (value) => Math.round(value / pitch) * pitch;
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy) || 1;
dx /= length; dy /= length;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) {
dx = -dx; dy = -dy;
}
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
const angleBucket = Math.round(angle * 1800) / 1800;
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angleBucket.toFixed(4)}`;
};
const edges = new Map();
for (const room of current.rooms || []) {
for (let index = 0; index < room.poly.length; index++) {
@@ -291,13 +305,21 @@ const rasterFixture = await page.evaluate(async () => {
if (!edges.has(reverse) && !edges.has(edgeKey(a, b))) edges.set(edgeKey(a, b), { a, b });
}
}
const walls = [...edges.values()].map(({ a, b }, index) => ({
key: `glow-raster-wall-${index}`, a, b, cm: 15,
const walls = [...edges.values()].map(({ a, b }) => ({
key: wallKey(a, b), a, b, cm: 15,
}));
const openings = [
{ id: 'glow-raster-east', type: 'door', x: 0.55, y: 0.32, angle: 90, length: 0.09 },
{ id: 'glow-raster-south', type: 'door', x: 0.32, y: 0.58, angle: 0, length: 0.09 },
];
const wallSource = [...edges.values()]
.map(({ a, b }) => ({
point: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
clearance: Math.min(...openings.map((opening) => Math.hypot(
(a[0] + b[0]) / 2 - opening.x, (a[1] + b[1]) / 2 - opening.y,
))),
}))
.sort((left, right) => right.clearance - left.clearance)[0]?.point;
c._serverCfg = {
...c._serverCfg,
settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 },
@@ -425,7 +447,7 @@ const rasterFixture = await page.evaluate(async () => {
}),
};
}
return { ready: true, litParts, sourceEid, doors, shadow };
return { ready: true, litParts, sourceEid, sourceId: source.id, wallSource, doors, shadow };
});
if (rasterFixture.ready) {
@@ -531,6 +553,39 @@ if (rasterFixture.ready) {
res.shadowEdgeIsCrisp = !!metrics.shadow
&& metrics.shadow.high - metrics.shadow.low >= 8
&& metrics.shadow.edgeWidth <= 4;
const sourceInsideWall = await page.evaluate(async ({ sourceEid, sourceId, wallSource }) => {
if (!wallSource) return false;
const c = window.__card;
const state = c.hass.states[sourceEid];
c._layout = {
...c._layout,
[sourceId]: { s: c._space, x: wallSource[0], y: wallSource[1] },
};
c.hass = {
...c.hass,
states: { ...c.hass.states, [sourceEid]: { ...state, state: 'on' } },
};
c._glowClipCache.clear();
c.requestUpdate();
await c.updateComplete;
// The continuity contract may keep the last complete device frame briefly
// while the moved source and its HA snapshot are staged atomically. Wait
// for that bounded hand-off instead of mistaking the allowed stale frame
// for a geometry leak.
const root = c.shadowRoot || c.renderRoot;
const selector = `[data-glow-source="${CSS.escape(sourceEid)}"] .glow-pool`;
const deadline = performance.now() + 1800;
while (root.querySelector(selector) && performance.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 40));
await c.updateComplete;
}
return !root.querySelector(selector);
}, {
sourceEid: rasterFixture.sourceEid,
sourceId: rasterFixture.sourceId,
wallSource: rasterFixture.wallSource,
});
res.sourceInsideWallSuppressed = sourceInsideWall;
} else {
res.litPartsMatchClip = false;
res.doorwayCarriesLight = false;
@@ -538,6 +593,7 @@ if (rasterFixture.ready) {
res.apertureItselfLit = false;
res.occluderCastsShadow = false;
res.shadowEdgeIsCrisp = false;
res.sourceInsideWallSuppressed = false;
}
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
+1 -1
View File
@@ -106,7 +106,7 @@ const out = await page.evaluate(async () => {
// ---- 1e) an OPENING is WALL-bound: on its wall, whole steps along it ---
c._setMode('plan'); await c.updateComplete;
c._tool = 'opening';
c._activateOpeningPlacement('door');
const spm = c._spaceModel(c._space);
const wall = (() => {
for (const r of spm.rooms) {
+10 -5
View File
@@ -93,18 +93,23 @@ const res = await page.evaluate(async () => {
light_entity: draft.lightEntity || null,
},
} : realPreview(draft);
c._openMarkerDialog(multi); await c.updateComplete;
c._setMarkerLightRole('always'); await c.updateComplete;
c._openMarkerDialog({
...multi,
marker: { ...(multi.marker || {}), is_light: true, light_entity: extra },
});
await c.updateComplete;
out.leadingDraftIsAlways = c._markerDialog.lightRole === 'always';
out.leadingPreviewHasExtra = c._markerPreviewDevice(c._markerDialog)?.entities?.includes(extra) === true;
const leading = sr().querySelector('hp-dialog #marker-light-entity');
out.leadingSelectorForMultiple = !!leading && leading.options.length === 3;
out.savedLeadingEntitySelected = leading?.value === extra
&& leading?.selectedOptions?.[0]?.value === extra;
if (leading) {
leading.value = extra;
leading.value = multi.primary;
leading.dispatchEvent(new Event('change', { bubbles: true }));
await c.updateComplete;
out.leadingUpdatesPreview = c._markerDialog.lightEntity === extra
&& c._markerSpatialSource(c._markerDialog)?.eid === extra;
out.leadingUpdatesPreview = c._markerDialog.lightEntity === multi.primary
&& c._markerSpatialSource(c._markerDialog)?.eid === multi.primary;
} else {
out.leadingUpdatesPreview = false;
}
+47
View File
@@ -48,12 +48,53 @@ const res = await page.evaluate(async () => {
|| root().querySelector('hp-dialog')?.shadowRoot?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
};
// Empty or incomplete content must not leave a dead focus target behind.
const incompleteHelp = document.createElement('hp-help');
incompleteHelp.text = ' ';
incompleteHelp.ariaLabel = 'Help: unavailable description';
document.body.append(incompleteHelp);
await incompleteHelp.updateComplete;
out.emptyTextHidesTrigger = !incompleteHelp.shadowRoot?.querySelector('.trigger')
&& getComputedStyle(incompleteHelp).display === 'none';
incompleteHelp.text = 'Available description';
incompleteHelp.ariaLabel = ' ';
await incompleteHelp.updateComplete;
out.emptyAriaHidesTrigger = !incompleteHelp.shadowRoot?.querySelector('.trigger')
&& getComputedStyle(incompleteHelp).display === 'none';
incompleteHelp.ariaLabel = 'Help: available description';
await incompleteHelp.updateComplete;
const restoredTrigger = incompleteHelp.shadowRoot?.querySelector('.trigger');
out.circledQuestionIcon = restoredTrigger?.querySelector('svg[viewBox="0 0 24 24"] path')
?.getAttribute('d')?.length > 0;
incompleteHelp.text = Array.from({ length: 320 }, (_, index) => `Line ${index + 1}`).join(' · ');
await incompleteHelp.updateComplete;
const overflowTrigger = incompleteHelp.shadowRoot?.querySelector('.trigger');
overflowTrigger?.click();
await incompleteHelp.updateComplete;
await frame();
const longSurface = incompleteHelp.shadowRoot?.querySelector('.tooltip');
overflowTrigger?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'PageDown', bubbles: true, composed: true, cancelable: true,
}));
await frame();
out.overflowHelpScrollsFromTrigger = !!longSurface
&& longSurface.scrollHeight > longSurface.clientHeight && longSurface.scrollTop > 0;
overflowTrigger?.click();
incompleteHelp.remove();
card._setMode('devices');
const marker = card._devices[0];
card._openMarkerDialog(marker);
await card.updateComplete;
await frame();
const dialogBody = root().querySelector('hp-dialog .body');
const scrollGeometry = () => dialogBody ? {
clientHeight: dialogBody.clientHeight,
scrollHeight: dialogBody.scrollHeight,
scrollTop: dialogBody.scrollTop,
} : null;
const roleHelp = root().querySelector('hp-help[data-help-key="marker.light_role.help"]');
const roleButton = helpButton('marker.light_role.help');
const roleDescription = roleHelp?.shadowRoot?.querySelector('[role="tooltip"]');
@@ -61,11 +102,14 @@ const res = await page.evaluate(async () => {
&& !!roleDescription?.id
&& !roleButton.hasAttribute('aria-describedby');
const beforeHoverGeometry = scrollGeometry();
roleButton?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse', bubbles: true, composed: true }));
await wait(330);
out.mouseHover = roleButton?.getAttribute('aria-expanded') === 'true'
&& roleButton.getAttribute('aria-describedby') === roleDescription?.id
&& !!helpSurface('marker.light_role.help');
out.hoverKeepsDialogScrollGeometry = JSON.stringify(scrollGeometry())
=== JSON.stringify(beforeHoverGeometry);
roleButton?.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'mouse', bubbles: true, composed: true }));
await wait(180);
out.mouseLeave = roleButton?.getAttribute('aria-expanded') === 'false';
@@ -155,12 +199,15 @@ const res = await page.evaluate(async () => {
const hide = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'hidePopover');
Object.defineProperty(HTMLElement.prototype, 'showPopover', { configurable: true, value: undefined });
Object.defineProperty(HTMLElement.prototype, 'hidePopover', { configurable: true, value: undefined });
const beforeFallbackGeometry = scrollGeometry();
glowHelpButton?.click();
await frame();
const dialog = root().querySelector('hp-dialog');
const fallback = dialog?.shadowRoot?.querySelector('[data-hp-overlay="help"]');
const fallbackBox = fallback?.shadowRoot?.querySelector('.tooltip')?.getBoundingClientRect();
out.realFallback = !!fallback && !!fallbackBox?.width && !!fallbackBox?.height;
out.fallbackKeepsDialogScrollGeometry = JSON.stringify(scrollGeometry())
=== JSON.stringify(beforeFallbackGeometry);
glowHelpButton?.click();
pickerButton?.click();
await frame();
+8 -2
View File
@@ -154,7 +154,7 @@ Object.assign(out, await page.evaluate(async () => {
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
o.rippleGhostHasIcon = ghosts.length > 0 && ghosts.every((x) => !!x.querySelector('ha-icon'));
o.rippleGhostNoNoicon = ghosts.every((x) => !x.classList.contains('noicon'));
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.activity-ring'));
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.device-pulse'));
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
c._setMode('view'); c._showHidden = false;
@@ -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');
+122
View File
@@ -0,0 +1,122 @@
// #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 });
const out = await page.evaluate(async () => {
const card = window.__card;
const root = () => card.renderRoot;
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const center = (element) => {
const rect = element.getBoundingClientRect();
return [rect.left + rect.width / 2, rect.top + rect.height / 2];
};
const enable = async () => {
history.replaceState(null, '', '#space=f1&hp-labs=iso');
dispatchEvent(new HashChangeEvent('hashchange'));
await card.updateComplete;
await frame();
};
const result = {};
const configSpace = card._serverCfg.spaces.find((space) => space.id === 'f1');
configSpace.settings = {
...(configSpace.settings || {}), show_borders: true, show_names: true,
};
configSpace.partitions = [{
id: 'iso-smoke-wall', a: [0.15, 0.12], b: [0.85, 0.12], cm: 15,
}];
card._cfgEpoch++;
card.requestUpdate();
await card.updateComplete;
await enable();
const toggle = root().querySelector('[data-hp="projection-toggle"]');
result.labsSnapshotFrozen = Object.isFrozen(window.__hpLabs)
&& JSON.stringify(window.__hpLabs) === '["iso"]';
result.toggleShown = !!toggle;
result.toggleMinHitTarget = toggle && toggle.getBoundingClientRect().width >= 44
&& toggle.getBoundingClientRect().height >= 44;
result.flatDefault = toggle?.getAttribute('aria-pressed') === 'false'
&& !root().querySelector('[data-hp="iso-walls"]');
toggle?.click();
await card.updateComplete;
await frame();
const isoToggle = root().querySelector('[data-hp="projection-toggle"]');
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)));
const deviceBefore = device ? center(device) : null;
card.hass = { ...card.hass, states: { ...card.hass.states } };
await card.updateComplete;
const deviceAfter = root().querySelector('[data-hp="device"]');
result.haUpdateKeepsGeometry = root().querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint
&& card._isoGeometryCache.size === 1;
result.haUpdateKeepsAnchor = !!deviceBefore && !!deviceAfter
&& Math.hypot(...center(deviceAfter).map((value, index) => value - deviceBefore[index])) <= 1;
card._setMode('plan');
await card.updateComplete;
while (card._modeTransitionBusy) await frame();
result.editorIsFlat = card._mode === 'plan'
&& !root().querySelector('[data-hp="projection-toggle"]')
&& !root().querySelector('[data-hp="iso-walls"]');
card._setMode('view');
await card.updateComplete;
while (card._modeTransitionBusy) await frame();
result.viewRestoresIso = root().querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true'
&& !!root().querySelector('[data-hp="iso-walls"]');
const source = card._isoSource;
let attempts = 0;
let shouldFail = true;
const fallbackKey = 'f1|injected-render-failure';
card._isoGeometryCache.clear();
card._isoSource = () => ({
key: fallbackKey,
build: () => {
attempts++;
if (shouldFail) throw new Error('injected isometric failure');
return { walls: [], floor: [], openings: [] };
},
});
card.requestUpdate();
await card.updateComplete;
card.hass = { ...card.hass, states: { ...card.hass.states } };
await card.updateComplete;
result.fallbackLatched = attempts === 1
&& root().querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'false'
&& !root().querySelector('[data-hp="iso-walls"]')
&& JSON.parse(localStorage.getItem('houseplan_card_view_v1') || '{}').f1 === 'iso';
shouldFail = false;
card._setProjection('iso');
await card.updateComplete;
result.explicitRetryRestoresIso = attempts === 2
&& !!root().querySelector('[data-hp="iso-walls"]')
&& root().querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true';
card._isoSource = source;
card._isoGeometryCache.delete(fallbackKey);
card._isoFallback.delete(fallbackKey);
card.requestUpdate();
await card.updateComplete;
history.replaceState(null, '', '#space=f1&hp-labs=-iso');
dispatchEvent(new HashChangeEvent('hashchange'));
await card.updateComplete;
await frame();
result.removalIsImmediateFlat = JSON.stringify(window.__hpLabs) === '[]'
&& !root().querySelector('[data-hp="projection-toggle"]')
&& !root().querySelector('[data-hp="iso-walls"]')
&& !root().querySelector('[id^="hp-iso-"]');
return result;
});
checkAll(out);
await finish(browser, out);
+336
View File
@@ -0,0 +1,336 @@
// #122 Stage 2: live-floor preservation, vertical openings, touch and remount paths.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(
{ width: 430, height: 820 }, 1, [], { hasTouch: true, isMobile: true },
);
const out = await page.evaluate(async () => {
const original = window.__card;
const root = (card) => card.renderRoot;
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
history.replaceState(null, '', '?hp-labs=iso#space=f1');
dispatchEvent(new HashChangeEvent('hashchange'));
await original.updateComplete;
const configSpace = original._serverCfg.spaces.find((space) => space.id === 'f1');
configSpace.settings = {
...(configSpace.settings || {}), fill_mode: 'custom',
custom_fill: { c: '#31465f', a: 0.42 }, glow_enabled: true,
show_borders: true, show_names: true, north_deg: 0, sun_rays: true,
};
configSpace.rooms[0].settings = {
...(configSpace.rooms[0].settings || {}), fill_mode: 'none', glow: true,
};
configSpace.openings = [
{ id: 'iso-window', type: 'window', x: 0.28, y: 0.14, angle: 0, length: 0.12,
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,
}];
configSpace.decor = [
{ id: 'iso-line', kind: 'line', x1: 0.12, y1: 0.7, x2: 0.45, y2: 0.7,
color: '#667788', opacity: 0.7, width_cm: 1 },
{ id: 'iso-sofa', kind: 'furniture', symbol: 'sofa', x: 0.62, y: 0.55,
w: 0.2, h: 0.12, color: '#667788', opacity: 0.7, width_cm: 1 },
];
original._serverCfg.markers = original._serverCfg.markers || [];
original._serverCfg.markers.push({
id: 'iso-vacuum', binding: 'device:d_mower', space: 'f1', area: 'living_room',
vacuum: {
source: 'camera.static_robot_map', trail_mode: 'always',
calibration: { m1: [0.5, 0, 0, 0, 0.5, 0] },
},
});
original._layout['iso-vacuum'] = { s: 'f1', x: 0.18, y: 0.78 };
original._hoverRoom = { space: 'f1', room: original._spaceModel('f1').rooms[0] };
original.hass = {
...original.hass,
states: {
...original.hass.states,
'light.floor_lamp': {
...original.hass.states['light.floor_lamp'], state: 'on',
},
'vacuum.mower': {
...original.hass.states['vacuum.mower'], state: 'cleaning',
},
'camera.static_robot_map': {
entity_id: 'camera.static_robot_map', state: 'idle',
attributes: {
map_name: 'm1', vacuum_position: { x: 650, y: 700, a: 0 },
path: [[200, 200], [420, 440], [650, 700]],
},
},
'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 0, elevation: 24 },
},
},
};
original._cfgEpoch++;
original._regSignature = '';
original.requestUpdate();
await original.updateComplete;
await frame();
const count = (selector) => root(original).querySelectorAll(selector).length;
const layers = () => ({
rooms: count('[data-hp="room"]'),
roomFills: count('[data-hp="room"].filled'),
decor: count('.decorlayer [data-hp="decor"]'),
furniture: count('.decorlayer .dfurn'),
backdrop: count('.hp-backdrop'),
glowBase: count('.glow-base-layer .glow-base'),
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'),
vacuumTrails: count('.vactrail .case'),
});
const flat = layers();
const flatSpillParts = [...root(original).querySelectorAll('.glow-pool')]
.map((node) => node.getAttribute('data-lit-parts'));
root(original).querySelector('[data-hp="projection-toggle"]')?.click();
await original.updateComplete;
await frame();
const before = layers();
const beforeSpillParts = [...root(original).querySelectorAll('.glow-pool')]
.map((node) => node.getAttribute('data-lit-parts'));
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: {
...original.hass.states,
'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;
const after = layers();
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, 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 === 0
&& before.verticalOpenings === 5
&& before.devices > 0 && before.hover > 0 && before.vacuumPucks > 0
&& before.vacuumTrails >= 2,
liveLayersStable: JSON.stringify(after) === JSON.stringify(before),
spillBarrierStable: JSON.stringify(afterSpillParts) === JSON.stringify(beforeSpillParts)
&& beforeSpillParts.length >= 2 && beforeSpillParts.every((parts) => Number(parts) > 0),
floorToOverlayOrderPreserved: ordered.every(Boolean)
&& ordered.slice(1).every((node, index) => Boolean(
ordered[index].compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING
)),
haColorUpdatePainted: [...root(original).querySelectorAll('radialGradient stop')]
.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,
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,
callService: async (domain, service, data) => calls.push({ domain, service, data }),
};
await original.updateComplete;
root(original).querySelector('[data-entity="light.ceiling"]')?.click();
await frame();
original._setProjection('flat');
await original.updateComplete;
root(original).querySelector('[data-entity="light.ceiling"]')?.click();
await frame();
result.flatIsoActionParity = calls.length === 2
&& JSON.stringify(calls[0]) === JSON.stringify(calls[1]);
original._setProjection('iso');
await original.updateComplete;
const stage = root(original).querySelector('.stage');
const stageRect = stage.getBoundingClientRect();
const zoomBefore = original._zoom;
const pointer = (type, id, x, y, buttons) => new PointerEvent(type, {
bubbles: true, composed: true, pointerId: id, pointerType: 'touch',
clientX: x, clientY: y, button: type === 'pointerdown' ? 0 : -1, buttons,
});
const cy = stageRect.top + stageRect.height / 2;
stage.dispatchEvent(pointer('pointerdown', 8902, stageRect.left + 150, cy, 1));
stage.dispatchEvent(pointer('pointerdown', 8903, stageRect.left + 270, cy, 1));
stage.dispatchEvent(pointer('pointermove', 8902, stageRect.left + 115, cy, 1));
stage.dispatchEvent(pointer('pointermove', 8903, stageRect.left + 305, cy, 1));
stage.dispatchEvent(pointer('pointerup', 8902, stageRect.left + 115, cy, 0));
stage.dispatchEvent(pointer('pointerup', 8903, stageRect.left + 305, cy, 0));
await original.updateComplete;
result.touchPinchKeepsIso = original._zoom > zoomBefore
&& !!root(original).querySelector('[data-hp="iso-walls"]');
const opening = root(original).querySelector('[data-hp="opening"] .op-hit');
opening?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
result.touchOpeningIsSafe = !original._openingDialog && !original._openingInfo
&& original._mode === 'view' && calls.length === 2;
const device = root(original).querySelector('[data-hp="device"]');
if (device) {
const rect = device.getBoundingClientRect();
device.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, pointerId: 8901, pointerType: 'touch',
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
button: 0, buttons: 1,
}));
await wait(700);
result.touchLongPressHitsDevice = !!original._infoCard;
device.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, composed: true, pointerId: 8901, pointerType: 'touch',
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
button: 0, buttons: 0,
}));
original._closeInfoCard();
} else result.touchLongPressHitsDevice = false;
original._pickSpace('garden');
await wait(230);
original._setProjection('iso');
await original.updateComplete;
const gardenIso = original._space === 'garden'
&& root(original).querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true';
original._pickSpace('f1');
await wait(230);
result.touchSpaceSwitchKeepsPerSpaceIso = gardenIso && original._space === 'f1'
&& !!root(original).querySelector('[data-hp="iso-walls"]');
const ownVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState');
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
document.dispatchEvent(new Event('visibilitychange'));
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
document.dispatchEvent(new Event('visibilitychange'));
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
await original.updateComplete;
await frame();
const recoveryStarted = performance.now();
while (root(original).querySelector('.recoveryoverlay')
&& performance.now() - recoveryStarted < 2000) await wait(20);
result.backgroundForegroundKeepsIso = !!root(original).querySelector('[data-hp="iso-walls"]')
&& !root(original).querySelector('.recoveryoverlay');
if (ownVisibilityState) Object.defineProperty(document, 'visibilityState', ownVisibilityState);
else delete document.visibilityState;
const host = document.getElementById('host');
const mount = async (kiosk = false) => {
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', kiosk });
host.replaceChildren(card);
card.hass = window.__mkHass();
const started = performance.now();
while ((!card._loadOk || card._booting) && performance.now() - started < 9000) await wait(30);
await card.updateComplete;
await frame();
return card;
};
original.remove();
const warm = await mount(false);
result.warmRemountIso = !warm._booting
&& !!root(warm).querySelector('[data-hp="iso-walls"]')
&& root(warm).querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true';
warm.remove();
const kiosk = await mount(true);
result.kioskReadsPreference = !!root(kiosk).querySelector('[data-hp="iso-walls"]');
result.kioskHasNoToggle = !root(kiosk).querySelector('[data-hp="projection-toggle"]');
return result;
});
await page.setViewportSize({ width: 820, height: 430 });
out.orientationResizeKeepsIso = await page.evaluate(async () => {
const card = document.querySelector('houseplan-card');
window.dispatchEvent(new Event('resize'));
await card.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const anchor = card.renderRoot.querySelector('[data-hp="device"]')?.getBoundingClientRect();
return !!card.renderRoot.querySelector('[data-hp="iso-walls"]')
&& !!anchor && [anchor.left, anchor.top, anchor.width, anchor.height].every(Number.isFinite);
});
out.kioskEmergencyOffIsFlat = await page.evaluate(async () => {
const card = document.querySelector('houseplan-card');
history.replaceState(null, '', '?hp-labs=-iso#space=f1');
dispatchEvent(new HashChangeEvent('hashchange'));
await card.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
return !card.renderRoot.querySelector('[data-hp="iso-walls"]')
&& !card.renderRoot.querySelector('[data-hp="projection-toggle"]');
});
checkAll(out);
await finish(browser, out);
+9 -3
View File
@@ -7,6 +7,12 @@ const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const setFill = async (mode) => {
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
const f1 = cfg.spaces.find((s) => s.id === 'f1');
@@ -47,18 +53,18 @@ const out = await page.evaluate(async () => {
o.morphInGlow = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
// --- редакторы используют ту же жёлтую подложку независимо от слоя glow --
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
c._setMode('devices'); await settleMode();
le = devEl('d_lamp');
o.devicesEditorLampYellow = !!le && le.classList.contains('on');
o.devicesEditorHasGlow = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
c._setMode('plan'); await settleMode();
// ИМЕННО лампа (mutation-проверка аудита HP-1521-01: розетка тоже on и
// удовлетворяла бы поиску «любой .dev.on», пряча регресс самой лампы)
le = devEl('d_lamp');
o.planEditorLampYellow = !!le && le.classList.contains('on'); // слой скрыт — жёлтый вернулся
o.planEditorKettleStillOn = !!devEl('d_kettle')?.classList.contains('on');
o.planEditorNoGlow = !sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
c._setMode('view'); await settleMode();
// --- выключенная лампа тёмная в обоих режимах ---------------------------
c.hass = { ...c.hass, states: { ...c.hass.states,
+29 -1
View File
@@ -10,9 +10,37 @@ const res = await page.evaluate(async () => {
// лампа (primary light) без явного действия → клик = toggle
const lamp = c._devices.find((d) => d.primary?.startsWith('light.') && !d.tapAction);
out.hasLamp = !!lamp;
const lampState = c.hass.states[lamp.primary]?.state;
const expectedService = lampState === 'off' ? 'turn_on'
: (lampState === 'unknown' || lampState === '' ? 'toggle' : 'turn_off');
c._infoCard = null;
c._clickDevice(new MouseEvent('click'), lamp);
out.lampToggles = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'toggle', lamp.primary]);
const lampCall = calls.at(-1);
out.lampToggles = ['light', 'homeassistant'].includes(lampCall?.[0])
&& lampCall?.[1] === expectedService && lampCall?.[2] === lamp.primary;
// The untouched select is an effective projection, not a frozen draft.
// Reproduce the field race where a dialog was opened before HA identified
// the leading entity as light: runtime already sees light-default toggle,
// and the UI must catch up without materializing a stored tap_action.
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(lamp); await c.updateComplete;
c._markerDialog = {
...c._markerDialog,
tapAction: 'info', tapActionTouched: false,
originalHasTapAction: false, originalTapAction: undefined,
};
await c.updateComplete;
const tapSelect = c.renderRoot.querySelector('#marker-tap-action');
out.defaultLampDialogShowsEffectiveToggle = tapSelect?.value === 'toggle';
out.defaultLampActionRemainsUnmaterialized = !c._markerDialog.tapActionTouched
&& !c._markerDialog.originalHasTapAction;
tapSelect.value = 'info';
tapSelect.dispatchEvent(new Event('change'));
await c.updateComplete;
out.explicitDeviceCardStaysSelected = c.renderRoot.querySelector('#marker-tap-action')?.value === 'info'
&& c._markerDialog.tapActionTouched;
c._markerDialog = null;
c._setMode('view'); await c.updateComplete;
// устройство с не-light primary (сенсор) → клик = инфо
const sensorDev = c._devices.find((d) => d.primary?.startsWith('sensor.') && !d.tapAction);
const n = calls.length;

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