Compare commits

..
Author SHA1 Message Date
claude[bot] cb181ba5ee docs: review document for #231
Issue: #231
User-Visible: no
2026-08-22 15:57:59 +00:00
Sergey Matyunin 6d31553a74 docs: record complete decor golden impact
Issue: #231
User-Visible: no
2026-08-22 18:52:55 +03:00
claude[bot] d46abe51fc docs: review document for #231
Issue: #231
User-Visible: no
2026-08-22 15:51:52 +00:00
Sergey Matyunin b74731698f fix: render decor above room fills
Issue: #231
User-Visible: yes
2026-08-22 18:37:50 +03:00
claude[bot] e943e2f1b2 docs: review document for #231
Issue: #231
User-Visible: no
2026-08-22 15:24:00 +00:00
Sergey Matyunin 9410be6028 docs(spec): prove decor above room hover
Issue: #231
User-Visible: no
2026-08-22 18:19:42 +03:00
claude[bot] d2e7626b2c docs: review document for #231
Issue: #231
User-Visible: no
2026-08-22 15:18:55 +00:00
Sergey Matyunin e023adbbe5 docs(spec): define decor layer composition
Issue: #231
User-Visible: no
2026-08-22 18:10:43 +03:00
Sergey Matyunin dcb752db73 Release v1.67.0-beta.1 candidate
Package the reviewed post-v1.66 Plan precision, geometry durability, backup, ordering, and marker-deduplication work as the first v1.67 prerelease. Versions, production bundles, bilingual changelogs, release notes, status, and documentation provenance are synchronized.

Issue: #199
Issue: #220
Issue: #223
Issue: #224
Issue: #225
Issue: #226
Issue: #228
Issue: #229
Issue: #230
Issue: #233
Issue: #234
Issue: #238
Issue: #239
User-Visible: yes
2026-08-22 17:18:20 +03:00
Sergey Matyunin a44b664311 test: accept reviewed v1.67.0-beta.1 goldens
Accepted the complete Linux artifact after visual review. The intentional changes are limited to Optimize preflight failure dialogs, opening inner-distance overlays, and the corrected live wall-thickness preview. Renderer-noise images that stayed within their existing thresholds were restored to their prior bytes and hashes.

Issue: #199
Issue: #234
Issue: #238
User-Visible: no
Release: v1.67.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32576969813
2026-08-22 17:12:29 +03:00
Sergey Matyunin 7105e645d9 fix: sync live wall thickness preview
Issue: #234
User-Visible: yes
2026-08-22 16:49:29 +03:00
claude[bot] f82141cd23 docs: review document for #199
Issue: #199
User-Visible: no
2026-08-22 13:44:26 +00:00
Sergey Matyunin 482afb73eb feat: block unsafe Optimize geometry
Issue: #199
User-Visible: yes
2026-08-22 16:29:33 +03:00
claude[bot] add9dfe3a4 docs: review document for #199
Issue: #199
User-Visible: no
2026-08-22 13:12:13 +00:00
Sergey Matyunin b76f89a282 docs: define English Optimize failure copy
Issue: #199
User-Visible: no
2026-08-22 16:08:04 +03:00
claude[bot] ddc141b1be docs: review document for #199
Issue: #199
User-Visible: no
2026-08-22 13:07:27 +00:00
Sergey Matyunin 0ac63b6296 docs: specify Optimize geometry preflight
Issue: #199
User-Visible: no
2026-08-22 15:58:53 +03:00
claude[bot] c397b78e87 docs: review document for #224
Issue: #224
User-Visible: no
2026-08-22 12:48:41 +00:00
Sergey Matyunin 4dbdb446f8 fix: address coordinate review findings
Issue: #224
User-Visible: no
2026-08-22 15:32:55 +03:00
claude[bot] 51fde854e3 docs: review document for #224
Issue: #224
User-Visible: no
2026-08-22 12:24:25 +00:00
Sergey Matyunin 4a798e3e13 fix: canonicalize persisted geometry
Issue: #224
User-Visible: yes
2026-08-22 14:47:38 +03:00
claude[bot] 8442538b6b docs: review document for #224
Issue: #224
User-Visible: no
2026-08-22 11:33:47 +00:00
Sergey Matyunin d5725c2fab docs: specify coordinate storage invariant
Issue: #224
User-Visible: no
2026-08-22 14:28:22 +03:00
claude[bot] 052e19715e docs: review document for #239
Validate / docs (push) Successful in 30s
Validate / reuse (push) Successful in 36s
Validate / provenance (push) Successful in 1m13s
Validate / changes (push) Successful in 1m12s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / backend (push) Skipped
Validate / process-gate (push) Failing after 1m16s
Validate / frontend (push) Successful in 7m3s
Validate / golden (push) Failing after 11m17s
Validate / performance_smoke (push) Failing after 14m7s
Validate / smoke (push) Failing after 40m1s
Issue: #239
User-Visible: no
2026-08-22 11:17:11 +00:00
Sergey Matyunin 30ea73b0aa test: stabilize grid-scale raster evidence
Issue: #239
User-Visible: no
2026-08-22 14:09:10 +03:00
claude[bot] 68cfbdff69 docs: review document for #239
Issue: #239
User-Visible: no
2026-08-22 11:00:12 +00:00
Sergey Matyunin 0d91c1e18e fix: make plan visuals grid-scale invariant
Issue: #239
User-Visible: yes
2026-08-22 13:45:26 +03:00
claude[bot] a4e8dd7ba7 docs: review document for #239
Issue: #239
User-Visible: no
2026-08-22 10:03:10 +00:00
Sergey Matyunin e8b3c861b2 docs(spec): add proof matrix for grid scale
Issue: #239
User-Visible: no
2026-08-22 12:59:18 +03:00
claude[bot] da597da274 docs: review document for #239
Issue: #239
User-Visible: no
2026-08-22 09:58:21 +00:00
Sergey Matyunin 4c065f5ec4 docs(spec): define grid-scale visual invariance
Issue: #239
User-Visible: no
2026-08-22 12:49:46 +03:00
claude[bot] 1af0cc1711 docs: review document for #238
Validate / docs (push) Failing after 26s
Validate / provenance (push) Successful in 1m18s
Validate / process-gate (push) Failing after 1m9s
Validate / changes (push) Successful in 1m0s
Validate / reuse (push) Successful in 43s
Validate / hacs (push) Failing after 21s
Validate / hassfest (push) Failing after 18s
Validate / frontend (push) Successful in 7m27s
Validate / backend (push) Failing after 10m17s
Validate / golden (push) Failing after 12m29s
Validate / performance_smoke (push) Failing after 13m26s
Validate / smoke (push) Failing after 40m7s
Issue: #238
User-Visible: no
2026-08-22 09:40:27 +00:00
Sergey Matyunin 18cd0c8b01 docs: refresh rebased screenshot provenance
Issue: #238
User-Visible: no
2026-08-22 12:25:40 +03:00
claude[bot]andSergey Matyunin ace6e62f89 docs: review document for #238
Issue: #238
User-Visible: no
2026-08-22 12:25:05 +03:00
Sergey Matyunin 2652bb360d fix: sync #238 bundle and lock shared order
Issue: #238
User-Visible: no
2026-08-22 12:25:05 +03:00
claude[bot]andSergey Matyunin 7c6e631560 docs: review document for #238
Issue: #238
User-Visible: no
2026-08-22 12:25:05 +03:00
Sergey Matyunin cb19b7d52e feat: measure opening preview to inner faces
Issue: #238
User-Visible: yes
2026-08-22 12:24:49 +03:00
claude[bot]andSergey Matyunin 06c7934667 docs: review document for #238
Issue: #238
User-Visible: no
2026-08-22 12:24:49 +03:00
Sergey Matyunin 5582e9a35d docs(spec): define opening inner-distance guides
Issue: #238
User-Visible: no
2026-08-22 12:24:49 +03:00
Matysh 636d0a56fa ci: name check-docs in the review gate set
Issue: #237
User-Visible: no
2026-08-22 11:43:55 +03:00
Matysh 9e71334cb4 docs: refresh documentation screenshots and require check-docs
Issue: #237
User-Visible: no
2026-08-22 11:42:55 +03:00
Matysh 142f7ec121 test: grow the draft chain by clicks, as the product does
Issue: #234
User-Visible: no
2026-08-22 11:40:12 +03:00
Matysh 1391aeacfe test: follow the room-delete dialog instead of window.confirm
Issue: #228
User-Visible: no
2026-08-22 11:40:11 +03:00
Matysh 3aba493b25 fix: compile test-build inside the mutant worktree
Issue: #235
User-Visible: no
2026-08-22 11:24:37 +03:00
claude[bot] 6bd197ffd7 docs: review document for #228
Issue: #228
User-Visible: no
2026-08-22 08:14:31 +00:00
claude[bot]andSergey Matyunin 55d5a560ad docs: review document for #228
Issue: #228
User-Visible: no
2026-08-22 10:56:38 +03:00
Sergey Matyunin 691cea074b fix: close plan repair review findings
Issue: #228
User-Visible: yes
2026-08-22 10:56:38 +03:00
claude[bot]andSergey Matyunin 8fc3ec783b docs: review document for #228
Issue: #228
User-Visible: no
2026-08-22 10:56:23 +03:00
Sergey Matyunin 2f968996b1 fix: make plan drawing fail closed
Issue: #228
User-Visible: yes
2026-08-22 10:56:22 +03:00
claude[bot]andSergey Matyunin 9a131cca25 docs: review document for #228
Issue: #228
User-Visible: no
2026-08-22 10:55:08 +03:00
Sergey Matyunin e44b3c97b0 docs: specify plan drawing repairs
Issue: #228
User-Visible: no
2026-08-22 10:55:08 +03:00
claude[bot] 66ffd6fdda docs: review document for #233
Validate / docs (push) Failing after 23s
Validate / provenance (push) Successful in 1m15s
Validate / process-gate (push) Failing after 1m14s
Validate / changes (push) Successful in 1m0s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / reuse (push) Successful in 47s
Validate / backend (push) Skipped
Validate / frontend (push) Successful in 8m16s
Validate / golden (push) Failing after 13m4s
Validate / performance_smoke (push) Failing after 13m7s
Validate / smoke (push) Failing after 34m13s
Issue: #233
User-Visible: no
2026-08-22 07:22:40 +00:00
Matysh 81f38689bb test: prove the atomic profile on a genuinely split edge (#233 r1 M1,M2)
Issue: #233
User-Visible: no
2026-08-22 10:13:50 +03:00
claude[bot] bff47f522b docs: review document for #233
Issue: #233
User-Visible: no
2026-08-22 06:57:57 +00:00
Matysh abfaae3e38 feat: measure resize labels between wall faces
Issue: #233
User-Visible: yes
2026-08-22 09:45:15 +03:00
claude[bot] 969847a4c4 docs: review document for #233
Issue: #233
User-Visible: no
2026-08-22 00:26:11 +00:00
Matysh bc8c368db9 docs(spec): keep a passage full length, read thickness atomically
Spec review r1 returned two blocking findings and both were right.

A measured side that is itself a passage would have been shortened by its
neighbouring walls, while insetContour — the very function the area label
already uses — treats that joint as a flat cap and shortens nothing. Length
and area would have diverged again, at a different boundary, which is the
defect this task exists to remove. The zero rule now comes first and returns
the full centreline length for an open side.

The thickness source was wrong as a matter of fact, not of taste: an
existing test shows thicknessCmAt returns 0 for a whole-edge query against a
partially set thickness, so a split-thickness edge would have silently
stopped shortening. Half-depths now come from roomWallProfile, the atomic
profile that innerContourForRoom already uses for the area, so one edge is
resolved by one mechanism.

Two acceptance criteria and two mutation guards added for the closed
findings.

Issue: #233
User-Visible: no
2026-08-22 03:20:06 +03:00
claude[bot] 0312104527 docs: review document for #233
Issue: #233
User-Visible: no
2026-08-22 00:13:35 +00:00
Matysh 77fa698a27 docs(spec): register the #233 spec in the index
Issue: #233
User-Visible: no
2026-08-22 03:00:05 +03:00
Matysh 7b0747888c docs(spec): measure resize labels between wall faces
Issue: #233
User-Visible: no
2026-08-22 02:58:37 +03:00
Matysh dc9ced2fb4 docs: extend the freshness contract to smokes
Validate / docs (push) Failing after 24s
Validate / reuse (push) Successful in 48s
Validate / process-gate (push) Failing after 1m22s
Validate / provenance (push) Successful in 1m22s
Validate / changes (push) Successful in 1m18s
Validate / hacs (push) Failing after 16s
Validate / hassfest (push) Failing after 23s
Validate / frontend (push) Successful in 4m39s
Validate / backend (push) Failing after 7m35s
Validate / golden (push) Failing after 10m17s
Validate / performance_smoke (push) Failing after 11m19s
Validate / smoke (push) Failing after 23m53s
The contract named benchmark and golden tooling, which is exactly why the smoke
launcher was allowed to skip the check for so long. It now covers every browser
check, names where each one gets it, and records why a stale bundle is worse
than a plain failure: part of the assertions go red and part stay green.

Issue: #236
User-Visible: no
2026-08-22 02:05:35 +03:00
Matysh 61c874953a fix: verify demo bundle freshness for smokes too
Golden runs, benchmarks and documentation captures each called
assertFreshDemoBundle; the smoke launcher never did, so all ~128 smokes could
silently test a stale demo/srv/assets bundle. On #234 that cost a round of
analysis: three assertions went red and a fourth went green, because the old
code was wrong in two places that agreed with each other, and a mixed result
reads as a logic defect rather than a stale artefact.

launch() now runs the check once for every smoke, against the repository root
rather than the serving root — demo/srv has no src/** to fingerprint.
HP_ALLOW_STALE_BUNDLE=1 skips it for debugging and warns out loud, because a
guard that says nothing when it steps aside is the silent success this project
keeps removing. A mutation entry proves the call cannot quietly disappear.

Issue: #236
User-Visible: no
2026-08-22 02:03:36 +03:00
Matysh 09b5d394a5 fix: verify demo bundle freshness for smokes too
Golden runs, benchmarks and documentation captures each called
assertFreshDemoBundle; the smoke launcher never did, so all ~128 smokes could
silently test a stale demo/srv/assets bundle. On #234 that cost a round of
analysis: three assertions went red and a fourth went green, because the old
code was wrong in two places that agreed with each other, and a mixed result
reads as a logic defect rather than a stale artefact.

launch() now runs the check once for every smoke, against the repository root
rather than the serving root — demo/srv has no src/** to fingerprint.
HP_ALLOW_STALE_BUNDLE=1 skips it for debugging and warns out loud, because a
guard that says nothing when it steps aside is the silent success this project
keeps removing. A mutation entry proves the call cannot quietly disappear.

Issue: #236
User-Visible: no
2026-08-22 01:53:24 +03:00
Matysh e4b4f33c6d fix: verify demo bundle freshness for smokes too
Golden runs, benchmarks and documentation captures each called
assertFreshDemoBundle; the smoke launcher never did, so all ~128 smokes could
silently test a stale demo/srv/assets bundle. On #234 that cost a round of
analysis: three assertions went red and a fourth went green, because the old
code was wrong in two places that agreed with each other, and a mixed result
reads as a logic defect rather than a stale artefact.

launch() now runs the check once for every smoke, against the repository root
rather than the serving root — demo/srv has no src/** to fingerprint.
HP_ALLOW_STALE_BUNDLE=1 skips it for debugging and warns out loud, because a
guard that says nothing when it steps aside is the silent success this project
keeps removing. A mutation entry proves the call cannot quietly disappear.

Issue: #236
User-Visible: no
2026-08-22 01:47:47 +03:00
Matysh 5fd9716906 fix: verify demo bundle freshness for smokes too
Golden runs, benchmarks and documentation captures each called
assertFreshDemoBundle; the smoke launcher never did, so all ~128 smokes could
silently test a stale demo/srv/assets bundle. On #234 that cost a round of
analysis: three assertions went red and a fourth went green, because the old
code was wrong in two places that agreed with each other, and a mixed result
reads as a logic defect rather than a stale artefact.

launch() now runs the check once for every smoke, against the repository root
rather than the serving root — demo/srv has no src/** to fingerprint.
HP_ALLOW_STALE_BUNDLE=1 skips it for debugging and warns out loud, because a
guard that says nothing when it steps aside is the silent success this project
keeps removing. A mutation entry proves the call cannot quietly disappear.

Issue: #236
User-Visible: no
2026-08-22 01:46:39 +03:00
Matysh 1fb3a5754e fix: verify demo bundle freshness for smokes too
Golden runs, benchmarks and documentation captures each called
assertFreshDemoBundle; the smoke launcher never did, so all ~128 smokes could
silently test a stale demo/srv/assets bundle. On #234 that cost a round of
analysis: three assertions went red and a fourth went green, because the old
code was wrong in two places that agreed with each other, and a mixed result
reads as a logic defect rather than a stale artefact.

launch() now runs the check once for every smoke, against the repository root
rather than the serving root — demo/srv has no src/** to fingerprint.
HP_ALLOW_STALE_BUNDLE=1 skips it for debugging and warns out loud, because a
guard that says nothing when it steps aside is the silent success this project
keeps removing. A mutation entry proves the call cannot quietly disappear.

Issue: #236
User-Visible: no
2026-08-22 01:45:56 +03:00
claude[bot] 0df5db8b1c docs: review document for #234
Validate / docs (push) Failing after 20s
Validate / reuse (push) Successful in 55s
Validate / changes (push) Successful in 1m13s
Validate / provenance (push) Failing after 1m21s
Validate / process-gate (push) Failing after 1m22s
Validate / hacs (push) Failing after 15s
Validate / hassfest (push) Failing after 11s
Validate / frontend (push) Successful in 3m23s
Validate / backend (push) Failing after 4m25s
Validate / golden (push) Failing after 9m54s
Validate / performance_smoke (push) Failing after 11m9s
Validate / smoke (push) Failing after 20m20s
Issue: #234
User-Visible: no
2026-08-21 16:56:20 +00:00
Sergey Matyunin c8e9597228 fix: resolve chain segment thickness in one place
Issue: #234
User-Visible: yes
2026-08-21 19:42:42 +03:00
claude[bot]andSergey Matyunin 67505f8343 docs: review document for #234
Issue: #234
User-Visible: no
2026-08-21 19:41:37 +03:00
MatyshandSergey Matyunin b2024c6626 docs(spec): state that i18n is untouched, name the zero case
The spec review found the i18n section missing outright — the analysis
comment claimed it was untouched, the document itself said nothing, and a
DoR section cannot be inferred from a comment. It now says so explicitly,
and adds that the absence of i18n files from the diff is part of the
contract rather than an accident.

The waived Low is closed too: the old wallChainSegments treated a recorded
zero as valid while the new contract requires strictly positive values, so
zero is now named in the AC1 examples instead of being derivable from the
prose.

Issue: #234
User-Visible: no
2026-08-21 19:41:37 +03:00
claude[bot]andSergey Matyunin b7a9cc4edf docs: review document for #234
Issue: #234
User-Visible: no
2026-08-21 19:41:37 +03:00
MatyshandSergey Matyunin f4098fcf09 docs(spec): register the #234 spec in the index
Issue: #234
User-Visible: no
2026-08-21 19:41:37 +03:00
MatyshandSergey Matyunin 05b2c74442 docs(spec): define one thickness resolver for a wall chain
Issue: #234
User-Visible: no
2026-08-21 19:41:37 +03:00
claude[bot] 1e2eba95b1 docs: review document for #230
Issue: #230
User-Visible: no
2026-08-21 14:07:33 +00:00
Codex 6836561340 fix: the four unit mutants of #230 have to build test-build first (#230 r1 H2)
Гейт мутаций работает в изолированном `git worktree`, где `test-build/` не
существует: юнит-гвард, идущий сразу в `node --test`, падает с
`ERR_MODULE_NOT_FOUND` ещё на чистом прогоне — то есть не проверяет ничего, а
еженедельный прогон реестра останавливается на первом же таком мутанте и не
доходит до остальных. Соседние юнит-мутанты этого не допускают: их гвард
начинается с `npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs`.
Четыре мутанта #230 теперь тоже.

Проверено штатным харнессом, а не вручную: `node scripts/mutation-gate.mjs
--id=<каждый>` — «поймано 1 из 1» для всех семи мутантов задачи, включая три
смоковых, которые и раньше работали.

Тем же дефектом страдают юнит-мутанты #220 и #229 — это чужой скоуп и предмет
[#235](https://github.com/Matysh/houseplan-card/issues/235); здесь не трогаю.

Issue: #230
User-Visible: no
2026-08-21 16:49:33 +03:00
CodexandCodex 6811cc0908 test: accept reviewed #230 hatch goldens
Две сцены и только они: `large-house-zoom-040-dark` (шаг штриховки был 20
юнитов, стал 8) и `large-house-zoom-250-dark` (был 3.2, стал 8). Расхождение
осмотрено покадрово: меняется только плотность штриховки тел стен, колонн и
перегородок — геометрия, цвета, устройства и свет идентичны. Это прямое
следствие решения владельца §4.2 ТЗ, ради которого зумовая компенсация и
убиралась.

Принято `npm run golden:accept -- --reviewed`. `accept` переснимает весь набор,
поэтому пять сцен, разошедшихся на шуме рендера
(`isometric-large-warm-remount-dark`, `room-label-parity-plan-dark`,
`tray-medium-group-en`, `wall-junctions-plan-preview-light`,
`wall-junctions-plan-t-dark`), возвращены к прежним байтам вместе с их хэшами
в индексе: `verify` считал их совпадающими в пределах допуска, и принимать их
задача #230 права не имеет.

Baseline-Reviewed — прогон, где job `golden` прошёл на Linux ровно на этих
эталонах.

Issue: #230
User-Visible: no
Release: v1.67.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32480753934
2026-08-21 16:42:32 +03:00
CodexandCodex d3468c1fde revert: take the golden baselines back out of the feature commit (#230)
`edf1cca` принял два эталона внутри продуктового коммита, а по правилу
процесса коммит, трогающий `demo/golden/baselines/**`, обязан нести `Release:`
и `Baseline-Reviewed:`. Локально это не остановилось: в моём клоне не был
выставлен `core.hooksPath`, и `commit-msg` попросту не запускался — теперь хуки
установлены, проверено повторным прогоном скрипта вручную.

История не переписывается (AGENTS.md: «never rewrite published history to
satisfy trailers»): эталоны снимаются этим коммитом и возвращаются следующим,
уже с положенными трейлерами.

Issue: #230
User-Visible: no
Release: v1.67.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32480753934
2026-08-21 16:42:32 +03:00
claude[bot] 579d6e5f4e docs: review document for #230
Issue: #230
User-Visible: no
2026-08-21 12:33:54 +00:00
CodexandCodex edf1cca8e3 feat: hatch density is a distance, not a count of units (#230)
Одна и та же стена 15 см выглядела на планах с разным `cell_cm` по-разному:
шаг паттерна был константой в юнитах, а толщина стены переводится в юниты через
`cell_cm`, — значит число полос было пропорционально `1/cell_cm`. Разброс между
крайними масштабами достигал 25 раз, а при `cell_cm ≥ 10` в стену не попадало
и одной полосы: штриховка вырождалась в случайные штрихи или исчезала.

Шаг стал физической величиной: `wallHatchStepUnits(cellCm)` возвращает
`8 × (5 / cell_cm)` — это 9.6 см плана при любом масштабе сетки и ровно
исторические 8 юнитов при эталонном `cell_cm: 5`, так что старые планы не
двигаются. Толщина штриха следует за шагом, поэтому соотношение «штрих к
просвету» тоже перестало зависеть от масштаба.

Формула из описания issue (`cell_cm / 5`) не годилась: она увеличивает шаг там,
где стена и без того тонкая в юнитах, и разброс не исчезает, а растёт — 39
полос против 0.06 на краях диапазона. Множитель обратный; на эту ошибку
поставлен отдельный мутант `hatch-step-inverted`.

Зумовая компенсация `1/zoom` убрана по решению владельца (§4.2 ТЗ): стена,
которая меняет штриховку при зуме, — это тот же дефект, только по другой оси.
От каши на дальнем конце зума защищает второй порог `wallHatchNeedsSolid`
рядом с существующим `wallBodyNeedsSolid`; шаг клампится в [0.5, 80] юнитов,
чтобы патологический `cell_cm` не выродил паттерн.

Статический рендерер (`space-render.ts`) нёс собственную константу 8 и вообще
не знал про зум — то есть уже сегодня расходился с картой при любом зуме,
кроме единицы. Теперь оба читают шаг из одной функции; смок проверяет, что они
согласны между собой, а не только каждый сам с собой.

Два golden-эталона переснято осознанно (`golden:accept -- --reviewed`):
`large-house-zoom-040-dark` (шаг был 20 юнитов, стал 8) и
`large-house-zoom-250-dark` (был 3.2, стал 8). Расхождение осмотрено: меняется
только плотность штриховки тел стен, колонн и перегородок, геометрия и цвета
идентичны. Остальные 80 сцен не тронуты — `accept` переснимает весь набор, и
пять сцен, разошедшихся на шуме рендера, возвращены к прежним байтам вместе с
их хэшами в индексе.

Issue: #230
User-Visible: yes
2026-08-21 15:12:08 +03:00
claude[bot] 115a7d4ccd docs: review document for #230
Issue: #230
User-Visible: no
2026-08-21 11:54:24 +00:00
CodexandCodex 44a35fde0f docs: two golden scenes DO move, and the static renderer needs its own AC (#230 r1)
Issue: #230
User-Visible: no
2026-08-21 14:47:49 +03:00
claude[bot] 6872dc420d docs: review document for #230
Issue: #230
User-Visible: no
2026-08-21 11:46:42 +00:00
Codex 2396044bae docs: spec for #230 — hatch density normalised to plan centimetres
Issue: #230
User-Visible: no
2026-08-21 14:35:29 +03:00
claude[bot] 619516806f docs: review document for #229
Validate / docs (push) Failing after 24s
Validate / changes (push) Successful in 1m10s
Validate / process-gate (push) Failing after 1m14s
Validate / provenance (push) Successful in 1m16s
Validate / reuse (push) Successful in 52s
Validate / hassfest (push) Failing after 33s
Validate / hacs (push) Failing after 47s
Validate / frontend (push) Successful in 7m12s
Validate / backend (push) Failing after 16m13s
Validate / golden (push) Failing after 11m40s
Validate / performance_smoke (push) Failing after 14m46s
Validate / smoke (push) Failing after 32m43s
Issue: #229
User-Visible: no
2026-08-21 10:31:02 +00:00
CodexandCodex fab0c38ca9 refactor: one function owns the junction geometry of a space (#229 r2 M1)
Дефект High-1 был одинаковым в двух местах — и в живом рисовании, и в
«Оптимизировать планы», — потому что каждый вызывающий собирал геометрию
примыканий сам. Ревью r2 справедливо заметило, что и защита получилась
однобокой: юнит и мутант сторожили только оптимизатор, а путь карты — тот, где
дефект и был виден пользователю, — не сторожил никто. Заплатка в виде второго
мутанта-близнеца оставила бы причину на месте: два списка координат, которые
обязаны совпадать, но ничем не связаны.

Поэтому геометрия переехала в `spaceMergeGeometry(space, { excludeDraftId })`:
один источник комнат, колонн и концов черновиков, одни координаты, одно место,
где можно ошибиться. Оба вызывающих теперь строчка вызова.

Покрытие идёт за причиной, а не за симптомом: три юнита в
`test/wall-merge.test.mjs` проверяют масштаб полигонов (включая комнаты в форме
x/y/w/h и комнату без геометрии), исключение активного черновика и сам T-стык к
середине стороны комнаты. Мутанты `partition-merge-rescales-rooms` и
`chain-merge-sees-own-draft` перенацелены на общий модуль и теперь краснеют для
обоих путей сразу: 2 и 1 падение, проверено применением патча.

Сценарий с комнатой в смоке пробовал — не взлетел: рисование в комнату
поднимает `_offerWallFaces`, и цепочка не завершается штатно. Ломать смок под
тест не стал, юниты общего модуля покрывают оба пути честнее.

Issue: #229
User-Visible: no
2026-08-21 13:25:16 +03:00
claude[bot] fe2fcef735 docs: review document for #229
Issue: #229
User-Visible: no
2026-08-21 10:20:16 +00:00
CodexandCodex ed13b4a5ca fix: a room side and a live draft, seen in the right coordinates (#229 r1 H1,H2)
**High-1.** Комнаты хранятся в тех же координатах, что и перегородки:
`roomPoly` отдаёт сырой полигон конфига. Обе обвязки делили его на `NORM_W`
ещё раз, комната уезжала в область ~0.0001, и `junctionAt` не находил ни
одного совпадения. Узел на T-стыке к середине стены комнаты — тот самый
случай, ради которого ТЗ прошло два раунда ревью, — молча исчезал.
Воспроизведено вызовом `optimizePlans`: `partitionsMerged === 1` там, где
ожидается 0.

**High-2.** Завершаемая цепочка к моменту слияния ещё лежит в `room_drafts`:
каждый клик персистит её через `_persistActiveDraftSegment`, а удаляется
черновик строкой ниже вызова слияния. Собственные концы цепочки считались
чужим примыканием, и стык с существующей стеной не срастался. Активный
черновик теперь исключается — ровно так же, как это делает
`plan-snap-overlay` (`activeDraftId`).

Дыры в тестах, которые это пропустили, закрыты по существу, а не заплаткой:

- `demo/smoke_wall_chain_merge.mjs` рисует продолжение реальными кликами через
  `_markupClick`, а не присваиванием `_path`, — то есть исполняет тот путь, на
  котором дефект и жил. Клики задаются в координатах плана и переводятся через
  живой view box, иначе смок целится мимо только что нарисованной стены.
- `test/plan-optimizer.test.mjs` получил комнату с примыканием к середине
  стороны: юниты модуля этого не ловили, потому что передают полигон уже в
  согласованном масштабе, минуя обвязку.
- Мутанты `partition-merge-rescales-rooms` и `chain-merge-sees-own-draft`
  сторожат оба места: проверены применением патча, 1 и 2 падения.

Issue: #229
User-Visible: no
2026-08-21 13:04:20 +03:00
claude[bot] 10d5b5a288 docs: review document for #229
Issue: #229
User-Visible: no
2026-08-21 09:57:36 +00:00
Codex 50a00e41a1 docs: refresh documentation screenshots (#229)
`scripts/check-docs.mjs` держит скриншоты в соответствии с исходниками через
отпечаток `src/**`. Отпечаток был просрочен ещё до этой задачи — проверено
исполнением на чистом dev, — так что перезахват закрывает чужой долг заодно с
изменением, которое отпечаток всё равно бы сдвинуло. Содержательно кадры те
же: меняется только шум рендера.

Issue: #229
User-Visible: no
2026-08-21 12:40:41 +03:00
Codex e6be43b90f feat: a straight wall is one record, not a row of seams (#229)
Рисование прямой стены в несколько кликов оставляло по записи на каждый
отрезок. Швы невидимы, пока их не тронешь: выделение хватает кусок,
перетаскивание ломает стену пополам, толщина задаётся пофрагментно. У стен
комнат этого давно нет — `normalizeWallIntervals` схлопывает каждый сплошной
участок одной толщины. Независимые перегородки жили по другому правилу.

Новый чистый модуль `src/wall-merge.ts` даёт им то же правило:

- `mergeCollinearPartitions` сращивает соседей одинаковой толщины и
  направления до неподвижной точки, но только там, где узел никому не нужен.
  Узел остаётся, если в него приходит третья перегородка, стена комнаты
  (стороной, а не только вершиной), колонна или конец сохранённого черновика.
- Направление выжившей записи канонизируется лексикографически: иначе одна и
  та же физическая стена выходила то a→b, то b→a в зависимости от порядка
  входа, и каждый host.t вдоль неё переворачивался вместе с ней.
- `applyOpeningMoves` переносит проёмы на выжившую запись: и авторитетный
  `host`, и legacy-проекцию `x/y/angle`, которую рисует старый читатель
  конфига (docs/CONFIG-COMPATIBILITY.md, #132). Проекция здесь не кэш —
  канонизация направления разворачивает угол на 180°.

Рисование сращивает только свою цепочку и то, чего она коснулась (§8.6 ТЗ):
молча править чужие швы в стороне оно не вправе — для этого есть
«Оптимизировать планы» с предпросмотром, отчётом и отменой. Оптимизация
проходит по всему пространству без seed-ограничения и отдельной строкой
сообщает, сколько записей исчезло.

Issue: #229
User-Visible: yes
2026-08-21 12:40:34 +03:00
claude[bot] 483c29cc39 docs: review document for #229
Issue: #229
User-Visible: no
2026-08-21 09:01:53 +00:00
Codex 1ecd267138 docs: a room junction is a side, not just a corner (#229 r2 M1)
The tolerance fix in r2 named the room's nearest polygon vertex as the point
of contact, which silently excluded the T-junction — a partition meeting the
middle of a room wall. That is a documented product case (141-wall-junctions
§13.1) and the code already measures distance to the edge, not the vertex
(distToSegment over roomEdges). Merging would have run straight through a
legitimate node.

AC2 now proves the room case with a T-junction into the middle of a long
side, and a mutant restores the vertex-only search.

Issue: #229
User-Visible: no
2026-08-21 11:55:51 +03:00
claude[bot] 5c560df1a2 docs: review document for #229
Issue: #229
User-Visible: no
2026-08-21 08:54:11 +00:00
Codex ecc3d6a88b docs: close M1, M2 and M3 from the spec review of #229
M1: "merge across the whole space when a chain ends" quietly overreached the
owner's split — drawing fixes its own seam, the optimiser fixes what has piled
up, and only the latter comes with a report and an undo. Section 8.6 now scopes
it to the connected component the new chain belongs to.

M2: the tolerance for "something else meets here" was only defined for a
partition-to-partition joint. Room edges, columns and drafts now use the same
EPS_JOIN — a gap cannot be a junction in one case and not in another.

M3: an opening also carries a materialised x/y/angle projection that
CONFIG-COMPATIBILITY (#132) requires to stay in step with its host, and the
code already re-materialises it after every host change. Merging is such a
change; AC3 now fails if only host.t is recomputed and the projection goes
stale.

Issue: #229
User-Visible: no
2026-08-21 11:46:36 +03:00
claude[bot] 5b8b6899da docs: review document for #229
Issue: #229
User-Visible: no
2026-08-21 08:43:01 +00:00
Codex 81210f7afc docs: spec for merging collinear wall segments
Written on the owner's decisions of 2026-08-21: merge as the chain is
finished, sweep already-drawn plans from "Optimise plans", and keep merging
even when an opening sits on the seam.

The part that is easy to miss is that last one. An opening stores its
position as a fraction of its host's length, so merging two partitions
changes the length under it and moves the door unless the fraction is
recomputed. AC3 therefore checks the door's coordinates in plan units, not
that a field was rewritten.

Issue: #229
User-Visible: no
2026-08-21 11:32:07 +03:00
Sergey Matyunin fd43a25746 Merge remote-tracking branch 'origin/issue/220-space-tab-reorder' into dev 2026-08-21 11:03:51 +03:00
Matysh 33470306d2 fix: keep the review document outside the tree the reviewer mutates
Three code-review rounds on #220 published a verdict and then failed the
run: the document never reached the branch, so the #171 guard refused
before the label step and neither the merge nor S8-merged happened. The
cause was structural. The document lived as an untracked file inside the
very checkout the reviewer edits while proving that a test can fail, and
restoring that tree — git checkout, git clean — deletes an untracked file.
Spec rounds survived only because they never mutate anything.

The reviewer now writes to REVIEW_DOC under RUNNER_TEMP, outside the
repository, and the publish step copies it into docs/reviews before
committing. Tree cleanup can no longer destroy the artefact, and the
reviewer no longer needs to touch docs/reviews at all.

Verified against a local git fixture on five paths: document outside the
repo with a mutated tree, nothing anywhere (loud failure), document only in
the working copy, document already committed by the reviewer, and a branch
that moved during the review.

Same file as main, byte for byte.

Issue: #220
User-Visible: no
2026-08-21 10:53:07 +03:00
Codex 82bb03cdf9 fix: end a tab drag when the card is disconnected
Validate / docs (push) Failing after 31s
Validate / provenance (push) Successful in 1m7s
Validate / process-gate (push) Failing after 1m11s
Validate / changes (push) Successful in 57s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / reuse (push) Successful in 1m30s
Validate / backend (push) Skipped
Validate / frontend (push) Successful in 12m25s
Validate / golden (push) Failing after 12m39s
Validate / performance_smoke (push) Failing after 11m36s
Validate / smoke (push) Failing after 27m40s
Review CODE-REVIEW-220-r2/r3, F1.

The M1 fix installs window listeners for the length of the gesture, and
disconnectedCallback — which takes down everything else, down to the other
local gesture — did not take those down. Losing the card mid-drag (Lovelace
rebuilding its tree, the user leaving the view with the button still down)
left them alive: the closure holds the instance and its config, and the next
pointerup anywhere on the page would have an invisible card write its order.

The smoke now holds a tab, removes the card, and checks that the release it
should no longer hear changes nothing. Registered as a mutant too.

Issue: #220
User-Visible: no
2026-08-21 08:02:03 +03:00
Codex a8aeecc32c fix: judge order dependence by the area in force, close the stuck drag
Review CODE-REVIEW-220-r1.

H1: markersNeedingPlacement decided who depends on the order by reading
marker.area alone, while resolveExplicitMarkerPlacement reads
`marker.area || <area of the HA device>`. The ordinary marker — bind an HA
device, store neither field — is anchored by the registry and never depended
on the order, yet it was being written a space it never asked for. Dormant
today, and the day that HA area changes it moves the marker to whatever space
used to be first. The resolver now asks for the area actually in force.

M1: a mouse released past the panel left the gesture stuck, swallowing the
next click. Pointer capture is the usual answer and is now taken, but it is
not a guarantee — the browser grants it only for a live pointer. The window
listener is what actually closes the gesture.

M2: the fifth mutant from the spec is registered, plus a sixth for the stuck
drag above.

Writing the smoke for M1 turned up why the first attempt passed against
broken code: synthetic PointerEvents default to composed:false and never
leave the shadow root, so nothing outside the panel could ever hear them.
Real pointer events are composed; the smoke now says so.

Issue: #220
User-Visible: no
2026-08-21 00:50:15 +03:00
claude[bot] 90a00670a7 docs: review document for #220
Issue: #220
User-Visible: no
2026-08-20 21:41:11 +00:00
Codex 8369c0e839 feat: reorder space tabs by dragging one to a new position
The order of config.spaces used to be whatever order the spaces were created
in, and there was no way back other than deleting a space and drawing it
again.

The gesture is deliberately narrow — mouse, editors only. The same tabs are
the primary way to switch spaces in View, where touch is first class, so a
drag there would compete with the tap that switches. Recorded in the spec as
"Touch editor: not exposed".

The part that needed care is not the drag. Position in the array feeds three
things: the marker placement fallback, the swipe neighbour and a positional
`floor`. So the write that stores the new order also writes down the
placement that used to depend on it: a marker with neither an explicit space
nor an area that names one gets the space it has right now. Both changes go in
one save; splitting them would leave a window in which markers move on their
own. The positional `floor` cannot be fixed from here, so the card says so
once.

Issue: #220
User-Visible: yes
2026-08-21 00:27:35 +03:00
Matysh 07f1b8c674 docs: record that a green verdict spends no review budget
Validate / docs (push) Failing after 27s
Validate / provenance (push) Successful in 54s
Validate / process-gate (push) Failing after 44s
Validate / changes (push) Successful in 39s
Validate / reuse (push) Successful in 45s
Validate / hacs (push) Failing after 16s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 6m37s
Validate / backend (push) Failing after 9m6s
Validate / performance_smoke (push) Failing after 18m53s
Validate / smoke (push) Failing after 39m24s
Validate / golden (push) Failing after 11m5s
Section 4 now says what the pipeline does: a cycle is a verdict with
blocking findings followed by a return to the author, so a green verdict
consumes nothing — the case that cost #225 an arbitration after a failed
merge forced a rebase and a third attempt.

The canon also separates the two quantities the verdict line carries. The
attempt number names the review document, because two runs sharing a
number would overwrite each other's artefact; the budget counts blocking
cycles only. That is why the document threshold in the process gate sits
above the cycle limit, and why the guard reports a recount of review-4
rather than removing the label itself.

Issue: #227
User-Visible: no
2026-08-21 00:01:19 +03:00
Matysh afd5d589d5 fix: spend the review budget on blocking verdicts only
The pipeline punished what it prescribed: after a failed merge it tells the
author to rebase and restore S7-code-review, and that attempt finished the
budget. On #225 a green code review with green CI ended in review-4.

Only yellow and red verdicts spend the budget now; a green verdict returned
nothing and consumes nothing. Attempts and cycles became separate
quantities: the attempt number names the review document, the limit
compares blocking cycles. The exhaustion comment lists what it counted, and
the guard reports a recount instead of stripping review-4 on its own.

Same file as main (41325a8), byte for byte.

Issue: #227
User-Visible: no
2026-08-20 23:54:13 +03:00
Matysh 99c6cd09f3 fix: restore the exact process-gate source for #227
Issue: #227
User-Visible: no
2026-08-20 23:49:12 +03:00
Matysh 6176f02430 test: expect the raised review-document threshold
Issue: #227
User-Visible: no
2026-08-20 23:42:29 +03:00
Matysh 01d7554607 test: expect the raised review-document threshold
Issue: #227
User-Visible: no
2026-08-20 23:37:57 +03:00
claude[bot] 30bf18f508 docs: review document for #220
Issue: #220
User-Visible: no
2026-08-20 20:37:09 +00:00
Matysh 9848f4a0cb fix: spend the review budget on blocking verdicts only
The pipeline punished what it prescribed: after a failed merge it tells the
author to rebase and restore S7-code-review, and that attempt finished the
budget. On #225 (light track, limit 2) the sequence yellow, green, rebase
produced review-4 on a task whose code review was green and whose CI was
green, with no product change after the verdict — the owner had to
arbitrate work that was already accepted.

A cycle under section 4 is a verdict with blocking findings followed by a
return to the author, so only yellow and red verdicts spend the budget now.
A green verdict returned nothing and consumes nothing, which also removes
any need to mark rebase re-runs specially.

Attempts and cycles are now separate quantities. The attempt number keeps
naming the document, because two runs sharing a number would overwrite each
other's review artefact, while the limit compares blocking cycles only. The
exhaustion comment lists the verdicts it counted, and the guard no longer
strips review-4 — it reports the recount and leaves the decision with the
owner.

Rule 7 of the process gate follows: its document threshold rises above the
cycle limit, because legitimate attempts can exceed cycles and a threshold
equal to the limit would refuse the very rebase the pipeline demands.

Issue: #227
User-Visible: no
2026-08-20 23:33:08 +03:00
Codex 5ba46dfd97 docs: take the atomic write out of the "free to change" block (#220 M3)
The assumptions section is explicitly labelled "technical, free to change",
and it held a requirement that AC3 and a mutant already test as a fact. Read
literally, it invited splitting the write in two — reopening the very window
in which markers move. The point now states the opposite: everything else in
that section is free, this one is normative and lives in section 8.3.

Issue: #220
User-Visible: no
2026-08-20 23:28:26 +03:00
claude[bot] 48308209c2 docs: review document for #220
Issue: #220
User-Visible: no
2026-08-20 20:27:01 +00:00
Codex e57e1d920c docs: close M1 and M2 from the spec review of #220
M1: the spec now carries the touch classification TOUCH-SUPPORT.md asks every
editor feature for — "Touch editor: not exposed", with the reason it is a
decision rather than an omission.

M2: the first draft denied adding a config field in one section while planning
to store an anchor in settings in another. Resolved by dropping the anchor:
reordering materialises the placement that was implicit, giving those markers
an explicit space in the same write. No new field, no schema change, and the
marker stays exactly where the user saw it.

Issue: #220
User-Visible: no
2026-08-20 23:20:31 +03:00
claude[bot] c3278ddd07 docs: review document for #220
Issue: #220
User-Visible: no
2026-08-20 20:17:57 +00:00
Codex 0fd2331d4a docs: spec for space tab reordering
Written on the owner's product decisions of 2026-08-20: mouse only and only in
the editor modes, one warning about the positional `floor` from #210, no
keyboard alternative.

The spec carries the part that is easy to miss — the order of `config.spaces`
is not decoration. It feeds the marker placement fallback, the swipe
neighbour and the numeric `floor`, so reordering tabs must not move a single
marker. That is a named acceptance criterion with a mutant behind it.

Issue: #220
User-Visible: no
2026-08-20 23:08:07 +03:00
claude[bot] 7763af6b8e docs: review document for #223
Issue: #223
User-Visible: no
2026-08-20 19:32:16 +00:00
Sergey Matyuninandclaude[bot] d486c64576 fix: canonicalize near-grid coordinates exactly
Issue: #223
User-Visible: yes
2026-08-20 19:32:16 +00:00
claude[bot] 603bb3647f docs: review document for #223
Issue: #223
User-Visible: no
2026-08-20 19:32:16 +00:00
Sergey Matyuninandclaude[bot] fb6d7ea693 docs: clarify optimize report terminology
Issue: #223
User-Visible: no
2026-08-20 19:32:15 +00:00
claude[bot] 73eb92257e docs: review document for #223
Issue: #223
User-Visible: no
2026-08-20 19:32:15 +00:00
Sergey Matyuninandclaude[bot] 62f398d359 docs: address coordinate spec review
Issue: #223
User-Visible: no
2026-08-20 19:32:15 +00:00
claude[bot]andclaude[bot] 38e6869c90 docs: review document for #223
Issue: #223
User-Visible: no
2026-08-20 19:32:15 +00:00
Sergey Matyuninandclaude[bot] 86778ed452 docs: specify exact coordinate canonicalization
Issue: #223
User-Visible: no
2026-08-20 19:32:15 +00:00
Codex 812e5ff391 Merge issue #225 into dev
Import of a backup holding PDF attachments: the content resolver parses a url
as a url, and the three mutants guarding it are registered. The user-visible
change is documented in 4a84734, which carries both changelog entries — this
merge adds no behaviour of its own.

Code review r2 green (docs/reviews/CODE-REVIEW-225-r2.md). The third pass was
a rebase over #226, not a fix — owner arbitration on the review-4 the cycle
counter raised for it (PROCESS.md §4; counter defect filed as #227).

Issue: #225
User-Visible: no
2026-08-20 22:14:36 +03:00
claude[bot]andCodex 810ad01ce9 docs: review document for #225
Issue: #225
User-Visible: no
2026-08-20 22:00:22 +03:00
Codex 2935c293e1 fix: reject absolute urls in the content resolver, register the mutants
Review CODE-REVIEW-225-r1.

M1: urlsplit(url).path was trusted even when the url carried a scheme or an
authority, so "https://evil.example/houseplan_files/files/m1/doc.pdf"
resolved onto a local file while _looks_internal kept calling it external —
the mirror image of the inconsistency this resolver exists to prevent. Only a
same-document reference is resolved by its path now.

M2: the three mutants the spec described are registered in
scripts/mutation-gate.mjs instead of living as a one-off manual run. The
traversal entry drops both structural checks at once on purpose: taken one at
a time the defence is layered (sanitize_marker_id turns ".." into "misc") and
the mutant would be equivalent — established by running it.

Issue: #225
User-Visible: no
2026-08-20 22:00:22 +03:00
claude[bot]andCodex cb1e4cea64 docs: review document for #225
Issue: #225
User-Visible: no
2026-08-20 22:00:22 +03:00
Codex 4a84734b18 fix: resolve internal content urls by path, not by string
A backup holding a PDF attachment could not be imported back: legacy links
carry a cache-buster (".../files/m1/doc.pdf?v=1783170649"), and the resolver
compared the raw tail with its sanitized form, so the query made the name
differ from itself. The reference then read as internal by prefix and
non-canonical by name, which is exactly the combination _content_state must
refuse — every such document failed with invalid_content.

Parse the url as a url: the path addresses the file, the query and the
fragment address the transfer. Path segments keep doing the guarding, so
dropping the query cannot widen what a segment is allowed to be.

Issue: #225
User-Visible: yes
2026-08-20 22:00:22 +03:00
claude[bot] a0716dc404 docs: review document for #226
Issue: #226
User-Visible: no
2026-08-20 18:48:01 +00:00
Sergey Matyuninandclaude[bot] 442731e8eb fix: deduplicate entity markers from parent devices
Issue: #226
User-Visible: yes
2026-08-20 18:48:01 +00:00
claude[bot] 723ebb8f61 docs: review document for #226
Issue: #226
User-Visible: no
2026-08-20 18:48:01 +00:00
Sergey Matyuninandclaude[bot] 8e0358e7dd docs: define hidden-only device residual
Issue: #226
User-Visible: no
2026-08-20 18:48:01 +00:00
claude[bot] 0cb38e5562 docs: review document for #226
Issue: #226
User-Visible: no
2026-08-20 18:48:01 +00:00
Sergey Matyuninandclaude[bot] ea50548b70 docs: specify entity parent deduplication
Issue: #226
User-Visible: no
2026-08-20 18:48:01 +00:00
claude[bot] 5f000cb6fb docs: review document for #225
Issue: #225
User-Visible: no
2026-08-20 18:23:36 +00:00
claude[bot] be0277f5f5 docs: review document for #225
Validate / docs (push) Failing after 22s
Validate / reuse (push) Successful in 51s
Validate / changes (push) Successful in 1m10s
Validate / provenance (push) Successful in 1m14s
Validate / process-gate (push) Successful in 1m16s
Validate / hacs (push) Failing after 13s
Validate / hassfest (push) Failing after 24s
Validate / frontend (push) Successful in 8m14s
Validate / backend (push) Failing after 8m22s
Validate / golden (push) Failing after 10m59s
Validate / performance_smoke (push) Failing after 14m6s
Validate / smoke (push) Failing after 33m13s
Issue: #225
User-Visible: no
2026-08-20 18:14:55 +00:00
Sergey Matyunin a20dd54ba6 test: retarget the LQI mutation guard
Validate / docs (push) Failing after 47s
Validate / provenance (push) Successful in 1m41s
Validate / process-gate (push) Failing after 1m52s
Validate / changes (push) Successful in 1m7s
Validate / reuse (push) Successful in 37s
Validate / hacs (push) Failing after 13s
Validate / hassfest (push) Failing after 15s
Validate / frontend (push) Successful in 7m29s
Validate / backend (push) Failing after 9m12s
Validate / golden (push) Failing after 13m36s
Validate / performance_smoke (push) Failing after 14m19s
Validate / smoke (push) Failing after 35m6s
Full Performance / performance (push) Failing after 1h57m49s
Issue: #222
User-Visible: no
2026-08-20 16:26:29 +03:00
Sergey Matyunin 3af04840a9 build: promote v1.66.0 after beta.1
Issue: #221
User-Visible: yes
2026-08-20 16:05:55 +03:00
Sergey Matyunin d68e958c0d test: align lock smokes with issue 219 palette
Issue: #219
User-Visible: no
2026-08-20 15:30:57 +03:00
Sergey Matyunin e0b3c4715d test: accept reviewed v1.66.0-beta.1 goldens
Issue: #217
Issue: #218
Issue: #219
User-Visible: no
Release: v1.66.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32368355958
2026-08-20 15:25:07 +03:00
Sergey Matyunin 4aa9d1b2bb docs: refresh v1.66.0-beta.1 screenshot fingerprint
Issue: #217
Issue: #218
Issue: #219
User-Visible: no
2026-08-20 15:20:37 +03:00
Sergey Matyunin d55e0dd298 Release v1.66.0-beta.1 candidate
Issue: #217
Issue: #218
Issue: #219
User-Visible: yes
2026-08-20 15:18:43 +03:00
claude[bot] ce400859f3 docs: review document for #219
Issue: #219
User-Visible: no
2026-08-20 12:09:51 +00:00
Sergey Matyunin bc75e00c2d fix: unify lock and orange icon colors
Issue: #219
User-Visible: yes
2026-08-20 14:56:21 +03:00
claude[bot] 84fa434a64 docs: review document for #219
Issue: #219
User-Visible: no
2026-08-20 11:48:34 +00:00
Sergey Matyunin 16c8a28aaf docs: require user guide palette update
Issue: #219
User-Visible: no
2026-08-20 14:44:35 +03:00
claude[bot] 6786c48a0e docs: review document for #219
Issue: #219
User-Visible: no
2026-08-20 11:43:39 +00:00
Sergey Matyunin 28d6b9ccd9 docs: specify lock and orange icon palette
Issue: #219
User-Visible: no
2026-08-20 14:38:35 +03:00
claude[bot] b56e122b07 docs: review document for #218
Issue: #218
User-Visible: no
2026-08-20 11:24:46 +00:00
Sergey Matyunin 3d11758e2b fix: keep Glow visible with noisy floor geometry
Stabilize polygon-boolean inputs at render time and isolate residual
room failures without weakening fail-dark clipping.

Issue: #218
User-Visible: yes
2026-08-20 14:05:22 +03:00
claude[bot] 4c512fa59f docs: review document for #218
Issue: #218
User-Visible: no
2026-08-20 10:47:39 +00:00
Sergey Matyunin 72275de567 docs: add risk analysis for glow geometry
Issue: #218
User-Visible: no
2026-08-20 13:43:20 +03:00
claude[bot] f9accc1198 docs: review document for #218
Issue: #218
User-Visible: no
2026-08-20 10:41:59 +00:00
Sergey Matyunin c6ff34cea3 docs: specify resilient glow floor geometry
Issue: #218
User-Visible: no
2026-08-20 13:35:09 +03:00
claude[bot] 9e8393f874 docs: review document for #217
Validate / docs (push) Successful in 26s
Validate / provenance (push) Successful in 52s
Validate / process-gate (push) Failing after 1m1s
Validate / changes (push) Successful in 53s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / reuse (push) Successful in 58s
Validate / backend (push) Skipped
Validate / frontend (push) Successful in 6m44s
Validate / golden (push) Failing after 11m20s
Validate / performance_smoke (push) Failing after 12m44s
Validate / smoke (push) Failing after 31m10s
Issue: #217
User-Visible: no
2026-08-20 10:30:27 +00:00
Sergey Matyunin 39456dc44c fix: restore capsule outline for text markers
Issue: #217
User-Visible: yes
2026-08-20 13:21:40 +03:00
claude[bot] c547586dc9 docs: review document for #217
Issue: #217
User-Visible: no
2026-08-20 10:13:43 +00:00
Sergey Matyunin fe33deaa13 docs: specify text marker shell geometry
Issue: #217
User-Visible: no
2026-08-20 13:07:49 +03:00
Sergey Matyunin 8ba840aa74 Merge main into dev before v1.65.0 promotion
Validate / golden (push) Failing after 11m30s
Validate / docs (push) Failing after 20s
Validate / provenance (push) Successful in 1m31s
Validate / process-gate (push) Failing after 2m46s
Validate / changes (push) Successful in 1m2s
Validate / reuse (push) Successful in 49s
Validate / hacs (push) Failing after 19s
Validate / hassfest (push) Failing after 22s
Validate / frontend (push) Successful in 15m2s
Validate / backend (push) Failing after 14m58s
Validate / smoke (push) Failing after 32m40s
Validate / performance_smoke (push) Failing after 10m27s
Full Performance / performance (push) Failing after 1h46m45s
2026-08-20 12:04:49 +03:00
Sergey Matyunin ea33edebf9 build: promote v1.65.0 after beta.9
Issue: #215
User-Visible: yes
2026-08-20 11:55:46 +03:00
Matysh 2b45086794 docs: scope a repeat review round to the delta
The reviewer prompt was identical for every round, and the canon said
nothing about the scope of a repeat pass, so r2 re-derived the product
framing and re-checked acceptance criteria the fix never touched: the r2
pass on #150 cost a full pipeline run over one line in a test fixture.

From the second cycle on, the subject is the delta against the SHA the
previous verdict was given on: each earlier finding must be shown closed
by a line of code or text, only the criteria the delta can reach are
re-verified, and whatever is carried over is listed with the round and SHA
it came from. Cheap gates still run every round.

The scope shrinks, the strictness does not. A fix can break a criterion an
earlier round accepted — that is how regression #102 happened — so the
boundary is the findings plus everything the delta can reach, and a
non-local delta (a rebase onto a moved dev, a behaviour contract change, a
new subsystem) still gets the full pass.

Issue: #214
User-Visible: no
2026-08-20 11:50:16 +03:00
Sergey Matyunin 75ad20a1df test: accept beta.9 Linux baselines
Issue: #213
User-Visible: no
Release: v1.65.0-beta.9
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32348854996
2026-08-20 11:35:37 +03:00
Matysh cbaa7b0cb9 docs: scope a repeat review round to the delta
The reviewer prompt was identical for every round, and the canon said
nothing about the scope of a repeat pass, so r2 re-derived the product
framing and re-checked acceptance criteria the fix never touched: the r2
pass on #150 cost a full pipeline run over one line in a test fixture.

From the second cycle on, the subject is the delta against the SHA the
previous verdict was given on: each earlier finding must be shown closed
by a line of code or text, only the criteria the delta can reach are
re-verified, and whatever is carried over is listed with the round and SHA
it came from. Cheap gates still run every round.

The scope shrinks, the strictness does not. A fix can break a criterion an
earlier round accepted — that is how regression #102 happened — so the
boundary is the findings plus everything the delta can reach, and a
non-local delta (a rebase onto a moved dev, a behaviour contract change, a
new subsystem) still gets the full pass.

Issue: #214
User-Visible: no
2026-08-20 11:29:45 +03:00
Sergey Matyunin adc1d15b98 Release v1.65.0-beta.9 candidate
Issue: #213
User-Visible: yes
2026-08-20 11:26:44 +03:00
claude[bot] a14de0a122 docs: review document for #213
Issue: #213
User-Visible: no
2026-08-20 08:16:28 +00:00
Sergey Matyunin 0e5ee030fe fix: polish device marker geometry and input
Issue: #213
User-Visible: yes
2026-08-20 11:00:22 +03:00
claude[bot] c749d68a4d docs: review document for #213
Issue: #213
User-Visible: no
2026-08-20 07:25:11 +00:00
Sergey Matyunin 0c93be9e85 docs: specify device marker geometry polish
Issue: #213
User-Visible: no
2026-08-20 10:19:03 +03:00
Sergey Matyunin 9f25f17c83 test: accept beta.8 Linux baselines
Issue: #212
User-Visible: no
Release: v1.65.0-beta.8
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32339899632
2026-08-20 09:42:24 +03:00
Sergey Matyunin 1521d3716a Release v1.65.0-beta.8 candidate
Issue: #212
User-Visible: yes
2026-08-20 09:30:32 +03:00
claude[bot] fbb426afc9 docs: review document for #212
Issue: #212
User-Visible: no
2026-08-20 06:22:45 +00:00
Sergey Matyunin c5ba699cf1 fix: address device icon review findings
Issue: #212
User-Visible: yes
2026-08-20 09:14:27 +03:00
claude[bot] b7bfc92d4e docs: review document for #212
Issue: #212
User-Visible: no
2026-08-20 06:09:06 +00:00
Sergey Matyunin 120d41317c fix: polish device icons and pointer feedback
Issue: #212
User-Visible: yes
2026-08-20 08:42:34 +03:00
claude[bot] 5cae1fdf82 docs: review document for #212
Issue: #212
User-Visible: no
2026-08-20 05:21:34 +00:00
Sergey Matyunin ec6f77b014 docs: specify device icon polish and touch feedback
Issue: #212
User-Visible: no
2026-08-20 08:13:16 +03:00
Sergey Matyunin efcdb269e8 test: accept corrected device marker golden baselines
Validate / docs (push) Successful in 22s
Validate / provenance (push) Successful in 59s
Validate / changes (push) Successful in 47s
Validate / process-gate (push) Failing after 56s
Validate / reuse (push) Successful in 34s
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 12s
Validate / frontend (push) Successful in 4m33s
Validate / backend (push) Failing after 8m42s
Validate / golden (push) Failing after 10m38s
Validate / performance_smoke (push) Failing after 11m22s
Validate / smoke (push) Failing after 19m55s
Issue: #211
User-Visible: no
Release: v1.65.0-beta.7
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32311158065
2026-08-20 02:06:01 +03:00
Sergey Matyunin 9f5ebec6f2 Release v1.65.0-beta.7 candidate
Issue: #210
Issue: #211
User-Visible: yes
2026-08-20 01:57:19 +03:00
claude[bot] 3490faae0d docs: review document for #210
Issue: #210
User-Visible: no
2026-08-19 22:50:42 +00:00
Sergey Matyunin 6063eead10 fix: pin card instances to configured spaces
Issue: #210
User-Visible: yes
2026-08-20 01:38:01 +03:00
claude[bot] b89456ca81 docs: review document for #210
Issue: #210
User-Visible: no
2026-08-19 22:26:20 +00:00
Sergey Matyunin 6c3d376b2b docs: clarify fixed-floor config clearing
Issue: #210
User-Visible: no
2026-08-20 01:22:12 +03:00
claude[bot] c08d5a88ae docs: spec review for #210
Issue: #210
User-Visible: no
2026-08-19 22:21:08 +00:00
Sergey Matyunin 0bd6094ca8 docs: specify fixed-floor card instances
Issue: #210
User-Visible: no
2026-08-20 01:12:55 +03:00
claude[bot] d197381894 docs: review document for #211
Issue: #211
User-Visible: no
2026-08-19 22:05:42 +00:00
Sergey Matyunin 4d1b62b57c test: align locked marker smoke with design package
Issue: #211
User-Visible: no
2026-08-20 00:57:11 +03:00
claude[bot] 303f710e03 docs: code review document for #211
Issue: #211
User-Visible: no
2026-08-19 21:56:08 +00:00
Sergey Matyunin 270cf634e6 test: strengthen unavailable hover mutant
Issue: #211
User-Visible: no
2026-08-20 00:44:48 +03:00
Sergey Matyunin 4e82976b4a fix: match device icons to designer package
Issue: #211
User-Visible: yes
2026-08-20 00:44:11 +03:00
claude[bot] 6feb0189ee docs: review document for #211
Issue: #211
User-Visible: no
2026-08-19 21:25:20 +00:00
Sergey Matyunin ce304646c8 docs: clarify device glyph parity contract
Issue: #211
User-Visible: no
2026-08-20 00:21:49 +03:00
claude[bot] 4fc0f7bd32 docs: review document for #211
Issue: #211
User-Visible: no
2026-08-19 21:20:16 +00:00
Sergey Matyunin 93200eb56a docs: specify device icon visual parity
Issue: #211
User-Visible: no
2026-08-20 00:07:42 +03:00
Sergey Matyunin aa97e7e0bc test: accept device icon golden baselines
Issue: #179
User-Visible: no
Release: v1.65.0-beta.6
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32298649253
2026-08-19 23:36:17 +03:00
Matysh 72eae1059c perf: skip a heavy gate whose inputs are byte-identical to a green run
Every push to dev paid for the full browser trio and the backend suite,
including commits that touch only documentation, workflows or process
scripts — the bundle and the harness were byte-identical, so the runs
proved nothing new. On 2026-08-19 alone that was roughly six pushes at
about seven minutes each.

The reuse key per heavy job is sourceFingerprint (src, demo fixtures,
golden scenarios, build manifests) plus that job's own harness: smoke
takes demo/smoke_*.mjs, golden takes demo/golden/** including baselines,
performance_smoke takes demo/performance/**, backend takes tests_backend
and the Python sources. A cache marker is written only by a successful run
of the same key, so a hit proves a job with identical inputs already
passed. scripts/** is deliberately outside every key: infrastructure work
edits it constantly and reuse would never fire.

This is not the path filter from the `changes` job, which stays disabled
on dev on purpose: there the scope is guessed from paths and "green" means
different things, here input equivalence is proven by a hash. And a
release candidate always bumps the version, which is part of the
fingerprint, so its keys are new by construction and the full gate set
still runs before every beta and release.

A waived job is announced with a notice and a run summary line rather than
skipped in silence, and the marker save tolerates a concurrent identical
run instead of reddening the job.

Issue: #208
User-Visible: no
2026-08-19 23:33:44 +03:00
Sergey Matyunin 11abc0292f test: reset golden pointer between scenarios
Issue: #179
User-Visible: no
2026-08-19 23:27:39 +03:00
Sergey Matyunin c447e41d4d docs: refresh prerelease screenshot fingerprint
Issue: #179
User-Visible: no
2026-08-19 23:19:21 +03:00
Sergey Matyunin c22b39e8fc Release v1.65.0-beta.6 candidate
Issue: #179
Issue: #198
Issue: #204
Issue: #205
User-Visible: yes
2026-08-19 23:17:01 +03:00
claude[bot] 842f9dc30e docs: review document for #179
Issue: #179
User-Visible: no
2026-08-19 20:10:18 +00:00
Sergey Matyunin 9daa2e91fd fix: align device compatibility guards and vacuum puck
Issue: #179
User-Visible: yes
2026-08-19 22:58:01 +03:00
claude[bot] 6fdb7dce1a docs: review document for #179
Issue: #179
User-Visible: no
2026-08-19 19:55:59 +00:00
Sergey Matyunin 7af6d742b9 test: make unavailable hover mutant effective
Issue: #179
User-Visible: no
2026-08-19 22:38:57 +03:00
Sergey Matyunin 040db9ad12 test: wait for device hover transition
Issue: #179
User-Visible: no
2026-08-19 22:38:17 +03:00
Sergey Matyunin 48bcdafab9 feat: redesign device marker faces
Issue: #179
User-Visible: yes
2026-08-19 22:37:25 +03:00
claude[bot] 6731691ad8 docs: review document for #179
Issue: #179
User-Visible: no
2026-08-19 19:13:42 +00:00
Sergey Matyunin 2310e6d88a docs: address device icon spec review
Issue: #179
User-Visible: no
2026-08-19 22:06:25 +03:00
claude[bot] 424e613c6f docs: review document for #179
Issue: #179
User-Visible: no
2026-08-19 19:05:01 +00:00
Sergey Matyunin 517a7101e7 docs: specify device icon redesign
Issue: #179
User-Visible: no
2026-08-19 21:52:17 +03:00
claude[bot] a84338b042 docs: review document for #198
Validate / docs (push) Failing after 21s
Validate / provenance (push) Successful in 47s
Validate / process-gate (push) Failing after 48s
Validate / changes (push) Successful in 52s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / smoke (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Issue: #198
User-Visible: no
2026-08-19 18:18:55 +00:00
Sergey Matyunin 2d5fec09d6 docs: refresh rebased optimizer screenshots
Issue: #198
User-Visible: no
2026-08-19 21:11:06 +03:00
Sergey Matyunin 5ce3ceeca7 test: isolate optimizer mutation fixture
Issue: #198
User-Visible: no
2026-08-19 21:10:27 +03:00
claude[bot]andSergey Matyunin ef22b236f8 docs: code review r2 for optimizer micro-interval cleanup (#198)
Issue: #198
User-Visible: no
2026-08-19 21:10:27 +03:00
Sergey Matyunin a797752c89 test: prove optimizer micro-interval boundaries
Issue: #198
User-Visible: no
2026-08-19 21:10:18 +03:00
claude[bot]andSergey Matyunin 833e8e5472 docs: review document for #198
Issue: #198
User-Visible: no
2026-08-19 21:10:18 +03:00
Sergey Matyunin 9b05dd598d fix: clean isolated wall micro-intervals in Optimize
Issue: #198
User-Visible: yes
2026-08-19 21:10:18 +03:00
claude[bot]andSergey Matyunin cd17a0b00f docs: spec review r1 for optimizer micro-interval cleanup (#198)
Issue: #198
User-Visible: no
2026-08-19 21:09:47 +03:00
Sergey Matyunin 3a68efa62f docs: specify optimizer micro-interval cleanup
Issue: #198
User-Visible: no
2026-08-19 21:09:47 +03:00
claude[bot] 2dd1731cc4 docs: review document for #205
Validate / docs (push) Failing after 26s
Validate / provenance (push) Successful in 40s
Validate / process-gate (push) Failing after 57s
Validate / changes (push) Successful in 57s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / smoke (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Issue: #205
User-Visible: no
2026-08-19 18:08:44 +00:00
Sergey Matyunin e158f8fdcc docs: refresh rebased screenshot provenance
Issue: #205
User-Visible: no
2026-08-19 20:57:50 +03:00
claude[bot]andSergey Matyunin edf532e217 docs: review document for #205
Issue: #205
User-Visible: no
2026-08-19 20:55:38 +03:00
Sergey Matyunin 56e01148f8 fix: resume vacuum trails after short stops
Issue: #205
User-Visible: yes
2026-08-19 20:55:38 +03:00
claude[bot]andSergey Matyunin d31ad3c562 docs: review document for #205
Issue: #205
User-Visible: no
2026-08-19 20:54:57 +03:00
Sergey Matyunin 5c09591ce9 docs: specify vacuum trail resume grace
Issue: #205
User-Visible: no
2026-08-19 20:54:56 +03:00
claude[bot] 5dc9016645 docs: review document for #204
Validate / provenance (push) Successful in 1m4s
Validate / docs (push) Failing after 42s
Validate / process-gate (push) Failing after 1m21s
Validate / changes (push) Successful in 2m6s
Validate / hacs (push) Failing after 18s
Validate / hassfest (push) Failing after 21s
Validate / frontend (push) Successful in 8m43s
Validate / backend (push) Failing after 10m15s
Validate / golden (push) Failing after 13m21s
Validate / performance_smoke (push) Failing after 14m29s
Validate / smoke (push) Failing after 35m34s
Issue: #204
User-Visible: no
2026-08-19 17:44:48 +00:00
Sergey Matyuninandclaude[bot] 6f89002e3a fix: show honest new-space display defaults
Issue: #204
User-Visible: yes
2026-08-19 17:44:48 +00:00
claude[bot]andclaude[bot] 4c8ba981e7 docs: review document for #204
Issue: #204
User-Visible: no
2026-08-19 17:44:48 +00:00
Sergey Matyuninandclaude[bot] 15dc8adc4f docs: specify space creation display defaults
Issue: #204
User-Visible: no
2026-08-19 17:44:48 +00:00
Matysh ad8e7a50cc fix: waive the issue status for a class-A-free range in the process gate
Rule 8 demanded a working S-label from every class A/B commit's issue,
while owner decision #118 sends infrastructure work outside the S1..S8
flow entirely — such an issue has no status label by construction. The
two rules contradicted each other and the machine-checked one won, so
Validate on dev went red on every infrastructure commit (#175, #191,
#202, #206) and the catch-up signal stopped meaning anything. A gate that
is always red is not a gate.

The waiver keys on the diff, not on a permission label: a range with no
class A file at all. An `infra` label could be pinned on a product task
to walk a product commit past the status check; ceasing to touch class A
without ceasing to be infrastructure work is not possible. Issue
existence, open state, `blocked` and fail-closed on an unreachable gh all
still apply, and the waiver prints a visible warning rather than passing
in silence.

Mutation-checked both ways: unwiring the waiver reddens the CLI test,
and letting class A keep the waiver reddens both new tests.

Issue: #207
User-Visible: no
2026-08-19 20:39:07 +03:00
Matysh f287bddd97 fix: cache Playwright browsers instead of reinstalling them via apt
performance_smoke burned nearly all of its 15-minute budget before the
benchmark even started, twice in a row: validate.yml had no browser cache
at all, so every browser job paid for a full `playwright install
--with-deps` — apt work the ubuntu-latest image makes redundant, with
unbounded retries against an unreachable azure mirror on top. For a
measuring job that is worse than lost minutes: the timing window competes
with package installation on the same runner.

#175 fixed this for the review pipeline but deliberately left the flag
here, reasoning that a prerelease gate values predictability over
minutes. That reasoning was wrong — the flag is what made the gate
unpredictable.

Browsers are now cached per package-lock hash in smoke, golden,
performance_smoke and the full performance run; installation happens only
on a cache miss and no longer touches apt. performance_smoke keeps
headroom for a cold cache at 20 minutes. If the image ever drops a
required library, Chromium fails to launch with a clear missing-libraries
error; that is the moment to bring the flag back.

Issue: #206
User-Visible: no
2026-08-19 20:06:38 +03:00
Sergey Matyunin 5ff80f3bdc test: accept v1.65.0-beta.5 Linux golden baselines
Issue: #200
Issue: #203
User-Visible: no
Release: v1.65.0-beta.5
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32276442988
2026-08-19 19:39:41 +03:00
Sergey Matyunin e6016a966f Release v1.65.0-beta.5 candidate
Issue: #176
Issue: #186
Issue: #195
Issue: #200
Issue: #201
Issue: #203
User-Visible: yes
2026-08-19 19:31:44 +03:00
claude[bot] 142bd611b1 docs: code review document for #203
Issue: #203
User-Visible: no
2026-08-19 16:04:50 +00:00
Sergey Matyunin 1290927f10 fix: hide disabled room names
Issue: #203
User-Visible: yes
2026-08-19 18:47:33 +03:00
claude[bot] 84e62dcd0f docs: review document for #203
Issue: #203
User-Visible: no
2026-08-19 15:35:50 +00:00
Sergey Matyunin f7b811a621 docs: protect hidden iso labels in spec
Issue: #203
User-Visible: no
2026-08-19 18:29:28 +03:00
claude[bot] 950403debd docs: review document for #203
Issue: #203
User-Visible: no
2026-08-19 15:28:37 +00:00
Sergey Matyunin 009fed9bc0 docs: specify hidden room names
Issue: #203
User-Visible: no
2026-08-19 18:17:05 +03:00
claude[bot] 10c0f3c95a docs: review document for #201
Issue: #201
User-Visible: no
2026-08-19 15:12:41 +00:00
Sergey Matyunin f7abf14abd fix: inherit parent thickness for atomic walls
Issue: #201
User-Visible: yes
2026-08-19 18:04:11 +03:00
claude[bot] 8b8b9ed90d docs: review document for #201
Issue: #201
User-Visible: no
2026-08-19 14:55:30 +00:00
Sergey Matyunin 7b759f316b docs: specify atomic wall thickness lookup
Issue: #201
User-Visible: no
2026-08-19 17:48:56 +03:00
claude[bot] b583d663e3 docs: review document for #200
Issue: #200
User-Visible: no
2026-08-19 14:42:10 +00:00
Sergey Matyunin 88a28775e7 docs: refresh screenshots for room label parity
Issue: #200
User-Visible: no
2026-08-19 17:16:50 +03:00
Sergey Matyunin cd029a0415 fix: keep room labels aligned across modes
Issue: #200
User-Visible: yes
2026-08-19 17:12:02 +03:00
claude[bot] bf83246b7b docs: review document for #200
Issue: #200
User-Visible: no
2026-08-19 13:57:42 +00:00
Sergey Matyunin 4089c912c6 docs: specify room label parity
Issue: #200
User-Visible: no
2026-08-19 16:50:29 +03:00
claude[bot] 0b411dd802 docs: review document for #195
Issue: #195
User-Visible: no
2026-08-19 13:46:04 +00:00
claude[bot]andSergey Matyunin 07b0b3dec2 docs: review document for #195
Issue: #195
User-Visible: no
2026-08-19 16:38:43 +03:00
Sergey Matyunin 2ef32417bd test: sample editor close glyph before removal
Issue: #195
User-Visible: no
2026-08-19 16:38:43 +03:00
Sergey Matyunin 016c75f539 fix: enlarge editor close hit target
Issue: #195
User-Visible: yes
2026-08-19 16:38:43 +03:00
claude[bot] 71d369a295 docs: review document for #176
Issue: #176
User-Visible: no
2026-08-19 13:24:29 +00:00
Sergey Matyunin f66e671b89 refactor: remove dead partition tool state
Issue: #176
User-Visible: no
2026-08-19 16:17:09 +03:00
claude[bot] 41e2cfffc6 docs: spec review document for #176
Issue: #176
User-Visible: no
2026-08-19 13:09:19 +00:00
claude[bot] a0c4c1cdd8 docs: code review r1 for #186
Validate / docs (push) Failing after 27s
Validate / provenance (push) Successful in 46s
Validate / process-gate (push) Failing after 46s
Validate / changes (push) Successful in 40s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / smoke (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Issue: #186
User-Visible: no
2026-08-19 13:00:16 +00:00
Sergey Matyunin edfea67ddd docs: refresh screenshot source fingerprint
Issue: #186
User-Visible: no
2026-08-19 15:57:00 +03:00
Sergey Matyunin 57ba75b9da fix: keep jamb margin on partition openings
Issue: #186
User-Visible: yes
2026-08-19 15:43:23 +03:00
claude[bot]andSergey Matyunin 804b282f5f docs: review document for #186
Issue: #186
User-Visible: no
2026-08-19 15:33:45 +03:00
Sergey Matyunin a356ec29ab docs: preserve legacy jambs on full restore
Issue: #186
User-Visible: no
2026-08-19 15:33:45 +03:00
claude[bot]andSergey Matyunin 67bf85e7d2 docs: review document for #186
Issue: #186
User-Visible: no
2026-08-19 15:33:44 +03:00
Sergey Matyunin 2453ec0d7f docs: specify partition opening jamb margin
Issue: #186
User-Visible: no
2026-08-19 15:33:44 +03:00
Sergey Matyunin fb265282cc test: accept v1.65.0-beta.4 Linux golden baselines
Issue: #180
Issue: #192
Issue: #193
Issue: #197
User-Visible: no
Release: v1.65.0-beta.4
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32247456636
2026-08-19 14:34:17 +03:00
Sergey Matyunin c9a83af50e Release v1.65.0-beta.4 candidate
Issue: #180
Issue: #192
Issue: #193
Issue: #196
Issue: #197
User-Visible: yes
2026-08-19 14:24:38 +03:00
Sergey Matyunin f08c4adabe test: align full-suite contracts with unified picker
Issue: #180
User-Visible: no
2026-08-19 14:24:30 +03:00
Matysh 6e93aa705c docs: fix in-scope Medium findings inside the current issue
Filing and servicing a separate issue costs far more than fixing a small
problem in place — the owner's call of 2026-08-19 (#202). A Medium finding
inside the task's scope no longer becomes its own issue: with no High
findings the verdict is yellow, the author fixes it and the fix passes
another review cycle. Only an out-of-scope Medium is still filed
separately, because foreign scope is never patched from a task branch.

Applied to the canon (PROCESS.md), the reviewer prompt in process.yml and
AGENTS.md; the verdict format now writes "Medium: N -> in-task | #NN".

Issue: #202
User-Visible: no
2026-08-19 13:46:25 +03:00
claude[bot] dd2e0e9b08 docs: review document for #196
Issue: #196
User-Visible: no
2026-08-19 10:34:55 +00:00
Sergey Matyunin d01d0926be feat: show humidity in room tooltips
Issue: #196
User-Visible: yes
2026-08-19 13:28:09 +03:00
claude[bot] 74d19d4d14 docs: spec review r1 for #196
Issue: #196
User-Visible: no
2026-08-19 10:23:47 +00:00
Matysh c1dde9a0cf docs: fix in-scope Medium findings inside the current issue
Validate / provenance (push) Successful in 42s
Validate / process-gate (push) Failing after 36s
Validate / changes (push) Successful in 44s
Validate / hacs (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / smoke (push) Skipped
Validate / golden (push) Skipped
Validate / backend (push) Skipped
Full Performance / performance (push) Failing after 2h16m25s
Filing and servicing a separate issue costs far more than fixing a small
problem in place — the owner's call of 2026-08-19 (#202). A Medium finding
inside the task's scope no longer becomes its own issue: with no High
findings the verdict is yellow, the author fixes it and the fix passes
another review cycle. Only an out-of-scope Medium is still filed
separately, because foreign scope is never patched from a task branch.

Applied to the canon (PROCESS.md), the reviewer prompt in process.yml and
AGENTS.md; the verdict format now writes "Medium: N -> in-task | #NN".

Issue: #202
User-Visible: no
2026-08-19 13:16:30 +03:00
claude[bot] 56f31dc199 docs: code review r1 for #197
Validate / changes (push) Successful in 1m20s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / provenance (push) Successful in 1m11s
Validate / backend (push) Skipped
Validate / docs (push) Failing after 38s
Validate / process-gate (push) Failing after 1m27s
Validate / frontend (push) Successful in 7m58s
Validate / golden (push) Failing after 9m32s
Validate / performance_smoke (push) Failing after 14m22s
Validate / smoke (push) Failing after 29m53s
Issue: #197
User-Visible: no
2026-08-19 10:12:32 +00:00
Sergey Matyunin db57180956 fix: isolate degenerate junction patches
Issue: #197
User-Visible: yes
2026-08-19 13:02:12 +03:00
claude[bot] bd9409b33d docs: spec review r3 for #197
Issue: #197
User-Visible: no
2026-08-19 09:48:18 +00:00
Sergey Matyunin b203e8faeb docs: clarify issue 197 reproduction coordinates
Issue: #197
User-Visible: no
2026-08-19 12:39:37 +03:00
claude[bot] 63249ffd97 docs: fix Medium count in spec review for #197
Issue: #197
User-Visible: no
2026-08-19 09:36:36 +00:00
claude[bot] 327c35f606 docs: spec review for #197
Issue: #197
User-Visible: no
2026-08-19 09:35:29 +00:00
Sergey Matyunin fe7b28f3a7 docs: specify junction patch resilience
Issue: #197
User-Visible: no
2026-08-19 12:15:55 +03:00
claude[bot] 19e92e0cc0 docs: review document for #180
Validate / hacs (push) Failing after 17s
Validate / hassfest (push) Failing after 17s
Validate / frontend (push) Successful in 6m39s
Validate / docs (push) Failing after 28s
Validate / backend (push) Failing after 6m54s
Validate / performance_smoke (push) Failing after 2m3s
Validate / provenance (push) Successful in 44s
Validate / golden (push) Failing after 2m5s
Validate / changes (push) Successful in 49s
Validate / smoke (push) Failing after 2m8s
Validate / process-gate (push) Failing after 54s
Issue: #180
User-Visible: no
2026-08-19 09:03:56 +00:00
Sergey Matyunin 9bde4b1a6d Prevent ripple color label overlap
Issue: #180
User-Visible: yes
2026-08-19 11:55:43 +03:00
claude[bot] 1bf90ee0d8 docs: review document for #180
Issue: #180
User-Visible: no
2026-08-19 08:52:44 +00:00
Sergey Matyunin fcee724638 Unify all color picker call sites
Issue: #180
User-Visible: yes
2026-08-19 11:40:32 +03:00
claude[bot] f69ac71ef7 docs: review document for #180
Issue: #180
User-Visible: no
2026-08-19 08:30:56 +00:00
Sergey Matyunin 3540d24f18 docs: specify unified color picker coverage
Issue: #180
User-Visible: no
2026-08-19 11:22:36 +03:00
claude[bot] 25ea8fefab docs: review document for #193
Issue: #193
User-Visible: no
2026-08-19 08:01:36 +00:00
Sergey Matyunin 9ec3636a42 Preview open passage placement
Issue: #193
User-Visible: yes
2026-08-19 10:51:07 +03:00
Sergey Matyunin 07d0c2ef86 Document passage preview touch contract
Issue: #193
User-Visible: no
2026-08-19 10:44:57 +03:00
claude[bot] 053007414d docs: review document for #193
Issue: #193
User-Visible: no
2026-08-19 07:44:15 +00:00
Sergey Matyunin fc22d9a6c5 Specify passage placement preview
Issue: #193
User-Visible: no
2026-08-19 10:34:58 +03:00
claude[bot]andclaude[bot] fbbaed22de docs: review document for #192
Issue: #192
User-Visible: no
2026-08-19 07:28:37 +00:00
Sergey Matyuninandclaude[bot] 29ce5d9e65 Keep hue slider thumb visible
Issue: #192
User-Visible: yes
2026-08-19 07:28:37 +00:00
claude[bot] 3a8aae06d1 docs: review document for #192
Issue: #192
User-Visible: no
2026-08-19 07:28:37 +00:00
Sergey Matyuninandclaude[bot] a75c729d87 Show spectrum on hue slider
Issue: #192
User-Visible: yes
2026-08-19 07:28:37 +00:00
Matysh a55ba3de8b test: make the junction-patch bridge test able to fail
The old fixture ran the branch into the end of p1, so the T-patch lived
beyond the host (x>100) and the probe point [92,0] sat outside it under
any code behaviour: deleting the cutPartitionBody flatMap over patches
kept all subtests green (#188, found at the #132 code review).

The branch now meets the middle of the span, putting both node patches
inside the default opening's slot. The test proves its own fixture first:
an uncut run must show the patches bridging the slot, so if the geometry
ever stops producing them the control goes red instead of silently
devaluing the real assertion. Mutation-checked: reverting the flatMap
fails exactly this test, 888/889.

Issue: #188
User-Visible: no
2026-08-19 09:57:43 +03:00
Matysh 2f0dc44f27 fix: clamp an issue-branch gate range to the branch's own commits
After the mandatory rebase of a published issue branch the pre-push hook
still passes remote_old..local_new, and once the old tip is no longer an
ancestor that range drags in the whole advanced dev history: on #117 it
meant 84 foreign commits and 20 false rule-8 rejections over already
closed issues, leaving --no-verify as the only exit.

The clamp lives in the gate rather than the hook: .githooks/pre-push
carries an executable bit that MCP publication strips (the commit-msg
precedent), so editing it needs an owner-side commit. When the target is
an issue branch and the declared base is not an ancestor of the head, the
base becomes the merge-base with origin/dev. Fast-forward pushes keep
their exact range, every own commit is still judged, and a real violation
in a post-rebase commit still blocks — covered by a scenario test that
goes red without the wiring.

Issue: #190
User-Visible: no
2026-08-19 09:48:39 +03:00
claude[bot] 1c3404fdd0 docs: review document for #192
Issue: #192
User-Visible: no
2026-08-19 06:46:17 +00:00
Matysh 3e335b8808 docs: list the actual Validate gate jobs in AGENTS.md
The canonical job list predated the `docs` job (added 2026-08-16) and
omitted `process-gate`. `docs` is a real blocking gate — its fingerprint
check went red right after the #113 merge and cost an extra review cycle
of confusion. The list now matches validate.yml and names `changes` as a
service path-filter rather than a gate.

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

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

Issue: #175
User-Visible: no
2026-08-18 20:01:23 +03:00
Matysh 91f2c23539 fix: install Chromium without --with-deps in the review pipeline
Full Performance / performance (push) Failing after 1h30m16s
Validate / provenance (push) Successful in 48s
Validate / changes (push) Successful in 38s
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Validate / process-gate (push) Failing after 45s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / smoke (push) Skipped
Validate / golden (push) Skipped
On a Playwright cache miss the flag pulled Chromium's system libraries
through apt, spending minutes of the 45-minute review budget on packages
the ubuntu-latest image already ships — and the runner's retries against
the unreachable azure mirror made the step look hung on a live run. If the
image ever drops a required library, Chromium fails to launch with a clear
missing-libraries error; that is the moment to bring the flag back.

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

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

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

Issue: #171
User-Visible: no
2026-08-18 18:02:44 +03:00
Matysh ba32234b52 fix: fail loudly when a review verdict has no document
On #150 both spec-review verdicts survived only as issue comments: the
publish step found nothing staged, printed a warning, and exited zero, so
the label moved and the missing artifact went unnoticed until the next
review caught it (#171). A verdict without a document in docs/reviews/ now
fails the run before the label step, preserving the invariant that an
unchanged label means a failed run.

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

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

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

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

Issue: #114
User-Visible: no
2026-08-18 17:21:06 +03:00
Matysh 382afd2766 fix: verify the PAT before reviewing, pick the freshest task branch
Issue #150 reached a green verdict and then hit two pipeline defects at once.
The review document push came back 403 as github-actions[bot]: the PAT had
died, and checkout's persisted credential quietly took its place — a masked
actor instead of a loud failure. Credentials are no longer persisted, and the
token is now proven alive before the review starts, not after forty minutes of
reviewer work.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Issue: #114
User-Visible: no
2026-08-13 11:34:01 +03:00
635 changed files with 99546 additions and 5758 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ while read -r local_ref local_sha remote_ref remote_sha; do
echo "process-gate: $local_ref, диапазон ${base}..${local_sha}" >&2
# shellcheck disable=SC2086
if ! node "$gate" --range "${base}..${local_sha}" $issues_flag >&2; then
if ! node "$gate" --range "${base}..${local_sha}" --target-ref "$remote_ref" $issues_flag >&2; then
status=1
fi
done
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
steps:
- name: Check out release notes for a reusable call
if: ${{ inputs.reusable == true }}
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
- name: Send to Telegram
+73
View File
@@ -0,0 +1,73 @@
name: Mutation gate
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
# защитой, и это хуже его отсутствия.
#
# Прогон дорогой: пересборка бандла на каждого мутанта. Поэтому он не входит в
# Validate и не идёт на каждый push. Его место — перед стабильным релизом
# (PROCESS.md §8) и раз в неделю по расписанию, чтобы дрейф тестов не копился
# до релиза. Дешёвая половина — «якоря патчей живы, guard-файлы существуют» —
# идёт с обычными юнитами: test/mutation-gate.test.mjs.
on:
workflow_dispatch:
inputs:
ref:
description: Git ref whose mutation guards must be proved
required: false
default: dev
schedule:
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
- cron: '20 5 * * 1'
permissions:
contents: read
concurrency:
group: mutation-gate
cancel-in-progress: true
jobs:
mutants:
runs-on: ubuntu-latest
# Все мутанты × (сборка + свой guard) — это десятки минут, и это нормально:
# гейт предрелизный. Час — потолок против зависшего Chromium.
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Установить backend test dependencies
run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Реестр применим к текущему коду
run: node scripts/mutation-gate.mjs --check
- name: Каждый тест ловит свою поломку
run: node scripts/mutation-gate.mjs
+16 -5
View File
@@ -30,7 +30,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Check out candidate
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
path: candidate
fetch-depth: 2
@@ -116,12 +116,12 @@ jobs:
echo "Comparison base: $sha ($source)" >> "$GITHUB_STEP_SUMMARY"
- name: Check out base SHA
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
ref: ${{ steps.base.outputs.sha }}
path: baseline
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
@@ -132,9 +132,17 @@ jobs:
- name: Install candidate and baseline dependencies
run: npm ci --prefix candidate && npm ci --prefix baseline
# То же, что в validate.yml: кэш браузеров, apt не трогаем (#206).
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('candidate/package-lock.json') }}
- name: Install pinned Chromium
if: steps.pw.outputs.cache-hit != 'true'
working-directory: candidate
run: npx playwright install --with-deps chromium
run: npx playwright install chromium
- name: Build both exact source trees
run: |
@@ -150,6 +158,8 @@ jobs:
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
@@ -164,12 +174,13 @@ jobs:
run: |
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
- name: Upload full performance reports
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: full-performance
path: artifacts/performance
+311 -49
View File
@@ -39,6 +39,7 @@ jobs:
outputs:
stage: ${{ steps.decide.outputs.stage }}
cycle: ${{ steps.decide.outputs.cycle }}
spent: ${{ steps.decide.outputs.spent }}
limit: ${{ steps.decide.outputs.limit }}
steps:
- id: decide
@@ -48,6 +49,7 @@ jobs:
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
SMALL: ${{ contains(github.event.issue.labels.*.name, 'small') }}
TRIVIAL: ${{ contains(github.event.issue.labels.*.name, 'trivial') }}
NUM: ${{ github.event.issue.number }}
run: |
# Этап определяется первым: от него зависит, какие вердикты считать.
@@ -58,22 +60,45 @@ jobs:
*) echo "метка $LABEL конвейер не запускает" ;;
esac
# Лимит циклов: 4 обычный, 2 на лёгком треке (PROCESS.md §4).
limit=4; [ "$SMALL" = "true" ] && limit=2
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
limit=4
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
# первое код-ревью получило r2/4. На задаче с двумя циклами ТЗ второе
# код-ревью упиралось бы в review-4 после одной правки.
# Считаются ДВЕ РАЗНЫЕ величины, и это не педантизм (#227).
#
# Этап опознаётся по имени документа в теле комментария. Если документа
# нет, вердикт не посчитается — недосчёт даёт лишний цикл, а перерасчёт
# остановил бы работу досрочно; из двух ошибок выбрана обратимая.
done_cycles=0
# `attempt` — сколько раз ревью уже отработало на этом этапе. Он нужен
# только для имени документа и метки: два захода с одинаковым номером
# означают, что второй документ перезапишет первый и артефакт ревью
# исчезнет.
#
# `spent` — сколько циклов израсходовано из бюджета §4. Цикл — это
# «отправка на ревью → вердикт с блокирующими находками → возврат
# автору», поэтому бюджет тратят ТОЛЬКО жёлтые и красные вердикты.
# Зелёный ничего на правки не вернул и цикла не образует.
#
# Раньше обе роли исполнял один счётчик всех вердиктов, и конвейер
# наказывал за то, что предписывал сам: при неудавшемся слиянии он
# велит вернуть S7-code-review после ребейза, и этот заход добивал
# бюджет. На #225 (лёгкий трек, лимит 2) последовательность
# жёлтый → зелёный → ребейз дала review-4 на задаче с зелёным ревью и
# зелёным CI: работа встала, хотя после вердикта не было ни одной
# правки продуктового кода.
#
# Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал
# цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по
# имени документа в теле комментария; документа нет — вердикт не
# посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы
# работу досрочно: из двух ошибок выбрана обратимая.
attempt=1; spent=0; spent_list=""
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")
comments=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json comments)
of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))]"
# Блокирующим считается вердикт, у которого в строке вердикта стоит
# «жёлтый» или «красный». Регистр и окружение слова не важны.
blocking="$of_stage | map(select(.body | test(\"Вердикт:[^\\n]*(жёлт|красн)\"; \"i\")))"
attempt=$(( $(printf '%s' "$comments" | jq -r "$of_stage | length") + 1 ))
spent=$(printf '%s' "$comments" | jq -r "$blocking | length")
spent_list=$(printf '%s' "$comments" | jq -r "$blocking | map(\"- \" + .url) | join(\"\\n\")")
fi
# Отказ обязан быть виден в issue, а не только в логе прогона.
@@ -107,19 +132,33 @@ jobs:
refuse "стоит blocked — конвейер не запускается" \
"на issue стоит \`blocked\` — задача ждёт внешнего решения. Снять метку, когда решение принято."
elif [ "$EXHAUSTED" = "true" ]; then
# Метку снимает владелец, а не конвейер: автоматика, отменяющая
# остановку работы, дороже ручного снятия. Но пересчёт печатается —
# метка могла остаться от прежнего правила, когда бюджет тратил и
# зелёный вердикт (#227).
stale=""
if [ "$spent" -lt "$limit" ]; then
stale=" Пересчёт по действующему правилу: блокирующих циклов $spent из $limit — метка могла остаться от прежнего правила, когда бюджет тратил любой вердикт. Снять её может владелец."
fi
refuse "стоит review-4 — решение за владельцем" \
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
elif [ "$done_cycles" -ge "$limit" ]; then
echo "циклов этапа $stage пройдено $done_cycles из $limit — лимит исчерпан"
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4).$stale"
elif [ "$spent" -ge "$limit" ]; then
echo "блокирующих циклов этапа $stage: $spent из $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)."
"Лимит циклов ревью исчерпан: блокирующих циклов $spent из $limit на этапе \`$stage\` (заход $attempt). Следующего захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4).
Учтены вердикты с блокирующими находками — зелёные бюджет не тратят:
$spent_list"
stage=""
else
echo "этап $stage, цикл $((done_cycles + 1)) из $limit"
echo "этап $stage, заход $attempt, блокирующих циклов $spent из $limit"
fi
echo "stage=$stage" >> "$GITHUB_OUTPUT"
echo "cycle=$((done_cycles + 1))" >> "$GITHUB_OUTPUT"
echo "cycle=$attempt" >> "$GITHUB_OUTPUT"
echo "spent=$spent" >> "$GITHUB_OUTPUT"
echo "limit=$limit" >> "$GITHUB_OUTPUT"
review:
@@ -129,13 +168,40 @@ jobs:
# Время — единственный настоящий ограничитель зациклившегося прогона.
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: dev
# Иначе в конфиге git остаётся креденшел GITHUB_TOKEN, и push с
# мёртвым PAT молча уходит от github-actions[bot] — 403 при
# contents: read. Отказ обязан быть громким и правильным.
persist-credentials: false
- uses: actions/setup-node@v4
with: { node-version: 22 }
# Живость PAT проверяется ДО ревью. На #150 истёкший токен обнаружился
# только на публикации документа — после сорока минут работы ревьюера.
- name: Секрет HP_PROCESS_TOKEN жив
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::HP_PROCESS_TOKEN пуст — секрет удалён или недоступен"
exit 1
fi
if ! login=$(gh api user -q .login 2>/dev/null); then
echo "::error::HP_PROCESS_TOKEN не аутентифицируется — истёк или отозван. Обновить: Settings -> Secrets and variables -> Actions -> HP_PROCESS_TOKEN"
exit 1
fi
echo "токен жив, действует от: $login"
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
# тяжёлых job, здесь его не было.
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
@@ -145,8 +211,15 @@ jobs:
env:
NUM: ${{ github.event.issue.number }}
run: |
branch=$(git ls-remote --heads origin "issue/${NUM}-*" \
| head -1 | sed 's|.*refs/heads/||')
# Свежая по последнему коммиту, а не первая по алфавиту: на #150 рядом
# жили ветка ТЗ и ветка реализации, и head -1 выбрал устаревшую.
git fetch -q origin "+refs/heads/issue/${NUM}-*:refs/remotes/origin/issue/${NUM}-*" || true
branches=$(git for-each-ref --sort=-committerdate \
--format='%(refname:lstrip=3)' "refs/remotes/origin/issue/${NUM}-*")
branch=$(printf '%s\n' "$branches" | head -1)
if [ "$(printf '%s\n' "$branches" | grep -c .)" -gt 1 ]; then
echo "::warning::веток issue/${NUM}-* несколько ($(echo $branches | tr '\n' ' ')) — выбрана свежая по коммиту: $branch. Устаревшую следует удалить."
fi
if [ -n "$branch" ]; then
git checkout -q "origin/$branch"
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
@@ -156,9 +229,36 @@ jobs:
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
fi
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
- name: Установить зависимости
run: npm ci
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
# но когда нужен — качать его заново дороже, чем держать в кэше.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
# Без --with-deps: системные библиотеки Chromium предустановлены в
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
# библиотека когда-нибудь пропадёт из образа, Chromium не запустится с
# внятной ошибкой — тогда флаг вернуть.
run: npx playwright install chromium
- name: Review
id: review
uses: anthropics/claude-code-action@v1
env:
# Вне рабочей копии: восстановление дерева ревьюером не должно
# уничтожать его собственный артефакт (#220).
REVIEW_DOC: ${{ runner.temp }}/review-document.md
with:
# Подписка, а не отдельный счёт API: токен выпускается через
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
@@ -171,6 +271,42 @@ jobs:
Этап: ${{ needs.guard.outputs.stage }}
spec — ревью ТЗ (PROCESS.md §2.4)
code — код-ревью (PROCESS.md §2.7)
Заход: r${{ needs.guard.outputs.cycle }} · блокирующих циклов израсходовано ${{ needs.guard.outputs.spent }} из ${{ needs.guard.outputs.limit }}
Бюджет §4 тратят только жёлтые и красные вердикты: зелёный
ничего не вернул на правки и цикла не образует (#227).
Номер захода нужен для имени документа — два документа с
одинаковым номером затёрли бы друг друга.
**Если цикл не первый — объём разбора по дельте, а не заново**
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
всех раундов, и повторный цикл заново выводил продуктовую рамку и
перепроверял AC, которых правка не касалась: r2 по #150 стоил
полного прогона ради одной строки в тестовой фикстуре.
Порядок для r2 и дальше:
1. найди вердикт предыдущего раунда в комментариях issue и SHA,
на котором он получен. SHA в вердикте не назван — это находка;
2. объяви дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла
ТЗ или тела issue для spec. Дельта — предмет этого раунда;
3. по каждой находке предыдущего раунда покажи, чем именно она
закрыта: строка кода или текста, а не заявление автора;
4. заново проверяй только те AC, чьё доказательство дельта
задевает. Остальные наследуй;
5. в документе обязателен раздел «Унаследовано из r<N-1>»: что
принято без повторной проверки, со ссылкой на документ того
раунда и SHA, на котором вывод получен. Без этого перечня
сокращение — молчаливое доверие, а такой тихий успех уже
дважды стоил дня (#171, #207).
Разбор остаётся ПОЛНЫМ, если дельта не локальна: ребейз на ушедший
вперёд dev (после ребейза это другой код, §7.2), смена контракта
поведения, задета новая подсистема, либо объём дельты сопоставим с
исходной задачей. Сомневаешься — разбирай полностью и скажи почему.
Сокращается объём РАЗБОРА, а не строгость: правка по замечанию
способна сломать AC, который предыдущий раунд признал выполненным —
так появилась регрессия #102. Поэтому граница не «только находки», а
«находки плюс всё, до чего дотягивается дельта».
Прочитай в этом порядке, прежде чем судить:
1. docs/SCOPE.md — зачем продукт существует и для кого. Он
@@ -205,34 +341,90 @@ jobs:
По каждому AC: либо он доказан автотестом и ты убедился, что тест
умеет падать, либо разобран по коду с явной записью «проверено
чтением, не исполнением». «Verified» без названной команды и её
результата доказательством не является. Зависимостей в рабочей
копии нет: перед гейтами выполни `npm ci`. Проверь трейлеры Issue и
User-Visible, при User-Visible: yes — правки в оба changelog в том же
коммите.
результата доказательством не является. Зависимости уже установлены
workflow, Chromium тоже — `npm ci` выполнять не нужно. Проверь
трейлеры Issue и User-Visible, при User-Visible: yes — правки в оба
changelog в том же коммите.
**Объём гейтов соразмерен задаче.** Прогонять весь набор на каждой
правке — не тщательность, а потеря времени: полные наборы это
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
Всегда, они дешёвые, и в повторном раунде тоже: код изменился,
а стоят они минуты:
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
копий бандла. Плюс `node scripts/check-docs.mjs`, если diff трогает
`src/**`: отпечаток скриншотов документации считается по всему
`src/**`, поэтому любая правка фронтенда делает его устаревшим —
выбирать тут нечего. Пропуск этого шага в #230 и #234 оставил `dev`
с красным job `docs` до следующей задачи (#237).
По необходимости, и «необходимость» определяется diff'ом и AC:
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс
относящиеся к тронутым поверхностям. Их 163; прогон всех уместен
только когда задача действительно задевает всё. Выбирать по теме
недостаточно: регресс #234 поймал `smoke_wall_junctions`, который
по названию про стыки стен, а не про толщину отрезка. Надёжнее
grep — искать в `demo/smoke_*.mjs` имена изменённых функций и
полей, и прогонять найденное;
- `npm run golden:verify` — если diff может изменить видимый
результат: рендер, геометрия, стили, слои;
- `python -m pytest tests_backend -q` — если тронут
`custom_components/**/*.py`;
- performance-профили — если названы в AC либо тронуты
чувствительные к перфу пути.
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
тем тестам, которые ты прогонял.
**В комментарии обязателен перечень: какие гейты прогнал, какие нет и
почему.** Это условие честности такого сужения: непрогнанный гейт
становится видимым решением, а не молчаливым пропуском. Раздел «чего
не проверял» в документе ревью — не формальность, а главный его
раздел на коротких задачах.
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
Серьёзность: High блокирует; Medium обязан стать отдельным issue;
Low либо правится, либо снимается с записью. Жёлтый вердикт
допустим при полностью выполненных AC, если изменение не решает
заявленный сценарий или ухудшает смежный. Продуктовое рассуждение
расширяет вопросы, но не отменяет AC и не даёт права менять скоуп.
Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней
же — без High это жёлтый вердикт и возврат автору, отдельный issue
НЕ заводится (решение владельца 2026-08-19, #202: заведение и
обслуживание issue дороже правки на месте); Low либо правится,
либо снимается с записью. Жёлтый вердикт допустим и при полностью
выполненных AC, если изменение не решает заявленный сценарий или
ухудшает смежный. Продуктовое рассуждение расширяет вопросы, но не
отменяет AC и не даёт права менять скоуп.
Каждую Medium-находку заведи отдельным issue со ссылкой на
#${{ github.event.issue.number }} и метками: тип, приоритет,
S1-new. «Оставили в тексте ревью» закрытием не считается и прямо
запрещено §12.
Только 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/ опубликован не будет.
Напиши полный документ ревью в файл, путь которого лежит в
переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория).
Почему не в docs/reviews: документ там был некоммитнутым файлом того
же дерева, которое ты мутируешь, проверяя «умеет ли тест падать». На
#220 три раунда подряд документ исчезал — восстановление дерева
(`git checkout -- .`, `git clean -fd`) сносит собственный артефакт
ревью, потому что он untracked. В репозиторий его положит шаг
публикации, взяв из REVIEW_DOC; тебе трогать docs/reviews не нужно.
В самом репозитории не создавай файлов вообще: любые изменения в
рабочей копии будут отброшены. Имя документа в docs/reviews шаг
публикации соберёт сам — SPEC-REVIEW для этапа spec, CODE-REVIEW для
code, с номером issue и заходом.
Содержание документа: скоуп, как проверялось, находки с
воспроизведением, что проверено и корректно, чего не проверял. Для
r2 и дальше добавь два раздела: «Закрытие раунда r<N-1>» — таблица
«находка | чем закрыта | где это видно», и «Унаследовано из r<N-1>» —
что принято без повторной проверки, с документом и SHA.
Затем оставь в issue краткий комментарий: вердикт, ключевые находки
и ссылка на документ. Первой строкой — вердикт в формате §7.2:
`Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → #…`
`Вердикт: зелёный/жёлтый/красный · заход r${{ needs.guard.outputs.cycle }} · блокирующих циклов ${{ needs.guard.outputs.spent }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…`
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору жёлтым)
Затем верни JSON по схеме. Это последнее действие и оно обязательно:
без него метка не переставится и конвейер встанет.
@@ -248,15 +440,62 @@ jobs:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
SOURCE: ${{ runner.temp }}/review-document.md
run: |
# Ветки задачи может не быть: у задач, размеченных до появления
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
# комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что
# ничего не сделал. Документ ложится туда же, где лежит само ТЗ.
target="${BRANCH:-dev}"
if [ -z "$BRANCH" ]; then
echo "ветки задачи нет — документ некуда класть"; exit 0
echo "::warning::ветки задачи нет — документ ревью ляжет в dev"
fi
marker=CODE-REVIEW
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
# Рабочая копия отбрасывается ДО того, как документ попадёт в дерево:
# ревьюер правит код, проверяя «умеет ли тест падать», и его правки
# публиковаться не должны.
git checkout -- . 2>/dev/null || true
# docs/reviews исключён из уборки: ревьюер мог написать документ по
# старому пути, и клин не должен его съесть до `git add` — ровно так
# оба пути остаются работоспособными.
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
# Документ приезжает извне репозитория (#220). Три раунда подряд он
# терялся, пока лежал некоммитнутым файлом в том же дереве, которое
# ревьюер мутирует и затем восстанавливает: `git checkout -- .` плюс
# `git clean -fd` сносят собственный артефакт ревью, потому что он
# untracked. Теперь его место — RUNNER_TEMP, и уборка дерева ему не
# страшна.
if [ -f "$SOURCE" ]; then
mkdir -p docs/reviews
cp "$SOURCE" "$doc"
echo "документ взят из $SOURCE ($(wc -c < "$doc") байт)"
else
# Совместимость: ревьюер мог написать по старому пути, если промпт
# ещё не обновился в этой ветке.
echo "::warning::$SOURCE не найден — ищу документ в рабочей копии"
fi
git add docs/reviews 2>/dev/null || true
if git diff --cached --quiet; then
echo "документ ревью не создан"; exit 0
# Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит
# документ сам, своим app-токеном мимо этого шага (CODE-REVIEW-150-r1,
# коммиттер GitHub). Провал — когда файла нет и на ветке.
git fetch -q origin "$target"
if git cat-file -e "origin/$target:$doc" 2>/dev/null; then
echo "документ уже опубликован ревьюером: $doc"
exit 0
fi
# Ревью без артефакта запрещено (PROCESS.md §2.4/§10.4/§12). Раньше
# здесь стоял warning с exit 0: на #150 оба вердикта ревью ТЗ
# остались только комментариями, метки переставились, и пропажу
# заметило лишь следующее ревью — issue #171. Падение ДО шага с
# меткой сохраняет инвариант «метка не сменилась = прогон упал».
echo "::error::вердикт есть, а документа нет: ни $SOURCE, ни $doc в рабочей копии, ни $doc в $target — ревью без артефакта (#171, #220)"
exit 1
fi
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
@@ -266,9 +505,32 @@ jobs:
Issue: #$NUM
User-Visible: no
EOF
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$BRANCH"
echo "документ опубликован в $BRANCH"
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
# либо dev, поэтому push нужен с явным перебазированием при гонке:
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"; then
git fetch -q origin "$target"
if ! git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
rebase "origin/$target"; then
git rebase --abort || true
# Тоже вердикт без артефакта: раньше exit 0 переставил бы метку.
echo "::error::документ ревью не удалось опубликовать в $target: конфликт (#171)"
exit 1
fi
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"
fi
# Постусловие: до ветки дошёл именно ожидаемый файл. Коммит с
# документом, названным не по формату, — тот же вердикт без
# артефакта, только дороже в обнаружении.
git fetch -q origin "$target"
if ! git cat-file -e "origin/$target:$doc" 2>/dev/null; then
echo "::error::коммит в $target опубликован, но ожидаемого $doc в нём нет — файл назван не по формату (#171)"
exit 1
fi
echo "документ опубликован в $target: $doc"
- name: Решение по вердикту
id: decide
+5 -5
View File
@@ -24,11 +24,11 @@ jobs:
sha: ${{ steps.candidate.outputs.sha }}
tag: ${{ steps.candidate.outputs.tag }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Pin the current dev candidate
id: candidate
@@ -67,11 +67,11 @@ jobs:
url: ${{ steps.verify.outputs.url }}
newly_published: ${{ steps.release.outputs.newly_published }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ needs.gate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Build and verify both release assets before publication
env:
@@ -173,7 +173,7 @@ jobs:
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n- assets: `houseplan-card.js`, `houseplan.zip`\n' \
"$TAG" "$SHA" "$URL" >> "$GITHUB_STEP_SUMMARY"
- name: Verify HACS prerelease discovery order
uses: actions/github-script@v7
uses: actions/github-script@v9
env:
EXPECTED_TAG: ${{ needs.gate.outputs.tag }}
with:
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ github.event.inputs.tag }}
run: echo "tag=${EVENT_TAG:-$INPUT_TAG}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ steps.tag.outputs.tag }}
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
+7 -7
View File
@@ -16,11 +16,11 @@ jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ github.event.release.tag_name }}
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Require a green Validate for this exact commit
env:
@@ -47,10 +47,10 @@ jobs:
needs: gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ github.event.release.tag_name }}
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with: { node-version: 22 }
- run: npm ci && npm run build
- name: Verify compositor frame continuity for a stable release
@@ -61,13 +61,13 @@ jobs:
npm run continuity:screencast
- name: Upload failed continuity frames
if: ${{ failure() && !github.event.release.prerelease }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: continuity-screencast
path: artifacts/continuity-screencast
- run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
- name: Attach card to release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: dist/houseplan-card.js
hacs-discovery:
@@ -80,7 +80,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Verify the published tag is the prerelease HACS will discover
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
+296 -30
View File
@@ -15,12 +15,21 @@ concurrency:
cancel-in-progress: true
jobs:
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Validate public documentation
run: node scripts/check-docs.mjs --external
provenance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Validate commit trailers and hook mode
env:
@@ -28,8 +37,9 @@ jobs:
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
DEVELOPMENT_BRANCH: dev
run: |
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range
# Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на
@@ -40,9 +50,9 @@ jobs:
process-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Process gate
env:
@@ -50,33 +60,181 @@ jobs:
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
DEVELOPMENT_BRANCH: dev
TARGET_REF: ${{ github.ref }}
# Публичный репозиторий: штатного токена хватает на чтение issue.
GH_TOKEN: ${{ github.token }}
run: |
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
node scripts/process-gate.mjs --github-range --issues
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
# относящееся к ним. НА DEV ФИЛЬТРОВ НЕТ: гейт беты принимает «зелёный Validate
# на точном SHA», и если объём прогона зависит от diff, «зелёный» перестаёт
# значить одно и то же — кандидат релиза (манифесты + changelog) пропустил бы
# браузерные тесты, а прогон с пропущенными job всё равно success. Фильтры
# экономят на ветках задач, где Validate — ранний сигнал: настоящую приёмку
# там делает код-ревью, которое гоняет гейты само (#127).
changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.classify.outputs.frontend }}
backend: ${{ steps.classify.outputs.backend }}
integration: ${{ steps.classify.outputs.integration }}
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- id: classify
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
REF: ${{ github.ref }}
run: |
if [ "$REF" = "refs/heads/dev" ]; then
echo "dev: без фильтров, всё true"
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
exit 0
fi
zero=$(printf '%040d' 0)
base="$BEFORE_SHA"
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
# иначе классифицировалась бы вся история.
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|| ! git cat-file -e "$base" 2>/dev/null; then
git fetch -q origin dev
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1")
fi
files=$(git diff --name-only "$base" "$HEAD_SHA")
printf '%s\n' "$files" | head -50
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
{
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|pytest\.ini$)')"
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
} >> "$GITHUB_OUTPUT"
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
# сборки) ПЛЮС оснастка именно этой job. Маркер в кэше пишет только успешный
# прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми
# же входами уже завершилась успешно.
#
# Это НЕ фильтр путей из job `changes` (на dev они отключены намеренно): там
# объём прогона угадывается по путям и «зелёный» начинает значить разное,
# здесь эквивалентность входов доказана хешем.
#
# Свойство, снимающее главный риск: релизный кандидат бампает версию, а
# CARD_VERSION и package.json входят в фингерпринт, поэтому ключи кандидата
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
reuse:
runs-on: ubuntu-latest
outputs:
smoke: ${{ steps.probe.outputs.smoke }}
golden: ${{ steps.probe.outputs.golden }}
performance_smoke: ${{ steps.probe.outputs.performance_smoke }}
backend: ${{ steps.probe.outputs.backend }}
smoke_key: ${{ steps.keys.outputs.smoke }}
golden_key: ${{ steps.keys.outputs.golden }}
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
backend_key: ${{ steps.keys.outputs.backend }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Ключи переиспользования
id: keys
run: |
for job in smoke golden performance_smoke backend; do
key=$(node scripts/gate-reuse.mjs --job="$job")
echo "$job=$key" >> "$GITHUB_OUTPUT"
echo "$job: $key"
done
# lookup-only: маркер только проверяется, но не восстанавливается —
# сохранять его в этой job нечего, она ничего не прогоняла.
- name: Маркер smoke
id: m_smoke
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-smoke-${{ steps.keys.outputs.smoke }}
lookup-only: true
- name: Маркер golden
id: m_golden
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-golden-${{ steps.keys.outputs.golden }}
lookup-only: true
- name: Маркер performance_smoke
id: m_perf
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
lookup-only: true
- name: Маркер backend
id: m_backend
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-backend-${{ steps.keys.outputs.backend }}
lookup-only: true
- name: Что переиспользуем
id: probe
env:
SMOKE: ${{ steps.m_smoke.outputs.cache-hit }}
GOLDEN: ${{ steps.m_golden.outputs.cache-hit }}
PERF: ${{ steps.m_perf.outputs.cache-hit }}
BACKEND: ${{ steps.m_backend.outputs.cache-hit }}
run: |
# Пропуск обязан быть громким: молчаливый skip — тот самый тихий
# успех, который уже дважды стоил нам дня (#171, #207).
waive() {
if [ "$2" = "true" ]; then
echo "$1=true" >> "$GITHUB_OUTPUT"
echo "::notice::$1 не прогоняется: входы побайтово те же, что в предыдущем успешном прогоне (#208)"
echo "- **$1** переиспользована: входы не менялись" >> "$GITHUB_STEP_SUMMARY"
else
echo "$1=false" >> "$GITHUB_OUTPUT"
echo "- $1: прогоняется" >> "$GITHUB_STEP_SUMMARY"
fi
}
echo "### Переиспользование гейтов (#208)" >> "$GITHUB_STEP_SUMMARY"
waive smoke "$SMOKE"
waive golden "$GOLDEN"
waive performance_smoke "$PERF"
waive backend "$BACKEND"
hacs:
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: HACS validation
uses: hacs/action@main
with:
category: integration
hassfest:
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Hassfest validation
uses: home-assistant/actions/hassfest@master
frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
@@ -94,17 +252,30 @@ jobs:
smoke:
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
needs: frontend
needs: [frontend, reuse]
if: needs.reuse.outputs.smoke != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- name: Install Chromium for Playwright
run: npx playwright install --with-deps chromium
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
# внятной ошибкой — тогда флаг вернуть.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install pinned Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
- name: Build a fresh bundle for the smokes
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Smoke suite
@@ -124,25 +295,54 @@ jobs:
exit $fail
- name: Upload smoke logs
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: smoke-logs
path: /tmp/smoke-logs
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
run: |
printf '%s\n' "smoke прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
- uses: actions/cache/save@v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-smoke-${{ needs.reuse.outputs.smoke_key }}
golden:
# Deterministic visual correctness stays in every prerelease gate: it is
# inexpensive and catches a different class of regressions than timings.
needs: frontend
needs: [frontend, reuse]
if: needs.reuse.outputs.golden != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
# внятной ошибкой — тогда флаг вернуть.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install pinned Chromium
run: npx playwright install --with-deps chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
- name: Build the exact source under review
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Capture or verify golden matrix
@@ -157,26 +357,58 @@ jobs:
fi
- name: Upload golden candidates/diffs
if: failure() || steps.golden.outputs.has_baselines == 'false'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: golden-images
path: artifacts/golden
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
run: |
printf '%s\n' "golden прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
- uses: actions/cache/save@v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-golden-${{ needs.reuse.outputs.golden_key }}
performance_smoke:
# Candidate-only catastrophic-regression guard for ordinary pushes and
# prereleases. The expensive same-runner comparison lives in performance.yml.
needs: frontend
needs: [frontend, reuse]
if: needs.reuse.outputs.performance_smoke != 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
# 15 минут не хватало, когда установка браузера шла через apt: замер
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
# job укладывается в те же минуты, что и раньше.
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
# внятной ошибкой — тогда флаг вернуть.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install pinned Chromium
run: npx playwright install --with-deps chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
- name: Build the exact candidate source
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Capture the heaviest Glow state
@@ -187,21 +419,55 @@ jobs:
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
- name: Upload performance smoke report
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: performance-smoke
path: artifacts/performance-smoke
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
run: |
printf '%s\n' "performance_smoke прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
- uses: actions/cache/save@v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
backend:
needs: [changes, reuse]
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
# Browser fixtures are generated by their real ESM factories and then
# validated through the Python CONFIG_SCHEMA/LAYOUT_SCHEMA in the same test.
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with: { node-version: 22 }
- uses: actions/setup-python@v5
- uses: actions/setup-python@v7
with: { python-version: "3.13" }
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
- name: Backend unit tests (pure + HA harness)
run: python -m pytest tests_backend/ -q
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
run: |
printf '%s\n' "backend прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
- uses: actions/cache/save@v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-backend-${{ needs.reuse.outputs.backend_key }}
+65 -12
View File
@@ -38,8 +38,16 @@ task records: problem, scope, acceptance criteria and discussion.
**Status lives in labels:** `S1-new`, `S2-analysis`, `S3-spec`, `S4-spec-review`,
`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`, plus `blocked` on top
of a status and `rejected` on a closed issue. Exactly one `S*` label per open
issue. [GitHub Projects (v2)](https://github.com/users/Matysh/projects/1) is a
human-facing view synchronised from the labels, not the source of truth.
issue. Labels are the whole of it: GitHub Projects is no longer used.
Two shortcuts exist for small work. `small` — the light track: the spec lives in
the issue body and its review is a comment. `trivial` — the short track: no spec
stage at all, `S2-analysis` straight to `S5-ready`, with the AC written into the
issue body first. `trivial` requires a bug confined to one surface with no new UX
contract, no migration, no i18n, no perf or touch impact, at most three checkable
AC, **and expected behaviour already on record** — nothing left to decide. Code
review is never skipped on either track; it is what stands in for testing.
`PROCESS.md` §5 and §5.1 hold the criteria.
An issue filed by an outsider is worked exactly like one of the owner's own, once
the owner has decided to take it. The check sits **at the entrance**, not on every
@@ -146,6 +154,28 @@ arrive. Cycles are counted per stage, so a code review spends its own budget.
Everything else still requires the owner's explicit command: pushing `main`,
creating tags, publishing betas and releases, closing issues.
## Working trees (#115)
One checkout, one `HEAD`: two agents sharing a directory inherit each other's
branch, and twice in one hour a commit landed on someone else's task branch that
way. The layout is therefore fixed:
- **`houseplan-card-src/houseplan-card`** — the author's tree. Task branches live
here; nobody else commits in it. Unfamiliar local changes belong to the author
or the owner — never reset or clean them away.
- **`houseplan-card-src/hp-dev`** — the owner's worktree, permanently on `dev`. For owner-side operations that must not disturb the
author's tree: pushing `dev`, restoring a hook's executable bit, emergencies.
- **The reviewer and the infrastructure agent own no local tree.** The reviewer
runs in CI on a fresh checkout. The infrastructure agent reads via `git show`
and publishes through the GitHub API; it makes no local commits at all, so it
needs no `HEAD` of its own. Its scratch worktrees live outside the repo and are
pruned after use.
A worktree is only usable on the machine that created it: the `.git` file records
an absolute path in that machine's format. One created from a Linux sandbox is
dead on Windows and vice versa — create worktrees on the machine that will use
them, which for `hp-dev` means the owner's.
## Two-agent workflow
**Codex** writes analysis, specs and all product code. **Claude** reviews specs and
@@ -225,10 +255,15 @@ The exchange happens in **issue comments** — there is no local message bus. Ve
format:
```text
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → #… · Document: …
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → in-task | #… · Document: …
```
High blocks. Medium must become its own issue. Low is fixed or waived with a note
High blocks. A Medium finding INSIDE the task's scope is fixed within the task:
with no High findings the verdict is yellow, the author fixes it and the fix
passes another review cycle — no separate issue (owner's decision 2026-08-19,
#202: filing and servicing an issue costs far more than fixing in place). Only
a Medium finding OUTSIDE the scope becomes its own issue — foreign scope is
never patched from this branch. 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.
@@ -290,9 +325,19 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
```
During the implementation cycle only the fast gates run. `smoke`, `golden` and
`performance_smoke` spin up Chromium and belong to the pre-beta run — which is then
mandatory and complete.
During the implementation cycle the fast gates always run. Since 2026-08-14 the
owner's machine also carries Playwright with Chromium (Windows) and a full WSL
environment, which changes one thing (#151): **before moving an issue to
`S7-code-review`, run the smokes named in its AC locally** — `node
demo/smoke_<name>.mjs`. A red smoke that reaches the review costs a cycle; run
locally it costs a minute. Precedent: on #89 a fixture error lived through a
whole review round that a local run would have caught immediately.
The full smoke set, `golden` and `performance_smoke` still belong to the
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
@@ -313,14 +358,22 @@ complete Linux CI artifact; never accept a partial scenario or images merely to
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.
TypeScript and package-lock build inputs. Every browser check must verify it
before trusting a result — benchmarks, golden runs and documentation captures
call `assertFreshDemoBundle` themselves, and smokes get it from `launch()` in
`demo/serve.mjs` (#236). A missing or mismatched fingerprint is a hard failure,
not a warning; `HP_ALLOW_STALE_BUNDLE=1` skips the check for debugging and says
so out loud. A smoke against a stale bundle does not fail cleanly: part of its
assertions go red and part stay green, which reads as a logic defect.
**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`.
an unfinished Validate for the same branch. Gate jobs, matching the actual
`validate.yml` (#191): `docs`, `provenance`, `process-gate`, `hacs`, `hassfest`,
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
went red after the #113 merge.
**"Verified" without a named command and its result is not evidence.**
+6 -7
View File
@@ -18,13 +18,12 @@ requests still belong in [issues](https://github.com/Matysh/houseplan-card/issue
## Backlog and work status
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and the linked
[GitHub Project v2](https://github.com/users/Matysh/projects/1) are the
only active project backlog. Issues own scope and acceptance criteria; Project
v2 owns prioritization and workflow status. Before starting planned work, link
it to an existing issue or create one, add it to the Project, and keep both
surfaces current until the verified result is closed. Design specs and ADRs may
support an issue, but they do not replace it or maintain a separate checklist.
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the only
active backlog. An issue owns scope and acceptance criteria; its **labels** own
priority and workflow status — `PROCESS.md` §9 holds the vocabulary. Before
starting planned work, link it to an existing issue or create one, and keep it
current until the verified result is closed. Design specs and ADRs may support an
issue, but they do not replace it or maintain a separate checklist.
## Five-minute setup
+179 -28
View File
@@ -11,7 +11,7 @@
> его не содержал вовсе.
>
> **Приоритет источников.** Канонический бэклог — GitHub Issues; статус живёт в
> метках, Project v2 остаётся человеческим представлением. При расхождении
> метках и больше нигде: Project v2 не используется. При расхождении
> документации с GitHub побеждает GitHub. При расхождении этого документа с
> `.github/workflows/*.yml` и `scripts/*` побеждает **фактическая автоматизация**:
> она исполняется, а описание — нет. Расхождение при этом не игнорируется, а
@@ -70,7 +70,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
→ S6-in-progress → S7-code-review ⟲ → S8-merged → закрыт при выпуске беты
служебные: blocked (поверх статуса) rejected (закрыт)
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком треке 2
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
```
Переходы `S4-spec-review` и `S7-code-review` выполняются **автоматически**: метка
@@ -85,9 +86,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
### 2.2 Аналитика и оценка
Задача разбирается, продуктовое «да» ещё не дано.
Задача разбирается — и разобранная **сама идёт дальше**. Умолчание изменено
решением владельца 2026-08-14: раньше аналитика ждала подтверждения по каждому
пункту, и большинство ожиданий ничего не меняло — issue в основном описаны
однозначно.
- **Кто:** агент-аналитик готовит, владелец решает.
- **Кто:** агент-аналитик. Владелец не утверждает переход — он правит асинхронно.
- **Чек-лист**, результат — комментарием в issue:
1. дубликаты проверены (ссылки на похожие issue);
2. в скоупе по `docs/SCOPE.md` и `docs/TOUCH-SUPPORT.md`;
@@ -97,10 +101,21 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
5. приоритет **P1/P2/P3**;
6. тип: баг / фича / техдолг;
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
8. лёгкий трек — да/нет по критериям §5.
- **Приоритет и ценность — поля владельца.** Агент предлагает, владелец
утверждает; иначе агенты приоритизируют сами и P1 разрастается.
- **Выход:** «ТЗ в работе» либо «Отклонено» с записанной причиной.
8. трек: обычный / `small` / `trivial` по критериям §5 и §5.1.
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
согласие**, несогласие он выражает правкой меток или комментарием, и это не
останавливает работу. Право отклонить задачу (`rejected`) остаётся за
владельцем на любой стадии.
- **Вопросов владельцу на этом этапе нет.** Единственный класс вопросов, который
вообще задаётся владельцу, — продуктовые (§7.1: что человек видит или делает,
объём видимых изменений), и их место — этап ТЗ, пачкой, с вариантами по
умолчанию и `blocked`. Вопрос, который можно отложить до ТЗ, не задаётся в
аналитике; вопрос, не блокирующий написание ТЗ, не задаётся вовсе — вместо
него в ТЗ пишется блок принятых предположений.
- **Выход:** `S3-spec` — переход выполняет сам аналитик, не дожидаясь ответа.
Либо, при явном конфликте со `SCOPE.md`, — предложение отклонить с причиной:
это единственный случай, когда аналитика останавливается и ждёт владельца.
### 2.3 ТЗ в работе — написание ТЗ
@@ -116,10 +131,14 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
Его задача — не согласиться, а найти, где ТЗ не выполнимо или не проверяемо.
- **Артефакт:** `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, вердикт
зелёный / жёлтый / красный. Лёгкий трек — комментарий в issue.
- **High-находки блокируют.** Medium/Low — либо правятся, либо становятся
отдельными issue со ссылкой; «оставили в тексте ревью» не считается закрытием.
- **High-находки блокируют.** Medium **в скоупе задачи** чинится в текущем
issue: без High это жёлтый вердикт, автор правит ТЗ, фикс проходит повторный
цикл. Medium **вне скоупа** — отдельный issue: чужой скоуп в этой задаче не
правится. «Оставили в тексте ревью» не считается закрытием ни для одной
(решение владельца 2026-08-19, #202: отдельный issue дороже правки на месте).
Low либо правится, либо снимается решением ревьюера с записью.
- **Выход:** «Готово к разработке» либо возврат в «ТЗ в работе» — не более
4 циклов (§4).
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
### 2.5 Готово к разработке (DoR)
@@ -172,9 +191,11 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
коду с явной записью «проверено чтением, не исполнением».
- **High блокируют.** Medium **обязаны** превратиться в issue.
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
Medium **вне скоупа** — отдельный issue (#202).
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
4 циклов (§4).
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
### 2.8 Закрытие после выпуска беты
@@ -195,6 +216,42 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
- **Отклонено:** закрытие с записанной причиной (вне скоупа, дубликат, цена не
оправдана). Тихое закрытие без причины запрещено.
### 2.10 Повторный раунд ревью — объём по дельте
Решение владельца 2026-08-19 (issue #214). Относится и к ревью ТЗ, и к
код-ревью, начиная со второго цикла.
**Предмет повторного раунда — дельта, а не задача целиком.** Раньше объём
разбора не был оговорён, промпт ревьюера для всех раундов был одинаковым, и
повторный цикл заново выводил продуктовую рамку и перепроверял AC, которых
правка не касалась: r2 по #150 стоил полного прогона конвейера ради одной
строки в тестовой фикстуре.
Порядок:
1. найти вердикт предыдущего раунда и **SHA, на котором он получен**; SHA в
вердикте не назван — это находка;
2. объявить дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла ТЗ либо тела
issue для этапа ТЗ;
3. по каждой находке предыдущего раунда показать, **чем именно она закрыта** —
строкой кода или текста, а не заявлением автора;
4. заново проверять только те AC, чьё доказательство дельта задевает;
5. **раздел «Унаследовано из r<N−1>»** обязателен: что принято без повторной
проверки, со ссылкой на документ того раунда и SHA. Без перечня сокращение
превращается в молчаливое доверие.
Дешёвые гейты (`typecheck`, `test`, `build` со сверкой копий бандла) гоняются в
каждом раунде: код изменился, а стоят они минуты. Тяжёлые — по дельте (§10.2).
**Разбор остаётся полным**, если дельта не локальна: ребейз на ушедший вперёд
`dev` (после ребейза это другой код, §7.2), смена контракта поведения, задета
новая подсистема, либо объём дельты сопоставим с исходной задачей.
Сокращается объём **разбора, а не строгость**: правка по замечанию способна
сломать AC, который предыдущий раунд признал выполненным — так появилась
регрессия #102. Граница не «только находки», а «находки плюс всё, до чего
дотягивается дельта».
---
## 3. Правила
@@ -216,8 +273,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
ревью-гейт.
7. **Ревью возвращает не более 4 раз.** Пятый заход — решение владельца: разделить,
отклонить или арбитраж (§4).
8. **High блокирует. Medium становится issue.** Low либо правится, либо снимается
решением ревьюера с записью в документе.
8. **High блокирует. Medium в скоупе чинится в текущем issue** (без High —
жёлтый вердикт и повторный цикл); Medium вне скоупа становится отдельным
issue (#202). Low либо правится, либо снимается решением ревьюера с записью
в документе.
9. **Скоуп не расширяется.** Всё найденное вне ТЗ — новый issue, а не попутная
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
@@ -248,11 +307,24 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
## 4. Лимит циклов ревью: 4
Оба ревью-гейта возвращают задачу на правки не более **4 раз**. Счётчик виден в
имени документа: `-r1` … `-r4`; на четвёртом заходе ставится метка `review-4`.
Оба ревью-гейта возвращают задачу на правки не более **4 раз**.
- **Что считается циклом:** отправка на ревью → вердикт с блокирующими находками
→ возврат. Уточняющий вопрос без вердикта циклом не считается.
- **Зелёный вердикт цикла не образует** и бюджет не тратит (решение владельца
2026-08-20, issue #227): он ничего не вернул на правки. Практический случай —
зелёное ревью, слияние которого не удалось: конвейер сам предписывает ребейз и
возврат метки, и этот заход не должен наказываться. Раньше счётчик считал все
вердикты подряд, и на #225 последовательность жёлтый → зелёный → ребейз дала
`review-4` на задаче с зелёным ревью и зелёным CI.
- **Заход и цикл — разные величины.** Заход — сколько раз ревью отработало; он
виден в имени документа (`-r1`, `-r2`, …) и нужен, чтобы два документа не
затёрли друг друга. Цикл — единица бюджета §4. Заходов законно бывает больше,
чем циклов, поэтому порог проверки 7 в `scripts/process-gate.mjs` выше лимита
циклов (шесть документов = четыре цикла плюс два ребейза).
- Метка `review-4` ставится, когда исчерпан **бюджет циклов**; конвейер снимать
её не вправе — это решение владельца. Если бюджет пересчитан и оказался ниже
лимита, конвейер сообщает пересчёт, но метку не трогает.
- **Исчерпание лимита — не «пятая попытка», а разбор.** Задача уходит владельцу,
решение одно из трёх:
1. **разделить** — issue закрывается как «заменён», вместо него 2–3 меньших с
@@ -294,6 +366,42 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
модуль) — метка `small` снимается, issue возвращается в `S3-spec` и получает
нормальный файл ТЗ. Это не провал, это ранняя диагностика.
### 5.1 Короткий трек (метка `trivial`)
Решение владельца 2026-08-13, issue #128. Лёгкий трек делает ТЗ дешёвым; короткий
обходится без него совсем.
**Маршрут:** `S1-new` → `S2-analysis` → `S5-ready` → `S6-in-progress` →
`S7-code-review` → `S8-merged`. Стадии `S3-spec` и `S4-spec-review` пропускаются.
`S2-analysis` остаётся: это комментарий, а не прогон CI, и именно там владелец
решает приоритет и ценность. AC пишет автор в теле issue при переводе в
`S5-ready` — до перехода, иначе ревьюеру нечего будет сверять.
**Критерии, все обязательны:**
- тип `bug`;
- правка ограничена одной поверхностью, нового UX-контракта нет;
- нет миграции конфига, новых ключей i18n, влияния на перф и touch;
- AC выражаются тремя проверяемыми утверждениями или меньше;
- **ожидаемое поведение уже зафиксировано** — в `docs/USER-GUIDE.ru.md`, в
каноническом документе подсистемы либо однозначно в самом отчёте. Решать нечего.
Если есть что решать, это `S3-spec`, и никакая экономия этого не отменяет.
Метка ставится в `S2-analysis` вместе с остальными оценками, одним комментарием,
где владелец утверждает и приоритет.
**Что не упрощается:** issue, оценка, статусы, трейлеры, changelog и **код-ревью**.
Лимит циклов код-ревью — 2, как на лёгком треке.
Если по ходу выясняется, что критерий нарушен, метка снимается и issue уходит в
`S3-spec` за нормальным ТЗ. Как и на лёгком треке, это не провал, а ранняя
диагностика.
**Чем этот трек опасен.** Он убирает единственное место, где решение проверялось
до написания кода. Признак «решать нечего» держит всю конструкцию, и его нельзя
подтверждать ощущением — только ссылкой на уже зафиксированное поведение.
---
## 6. Роли
@@ -393,8 +501,12 @@ issue #NN
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · цикл r<N>/<лимит> ·
High: N · Medium: N → #… · Документ: docs/reviews/…`
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · заход r<N> ·
блокирующих циклов K/<лимит> · High: N · Medium: N → в задаче | #… ·
Документ: docs/reviews/…`
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору.
Заход — номер прогона ревью, K — израсходованный бюджет §4: зелёные вердикты
его не тратят, поэтому заход и K расходятся, #227)
- **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>`
**Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору;
@@ -428,9 +540,30 @@ npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend
&& cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
node demo/smoke_<целевые>.mjs
npm run golden:verify # если менялся визуал
node scripts/check-docs.mjs # если менялся src/**
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
```
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла, а при
любом diff'е по `src/**` — ещё и `node scripts/check-docs.mjs`. По
необходимости, определяемой diff'ом и AC: браузерные смоки (их 163 — прогон всех
уместен только когда задача задевает всё), `golden:verify` при изменении видимого
результата, `pytest tests_backend` при правках в Python, performance-профили при
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
всегда попадает, и решать нечего. Цена пропуска измерена: скриншоты не
пересняли в #230 и #234, и `dev` стоял с красным job `docs`, пока это не нашли
при следующей задаче (#237). Пересъёмка — `npm run build && node
demo/docs/capture.mjs`, коммит вместе с задачей.
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
пропуском.
**Гейт беты** (условие закрытия issue): CI Validate зелёный на точном SHA тега.
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
@@ -444,9 +577,12 @@ Performance зелёные на точном SHA; статусов issue не к
## 9. Метки — канонический статус
Статус читается из меток: их видно в списке issue и их читает любой токен с
доступом к Issues, в отличие от Project v2, который требует отдельного скоупа.
Project v2 остаётся человеческим представлением и синхронизируется по меткам.
Статус читается из меток: их видно в списке issue, их читает любой токен с
доступом к Issues, и по ним же работает конвейер — смена метки порождает событие
(§10.4). **Project v2 не используется** (решение владельца 2026-08-14): второе
представление статуса рядом с метками требовало отдельного скоупа токена,
синхронизации и внимания, а давало вид доски. Два источника одного факта
расходятся — это уже случалось с колонкой «Статус ТЗ» в `docs/specs/README.md`.
**Имена меток английские** (решение владельца 2026-08-12). Русские имена в этом
документе были только на бумаге; репозиторий с самого начала жил на английских.
@@ -464,8 +600,8 @@ Project v2 остаётся человеческим представление
| `blocked` | Ждём внешнего или владельца, **поверх** статусной метки |
| `rejected` | Отклонено, issue закрыт |
Модификаторы: `small` (лёгкий трек, сложность ≤3), `hotfix`, `process`,
`review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
Модификаторы: `small` (лёгкий трек, сложность ≤3), `trivial` (короткий трек,
§5.1), `hotfix`, `process`, `review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
ортогональны процессу.
@@ -523,6 +659,13 @@ Project v2 остаётся человеческим представление
считается от `merge-base` с `origin/dev`, а не от начала истории — иначе в него
попали бы все нарушения, совершённые до появления гейта.
При возврате `main` в `dev` диапазон merge-коммита содержит второй родитель —
уже опубликованные в `main` коммиты с закрытыми issue. Для destination `dev`
общий скрипт pre-push/CI исключает только SHA, доказанно достижимые из
`origin/main`; сам merge и новые post-merge коммиты остаются под всеми
проверками. На `main`, beta/issue-ветки и обычный push в `dev` это исключение
не распространяется (issue #155).
Проверка статуса issue требует `gh`, поэтому при его отсутствии хук печатает
предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук,
который не работает в самолёте, отключают целиком, а строгий проход всё равно
@@ -560,7 +703,7 @@ Project v2 остаётся человеческим представление
{`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`}; закрытый,
недоступный или помеченный `blocked` — отказ (**fail closed**).
Две оговорки к проверке 8, обе выяснились при реализации.
Три оговорки к проверке 8 выяснились при реализации.
**`S8-merged` входит в множество**, хотя по смыслу задача уже принята. Причина
механическая: конвейер (§10.4) сливает ветку в `dev` **раньше**, чем ставит метку,
@@ -573,6 +716,13 @@ Validate стартует от этого push и успевает прочит
документ ревью: он ложится в ветку задачи, пока та в `S4-spec-review` или
`S7-code-review`, то есть заведомо вне рабочего множества.
**При продвижении в `main` не перепроверяются коммиты, уже достижимые из
prerelease-тега.** После выпуска беты их issue по §2.8 должны быть закрыты, а
stable fast-forward снова включает эти коммиты в диапазон `old-main..candidate`.
Pre-push передаёт целевую remote ref через `--target-ref`, а Validate — через
`TARGET_REF`; оба исключают только уже опубликованную prerelease-историю. Любой
post-beta коммит остаётся в проверке и по закрытому issue отклоняется fail-closed.
Не реализовано и остаётся долгом:
9. `npm run release:prerelease -- --issues=…` не проверяет, есть ли у issue
@@ -607,8 +757,8 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
```
Ревьюер — `anthropics/claude-code-action`. Он читает `docs/SCOPE.md`, `AGENTS.md`,
этот документ и тело issue, публикует разбор комментарием, заводит issue на каждую
Medium-находку, кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
этот документ и тело issue, публикует разбор комментарием, заводит issue на Medium-находки
вне скоупа задачи (#202), кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
структурированным JSON. **Метку переставляет отдельный детерминированный шаг по
вердикту, а не модель.**
@@ -732,7 +882,8 @@ Golden, браузерные смоки, performance и полный HA-харн
- принятие golden-эталонов ради зелёного CI или по частичному артефакту;
- закрытие issue до выпуска беты с зелёным CI;
- переоткрытие закрытого issue вместо нового бага;
- Medium-находки, оставленные как TODO в документе ревью;
- Medium-находки, оставленные как TODO в документе ревью: в скоупе — чинятся
в текущем issue, вне скоупа — становятся отдельным (#202);
- **параллельные бэклоги** в файлах (`BACKLOG-*.md`, «планы» в docs);
- ревью-документы вне репозитория;
- попутные правки «раз уж я здесь»;
+103 -314
View File
@@ -1,368 +1,157 @@
# 🏠 House Plan — interactive floor plan card for Home Assistant
# 🏠 House Plan — a live home map for Home Assistant
[![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases)
[![GitHub stars](https://img.shields.io/github/stars/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/stargazers)
[![CI](https://github.com/Matysh/houseplan-card/actions/workflows/validate.yml/badge.svg)](https://github.com/Matysh/houseplan-card/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Live demo](https://img.shields.io/badge/demo-try_it_live-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
[![Telegram chat](https://img.shields.io/badge/Telegram-chat-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
**Turn Home Assistant into a live, interactive map of your home.** Upload or draw
a floor plan, outline the rooms with your mouse — and every smart device appears
in its real place: live states, tap-to-toggle lights, temperature and humidity per
room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wall
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
right on your Lovelace dashboard.
📘 **[Full user guide](docs/USER-GUIDE.md)** · 🇷🇺 **[Русский](README.ru.md)** · 🗂 **[Project issues](https://github.com/Matysh/houseplan-card/issues)**
> **Use a desktop computer to edit plans.** View and kiosk are fully supported
> on phones and tablets. The editors are designed primarily for a desktop
> browser with a mouse and keyboard; individual editing operations on touch
> devices may be awkward, limited, or unavailable.
<!-- docs-section: overview -->
![Interactive Home Assistant floor plan: live rooms, devices, lights and climate on a real floorplan card](docs/images/demo.gif)
## Your whole home at a glance
> ### 🚀 Try it live — no install needed
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — a real Home Assistant
> with a ready-made plan. Log in as **`demo`** / **`demo`** and click anything:
> toggle lights, open the editors, break things. The stand resets itself to a
> pristine state every hour.
House Plan turns Home Assistant into a live map of your home. Upload a plan or
draw rooms directly on the dashboard, bind them to Home Assistant areas, and
the area's devices appear automatically. You can immediately see where a light
is on, a door is open, a room is too cold, Zigbee signal is weak, or a leak
sensor has fired.
🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan)
![Synthetic home in View mode with rooms, devices, light and climate](docs/images/01-view-desktop.png)
**Feature highlights**
Setup is entirely graphical: no floor-plan YAML, Inkscape, or external editor.
Plan data and device positions live on the Home Assistant server and stay in
sync across screens.
- ♾️ **An infinite canvas** — there is no "plan size" and no edge to run
past: draw and place devices anywhere, pan at any zoom, zoom out to see
everything, and let one tap fit the whole plan back on screen.
- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
virtual walls and a visual decor layer, all drawn with clicks; room resize
by dragging walls, with live lengths and areas as you drag; smart
alignment guides and a live ruler in real meters/feet.
- 🖼 **A backdrop you can move and scale** — drag the floor-plan picture into
place and pull a corner to size it, with its real size in metres shown as
you drag, so the drawing and the photo of your plan finally line up.
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
whole groups of lights (works for dumb switches and stateless remotes too).
- 🌒 **“Light sources” fill** — a dark house where every lit lamp lights exactly
the floor it can see: through doorways and open boundaries, stopped by walls,
columns and partitions, which cast real shadows.
- ☀️ **The sun on the plan** — set the compass and the backdrop lives with
the day (white noon → golden hour → deep night), while windows on exterior
walls cast real wedges of sunlight into the rooms; optional cloud cover
from a weather entity.
- 🪟 **Curtains and blinds open on a tap** — one action opens, closes or
stops a cover, and the icon itself morphs between open and closed while a
soft ring pulses as it travels.
- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
comfort-range temperature fills, per-room signal heatmap.
- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
explicit button, never an accidental tap.
- 📺 **Kiosk mode** for wall tablets and TVs: fullscreen, swipe between floors,
auto-carousel, per-screen icon sizes.
- 🤖 **Live robot vacuums** — the dock marker stays put while a round puck
drives the plan in real time, pouring its path out from under itself;
current and previous cleanup runs are recorded server-side. Calibration is
one click (rooms matched by name) or a drag-and-stretch overlay. A diagnostic
source picker also covers registry-less map cameras without silently
rebinding broken sources. Works with Xiaomi Cloud Map Extractor, Tasshack
dreame-vacuum and Valetudo.
- 🔔 New devices appear automatically with a red “new” dot; the layout is stored
**server-side** — one shared plan for every user and screen, synced live.
> **Edit on a desktop computer.** View and kiosk are fully supported on phones
> and tablets. The editors are designed primarily for a mouse and keyboard;
> individual touch editing operations may be awkward or unavailable. See the
> exact [touch support contract](docs/TOUCH-SUPPORT.md).
---
<!-- docs-section: features -->
## What it is and why
## What House Plan provides
House Plan shows your smart home the way it actually looks — on a floor plan. Instead of long lists of entities, you see rooms and devices in their real places: where the leak is, what the temperature is in the kids' room, whether the light is on in the hallway, whether the gate is open.
- **Live state and safe actions.** Lights and other safe devices can toggle from
the plan; a lock cannot be opened by an accidental plan tap.
- **Three built-in editors.** Plan creates rooms, walls and openings; Device
places and configures markers; Background adds lines, labels and furniture.
- **Area-aware rooms.** New devices appear automatically, while room cards can
show temperature, humidity, light state and average LQI.
- **Light and environment.** Room fills, lamp Glow, wall shadows, a day-cycle
backdrop and sunlight through windows.
- **Doors, windows, gates and vacuums.** Openings follow real contacts and locks;
a robot can show its position, dock and travelled path.
- **Several floors and screens.** Space tabs, swipe navigation, local viewport,
and a separate initial floor for each card.
- **Wall-display kiosk.** A plan-only view with fullscreen navigation and icon
sizes saved for that display.
This is convenient when:
![The same synthetic home in touch View mode](docs/images/02-view-touch.png)
- you have many devices and lists are awkward to use;
- you need to grasp the state of the house "at a glance";
- you want to give access to family members — anyone can figure out a picture;
- you want a beautiful overview screen for a wall-mounted tablet.
<!-- docs-section: first-run -->
The integration consists of two parts that are installed together:
## Your first working room
- **the Lovelace card** `houseplan-card` — the interactive plan itself;
- **the server-side component** — stores the room markup and icon positions in Home Assistant, so the plan is identical in all browsers and on all devices.
1. Install the integration and add the card to a dashboard.
2. Create the first **space**: upload SVG/PNG/JPG/WebP, reuse an uploaded image,
or choose no image and draw the plan by hand.
3. In Plan, select **Room outline**, place vertices, and click the first point to
close the outline.
4. Name the room and bind it to a Home Assistant area. Use “No area” for a room
that has no devices.
5. Open Device: devices from the bound area are already placed; drag their
markers to the correct positions.
6. Optionally use Background for lines, text and furniture.
7. Return to View. The plan now displays live state and accepts safe actions.
---
![Creating the first space](docs/images/03-space-create.png)
## How it differs from alternatives
![Closing a room outline on its first point](docs/images/04-room-contour-close.png)
A house plan in Home Assistant is usually built with `picture-elements`,
`ha-floorplan`, or newer GUI cards that draw walls and furniture in the
dashboard. Those either lock you into YAML/SVG, or store the plan in the
Lovelace card config. House Plan is a **shared live map** backed by a Home
Assistant integration:
![A selected partition and the Plan context tray](docs/images/05-plan-context-tray.png)
| | House Plan | picture-elements / ha-floorplan | GUI draw cards (e.g. easy-floorplan) |
|---|---|---|---|
| **Setup** | Entirely through the UI, with the mouse | Manual YAML / Inkscape SVG | In-card drawing of walls & furniture |
| **Adding devices** | Automatic, by HA **area** | You type every entity by hand | Place entities by hand on the drawing |
| **Icon coordinates** | Drag with the mouse | Count pixels into YAML | Drag on the canvas |
| **Room markup** | Built-in outline editor bound to areas | External SVG editor | Draw walls yourself (furniture CAD) |
| **Storage** | On the HA server (`.storage`, shared, multi-client) | In the dashboard YAML | In the card / dashboard YAML |
| **Overlays** | Glow, climate, LQI, sun, vacuums, kiosk | Whatever you script in SVG/CSS | Varies by card |
| **Zoom** | Smooth vector zoom | Usually a fixed image | SVG / virtual canvas |
![Device settings with binding provenance and the exact action result](docs/images/06-device-editor.png)
**One sentence:** House Plan is the shared, area-aware live map of your home —
not a general-purpose CAD package. Its Background editor covers practical
decor, labels and furniture; if you need unrestricted architectural drafting,
a draw-centric tool may fit better. With a plan and HA areas, House Plan keeps
every tablet on the same live layout.
![Live presentation preview for the same device](docs/images/06-device-display-preview.png)
Key advantages in short:
Every workflow and edge case is in the [full user guide](docs/USER-GUIDE.md).
The [Background editor contract](docs/DECOR-EDITOR.md) and
[vacuum guide](docs/VACUUM.md) are the authorities for those subsystems.
- **No code at all.** Everything — spaces, rooms, devices — is configured with clicks.
- **Automatic device placement.** Outline a room and bind it to a Home Assistant area — the devices of that area appear on the plan by themselves.
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
a light is shining, a socket is powering, a fan is spinning, a vacuum is
cleaning, a radiator valve is actually heating (not merely enabled). For climate integrations,
a reported work action is authoritative; when an integration exposes only its enabled HVAC mode,
that mode is the best available fallback. Orange = open / unlocked.
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
A translucent icon = unavailable. Dark = idle.
- **A coherent visual Background editor.** Draw lines/shapes, place labels and
furniture, edit physical styles and transform every object with the same
selection model. The plan image has its own move/resize/rotate tool, numeric
properties and shared Undo/Redo.
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
---
## Wall tablet / TV (kiosk mode)
Add the card to a dedicated dashboard with a **panel view** and set `kiosk: true`
(or tick "Wall device (kiosk) mode" in the card editor):
```yaml
type: custom:houseplan-card
kiosk: true
cycle: 0 # seconds between auto space switches, 0 = off (nice for TVs)
```
No header, no editors — just the live plan. Swipe to change floors (at 1:1),
pinch to zoom, double-tap to reset. Long-press an empty spot for 3 seconds to
tune icon and text sizes for THIS screen (saved per device). To hide Home
Assistant's own header use the companion app's kiosk settings or the
[kiosk-mode](https://github.com/NemesisRE/kiosk-mode) plugin.
<!-- docs-section: installation -->
## Installation
One click if you already run HACS:
### HACS
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
[![Open the repository in HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
1. In HACS open **⋮ → Custom repositories**.
2. Add `https://github.com/Matysh/houseplan-card` as an **Integration**.
3. Install House Plan and restart Home Assistant.
4. Open **Settings → Devices & services → Add integration → House Plan**.
### Via HACS (recommended)
The card is registered automatically. If you manage Lovelace resources
manually, use the URL served by the integration:
1. Open **HACS → menu (⋮) → Custom repositories**.
2. Paste the URL of this repository, set the category to **Integration**, and click **Add**.
3. Find **House Plan** in the list, install it and **restart Home Assistant**.
4. Go to **Settings → Devices & Services → Add integration** and select **House Plan**.
```yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
```
The card is registered automatically — no need to add a Lovelace resource manually.
Do not use the on-disk path inside `custom_components`; Home Assistant does not
serve that path as a JavaScript module.
> **Card doesn't load (`Custom element doesn't exist: houseplan-card`) or you manage Lovelace
> resources in YAML?** Add the resource manually pointing at the URL the integration *serves*:
>
> ```yaml
> resources:
> - url: /houseplan_files/houseplan-card.js
> type: module
> ```
>
> Do **not** use `/custom_components/houseplan/frontend/houseplan-card.js` — that is the file
> on disk, which Home Assistant does not serve over HTTP (you'll get a `text/plain` MIME error
> and the element never registers). The correct, integration-served URL is
> `/houseplan_files/houseplan-card.js`. Both cards (`houseplan-card` and
> `houseplan-space-card`) ship in that one file — no separate resource is needed.
### Manual installation
### Manually
Copy `custom_components/houseplan` to `config/custom_components`, restart Home
Assistant, and add the House Plan integration.
1. Copy the `custom_components/houseplan` folder into the `config/custom_components` directory of your Home Assistant.
2. Restart Home Assistant.
3. Add the integration: **Settings → Devices & Services → Add integration → House Plan**.
### Add the card
### Adding a plan screen
Create a new dashboard tab (a "Panel" view works best) and add the card:
Create a dashboard view (Panel works best) and add the card in the UI or as:
```yaml
type: custom:houseplan-card
title: House plan
```
Nothing else needs to be specified — everything else is configured right on the screen.
Different screens may start on different spaces:
---
```yaml
type: custom:houseplan-card
default_floor: ground
```
## How to use
All cards share server-side rooms and coordinates. Current mode, viewport and
selected space remain local to the screen. Revision checks and live sync cover
concurrent clients, but avoid editing the same object in two browsers at once.
### Step 1. Add a space (floor)
## Detailed documentation
On first open the plan is still empty — House Plan immediately offers to create the first space.
If your Home Assistant already has **floors** configured, a wizard offers to create a space
for each floor (names prefilled, a plan image is asked for one by one; any floor can be skipped).
- [Full user guide](docs/USER-GUIDE.md)
- [Mouse/touch/keyboard matrix](docs/USER-GUIDE.md#6-navigation-zoom-and-input)
- [Plan tools](docs/USER-GUIDE.md#plan-tools-at-a-glance)
- [Background editor](docs/DECOR-EDITOR.md)
- [Robot vacuums](docs/VACUUM.md)
- [Touch support](docs/TOUCH-SUPPORT.md)
![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
<!-- docs-section: support -->
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions by default and can be moved, resized or rotated at any time in the Background editor.
## Support and feedback
![Space creation dialog](docs/images/03-space-dialog.png)
- Questions and plan examples: [Telegram @ha_houseplan](https://t.me/ha_houseplan).
- Bugs and proposals: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues).
- Before reporting, update House Plan, restart HA and hard-refresh the page.
Include the version, browser, logs and reproduction steps; private entity IDs
may be replaced with fictional ones.
> 💡 You can draw the background in any floor planner (for example, REMPLANNER) or photograph a paper plan. SVG works best — it stays crisp when zoomed in.
Documentation screenshots are produced by the reproducible
`npm run build && node demo/docs/capture.mjs` command using synthetic data only. Scenario version,
source fingerprint and every image hash are recorded in the
[screenshot index](docs/images/screenshots.json).
Later you can add as many spaces as you like (floors, yard, garage) with the **+** button next to the tabs.
### Step 2. Outline the rooms
After the first space is added, the card switches to the **Plan** tab by itself. The card has three mode tabs in the header — **View** (default: display and device control only, nothing can be moved or edited), **Plan** (rooms, openings, labels, space settings) and **Devices** (placing and configuring markers); the edit tabs are shown to administrators. In Plan, click grid points, connecting them with lines, and close the room outline by clicking the first point.
As soon as the outline is closed, the room-save dialog appears. Here you need to **bind the room to a Home Assistant area** — this is exactly what enables the automation. For utility rooms with no devices (hall, sauna) there is a **"No area"** button.
![Marking up a room and saving it](docs/images/05-room-dialog.png)
While drawing, a ruler follows the cursor showing the current segment's real length (metres, or feet + inches on an imperial Home Assistant). The scale is set per space — the **"Scale (grid cell size)"** field in the space dialog says how many centimetres one grid cell represents (default 5 cm).
Rooms may not overlap: a click strictly inside an existing room, or an outline that would swallow one, is refused. Two more tools help you reshape the plan later:
- **Merge** — click a room, then a neighbour that shares a wall; they fuse into one. A dialog picks which name and area survive.
- **Split** — click a room, then two points on its walls; the chord cuts it in two. The bigger part stays the room it was (name, area, devices); the smaller one asks for a new name and area.
### Doors, windows, gates and locks
In markup mode the **"Opening"** tool places doors, windows and gates: click next to a wall and the
opening snaps onto it. Pick the type, the **length in real centimetres** (defaults: door 90 cm,
window 120 cm, gate 300 cm), an open/close sensor and — for doors and gates — a **lock entity**.
With a sensor bound, the plan comes alive: the door leaf swings on its hinge and the swing arc
draws itself in as the real door opens; a window opens its two casements. While open, the moving
parts take an accent colour. A gate keeps a 3–4 m opening compact on the plan: two half-width
leaves open only 10° outwards, without a full-width swing arc, while contact, lock and light
passage work exactly like a door. A door or gate with a lock shows a padlock badge next to it — green when
locked, orange when unlocked. For safety the lock can **not** be toggled from the plan; a click
on the opening shows a status card with both states instead.
Openings are easy to adjust later: hovering one highlights it, you can **drag it along the
walls** (it slides around corners too), and a **double click opens its properties**.
### Step 3. Devices appear by themselves
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Hidden and disabled"**: user-hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light. A device disabled in Home Assistant appears there as a labelled grey service ghost and is excluded from all plan data/actions until it is enabled in HA again.
From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
![Device card on click](docs/images/08-info.png)
### Step 4. Zoom
The mouse wheel or the **- / ⊹ / +** buttons zoom the plan in and out; on a touch screen the two-finger pinch works. Zoomed out you see the whole plan, zoomed in you see the details, and everything stays crisp. The zoom level is remembered separately for each space.
![Zoomed-in plan — everything stays crisp](docs/images/09-zoom.png)
### Step 5. Put the icons in their places
Switch to the **Devices** tab to arrange icons: drag them with the mouse, click one to open its editor. In **View** mode nothing can be moved — panning the map never displaces a sensor (a top user request). Positions are saved on the server and are identical in all browsers and devices. The **↺** button restores the automatic layout.
![Dragging icons — available at all times](docs/images/06-edit.png)
### Tap actions: control devices from the plan
By default a tap on an icon opens its info card. A device can instead use the
universal **Toggle state** action. Its editor shows the exact entity or configured
group, the current state and what the next tap will do; when nothing can be toggled,
it says so and the tap is a quiet no-op rather than an unexpected info-card fallback.
Lights keep their convenient toggle default. Covers and valves use open/close/stop
semantics automatically, while locks, alarm panels and secure garage/door/gate covers
remain blocked. An exact entity binding never falls through to a sibling switch, and
temporarily unavailable group members are skipped without erasing the configuration.
A **long press** still opens the info card and right-click still opens HA more-info.
### Icon rules
Which MDI icon a device gets is decided by **icon rules** — editable right in the card
(the ⬡ button in the header): an ordered list of “name pattern → icon” regexes with a
live test field, bilingual defaults (EN/RU) and a one-click reset. When no rule
matches, the entity *device class* decides (thermometer for temperature sensors, etc.).
### Step 6. Adding your own devices manually
You can also place a **single entity** (not just a whole device): start typing in the binding search and individual entities appear next to devices — handy when one device exposes several values (e.g. temperature and humidity) and you want each as its own icon.
Not everything has to be left to the automation. With the **+** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
To represent a dumb physical lamp controlled by a smart relay, place a virtual
point where the lamp really is and set **Light source → Always**. Manual colour,
brightness and radius stay available even though the point has no HA entity.
Then open the relay and add that plan source under **Controls other light
sources**. The relay continues to show the aggregate working state, while Glow,
room fill and statistics belong to the lamp's position. An unlinked passive
Always source is deliberately constant-on. With several own `light.*`/`switch.*`
entities, Always also offers a leading-entity selector; a missing saved choice
is warned about and retained while a deterministic fallback is used.
The same dialog controls how the device looks on the plan. **Display** switches between the
icon badge, an animated **presence ripple** (pulsing rings while the entity is active, a faint
dot when idle — great for motion sensors) or both, with a per-device ring colour and size. The
**icon size** (×0.5–3) and **rotation** are also per-device, so a wall valve can be small and
turned the way it is mounted.
![Adding a device manually](docs/images/07-marker-dialog.png)
### Styling the plan with card-mod (advanced, unsupported)
The card ships finished and has no CSS field of its own — but if you already run [card-mod](https://github.com/thomasloven/lovelace-card-mod), every object on the plan now carries a stable hook you can aim at: `data-hp="device"` (plus `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. We promise not to rename them; we do not ship card-mod, do not support it, and are not responsible for what your CSS does to the card. The full table, the examples and the limits are in **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
---
## Uninstalling
1. Remove the card (or the tab with the plan) from the dashboard.
2. **Settings → Devices & Services → House Plan → Delete** the integration entry.
3. Remove the integration from **HACS** (or delete the `custom_components/houseplan` folder if installed manually) and restart Home Assistant.
4. Optionally delete the saved plan data: the `config/houseplan/` files (backgrounds and attachments) and the `houseplan.config` / `houseplan.layout` entries in the `config/.storage` directory.
---
## Getting help & sharing your plan
- 💬 **[Telegram chat — @ha_houseplan](https://t.me/ha_houseplan)** — questions,
setup help, feature ideas, and screenshots of your plans. The fastest way to
reach the author and other users.
- 🐞 [GitHub issues](https://github.com/Matysh/houseplan-card/issues) — bug
reports and feature requests (please attach your House Plan version).
- 💡 [GitHub discussions](https://github.com/Matysh/houseplan-card/discussions) —
longer-form ideas.
- 📜 [Changelog](docs/CHANGELOG.md) — what changed in every version
([на русском](docs/CHANGELOG.ru.md)).
When reporting a problem, the version number helps a lot: it is shown in the
browser console on load (`HOUSEPLAN-CARD vX.Y.Z`) and in **Settings → Devices &
Services → House Plan**.
---
## Frequently asked questions
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. Open the device editor and press **"Hidden and disabled"**: a blue ghost is user-hidden and can be shown; a grey disabled ghost must first be enabled in Home Assistant.
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
**Is the data stored in the cloud?** No. Everything is stored locally in your Home Assistant.
---
<p align="center"><sub>Screenshots were taken on a real Home Assistant configuration.</sub></p>
License: [MIT](LICENSE).
+107 -297
View File
@@ -1,351 +1,161 @@
# 🏠 House Plan — интерактивный поэтажный план дома для Home Assistant
# 🏠 House Plan — живой план дома для Home Assistant
[![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases)
[![GitHub stars](https://img.shields.io/github/stars/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/stargazers)
[![CI](https://github.com/Matysh/houseplan-card/actions/workflows/validate.yml/badge.svg)](https://github.com/Matysh/houseplan-card/actions)
[![Live demo](https://img.shields.io/badge/демо-попробовать-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
[![Telegram chat](https://img.shields.io/badge/Telegram-чат-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · 🗂 **[Беклог проекта](https://github.com/users/Matysh/projects/1)**
📘 **[Полное руководство](docs/USER-GUIDE.ru.md)** · 🇬🇧 **[English](README.md)** · 🗂 **[Задачи проекта](https://github.com/Matysh/houseplan-card/issues)**
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
своих местах: живые состояния, свет по клику, температура и влажность по
комнатам, карта Zigbee-сигнала, светящиеся пятна ламп и полноэкранный
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
редакторов — весь план настраивается прямо на дашборде.
<!-- docs-section: overview -->
> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
> операции могут быть менее удобны, работать ограниченно или отсутствовать.
## Дом целиком — одним взглядом
![Интерактивный план дома для Home Assistant: комнаты, устройства, свет и климат на реальном поэтажном плане](docs/images/demo.gif)
House Plan превращает Home Assistant в живую карту дома. Загрузите изображение
плана или нарисуйте комнаты прямо на дашборде, свяжите их с зонами Home
Assistant — и устройства появятся на плане автоматически. Сразу видно, где
горит свет, открыта дверь, слишком холодно, слабый Zigbee-сигнал или сработал
датчик протечки.
> ### 🚀 Попробовать вживую — без установки
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — настоящий Home
> Assistant с готовым планом. Вход **`demo`** / **`demo`**, можно нажимать всё:
> включать свет, открывать редакторы, ломать что угодно. Каждый час стенд сам
> возвращается в исходное состояние.
![Синтетический дом в режиме просмотра: комнаты, устройства, свет и климат](docs/images/01-view-desktop.png)
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
Настройка выполняется в графическом интерфейсе: без YAML-разметки, Inkscape и
внешнего редактора плана. Данные плана и расположение устройств хранятся на
сервере Home Assistant и синхронизируются между экранами.
**Главное**
> **Редактируйте на компьютере.** Режим просмотра и киоск полноценно работают
> на телефонах и планшетах. Редакторы рассчитаны прежде всего на мышь и
> клавиатуру; на touch отдельные операции могут быть неудобны или недоступны.
> Подробный контракт: [поддержка touch](docs/TOUCH-SUPPORT.md).
- ♾️ **Бесконечный холст** — нет «размера плана» и нет края, за который
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
план обратно в экран.
- 🖱 **Редакторы прямо в карточке** — комнаты, двери, окна и ворота, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
линейка в реальных метрах.
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
освещает ровно тот пол, который видит: через проёмы и открытые границы,
а стены, колонны и перегородки его не пропускают и дают настоящие тени.
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
в комнаты настоящие клинья солнечного света; облачность — опционально, от
weather-сущности.
- 🪟 **Шторы открываются тапом** — одно действие открывает, закрывает или
останавливает штору, а сам значок морфится между открытым и закрытым
видом и мягко пульсирует кольцом, пока штора едет.
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
температурная заливка по комфортным границам.
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
никогда случайным тапом.
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
этажами, автокарусель, свои размеры на каждом экране.
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
(по именам комнат) или перетаскиванием призрака карты. Диагностика и явный
выбор источника поддерживают registry-less камеры карт и не подменяют молча
сломавшуюся привязку. Работают Xiaomi Cloud Map Extractor, dreame-vacuum
(Tasshack) и Valetudo.
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
<!-- docs-section: features -->
---
## Что умеет House Plan
- **Живые состояния и безопасные действия.** Свет и другие безопасные устройства
переключаются с плана; замок нельзя открыть случайным нажатием.
- **Три встроенных редактора.** «План» создаёт комнаты, стены и проёмы;
«Устройства» размещает и настраивает маркеры; «Подложка» добавляет линии,
подписи и мебель.
- **Комнаты, связанные с зонами HA.** Новые устройства появляются автоматически,
а карточки комнат показывают температуру, влажность, свет и средний LQI.
- **Свет и окружение.** Заливки комнат, Glow от ламп, тени от стен, дневной фон и
солнечные лучи из окон.
- **Двери, окна, ворота и пылесосы.** Проёмы отражают реальные датчики и замки;
робот показывает позицию, базу и пройденный путь.
- **Несколько этажей и экранов.** Вкладки пространств, жесты переключения,
локальный масштаб и отдельный стартовый этаж для каждой карточки.
- **Киоск для настенного экрана.** Только план, полноэкранная навигация и размеры
значков, сохранённые отдельно для этого устройства.
## Что это и зачем
![Тот же синтетический дом в touch-режиме просмотра](docs/images/02-view-touch.png)
House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота.
<!-- docs-section: first-run -->
Это удобно, когда:
## Первая рабочая комната
- устройств много, и списками пользоваться неудобно;
- нужно быстро понять состояние дома «одним взглядом»;
- хочется отдать доступ близким — по картинке разберётся любой;
- вы хотите красивый обзорный экран для настенного планшета.
1. Установите интеграцию и добавьте карточку на дашборд.
2. Создайте первое **пространство**: загрузите SVG/PNG/JPG/WebP либо выберите
вариант без изображения, чтобы нарисовать план вручную.
3. В редакторе «План» выберите **Контур комнаты**, поставьте вершины и замкните
контур нажатием на первую точку.
4. Назовите комнату и свяжите её с зоной Home Assistant. Для помещения без
устройств выберите «Без зоны».
5. Откройте «Устройства»: устройства связанной зоны уже размещены автоматически;
перетащите маркеры в нужные места.
6. При необходимости оформите подложку линиями, текстом и мебелью.
7. Вернитесь в «Просмотр» — теперь план показывает живые состояния и принимает
безопасные действия.
Интеграция состоит из двух частей, которые ставятся вместе:
![Создание первого пространства](docs/images/03-space-create.png)
- **карточка Lovelace** `houseplan-card` — сам интерактивный план;
- **серверный компонент** — хранит разметку комнат и позиции иконок в Home Assistant, поэтому план одинаков во всех браузерах и на всех устройствах.
![Замыкание контура комнаты на первой точке](docs/images/04-room-contour-close.png)
---
![Выбранная перегородка и контекстная панель редактора плана](docs/images/05-plan-context-tray.png)
## Чем отличается от аналогов
![Настройка устройства с источником привязки и точным результатом действия](docs/images/06-device-editor.png)
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan`
или новые GUI-карточки, где стены и мебель рисуют прямо на дашборде. Там либо
YAML/SVG, либо конфиг живёт в YAML карточки. House Plan — это **общий живой
план** на серверной интеграции Home Assistant:
![Живой предпросмотр отображения того же устройства](docs/images/06-device-display-preview.png)
| | House Plan | picture-elements / ha-floorplan | GUI-рисовалки (напр. easy-floorplan) |
|---|---|---|---|
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML / Inkscape SVG | Рисование стен и мебели в карточке |
| **Добавление устройств** | Автоматически по **зоне** HA | Каждую сущность вписываете руками | Ставите сущности руками на чертёж |
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели в YAML | Drag на холсте |
| **Разметка комнат** | Встроенный редактор контуров, привязка к зонам | Сторонний SVG-редактор | Сами рисуете стены (мебельный CAD) |
| **Хранение** | На сервере HA (`.storage`, общее, multi-client) | В YAML дашборда | В карточке / YAML дашборда |
| **Оверлеи** | Glow, климат, LQI, солнце, пылесосы, киоск | Что пропишете в SVG/CSS | Зависит от карточки |
| **Масштаб** | Плавный векторный зум | Обычно фиксированная картинка | SVG / виртуальный холст |
Пошаговые сценарии, все инструменты и особые случаи описаны в
[полном руководстве](docs/USER-GUIDE.ru.md). Возможности подложки отдельно
зафиксированы в [документе редактора](docs/DECOR-EDITOR.md), а роботов — в
[руководстве по пылесосам](docs/VACUUM.md).
**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не
универсальная CAD-система. Редактор подложки покрывает практический декор,
надписи и мебель; для свободного архитектурного черчения лучше отдельный
draw-инструмент. При наличии плана и зон HA House Plan держит один живой layout
на всех планшетах.
Ключевые преимущества коротко:
- **Никакого кода.** Всё — пространства, комнаты, устройства — настраивается кликами.
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно;
если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением.
Оранжевый = открыто / не заперто. Пульсирующее красное
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
---
## Настенный планшет / ТВ (киоск-режим)
Отдельный дашборд с view типа «панель», у карточки — `kiosk: true` (или
галочка «Режим настенного устройства» в редакторе карточки):
```yaml
type: custom:houseplan-card
kiosk: true
cycle: 0 # автосмена пространств каждые N секунд, 0 = выкл (удобно для ТВ)
```
Без шапки и редакторов — только живой план. Свайп листает этажи (при 1:1),
пинч — зум, двойной тап — сброс. Долгое нажатие (3 с) по пустому месту —
настройка размеров значков и текста для ЭТОГО экрана (хранится на
устройстве). Шапку самого Home Assistant скрывают настройки companion-app
или плагин [kiosk-mode](https://github.com/NemesisRE/kiosk-mode).
<!-- docs-section: installation -->
## Установка
В один клик, если у вас уже есть HACS:
### Через HACS
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
[![Открыть репозиторий в HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
1. В HACS откройте **⋮ → Пользовательские репозитории**.
2. Добавьте `https://github.com/Matysh/houseplan-card` с типом **Интеграция**.
3. Установите House Plan и перезапустите Home Assistant.
4. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
### Через HACS (рекомендуется)
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
добавьте именно URL, который публикует интеграция:
1. Откройте **HACS → меню (⋮) → Custom repositories**.
2. Вставьте URL этого репозитория, категория — **Integration**, и нажмите **Add**.
3. Найдите в списке **House Plan**, установите и **перезапустите Home Assistant**.
4. Перейдите в **Настройки → Устройства и службы → Добавить интеграцию** и выберите **House Plan**.
```yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
```
Карточка подключается автоматически — добавлять ресурс Lovelace вручную не нужно.
> **Карточка не грузится (`Custom element doesn't exist: houseplan-card`) или вы ведёте ресурсы
> Lovelace в YAML?** Добавьте ресурс вручную, указав URL, который *раздаёт сама интеграция*:
>
> ```yaml
> resources:
> - url: /houseplan_files/houseplan-card.js
> type: module
> ```
>
> **Не** используйте `/custom_components/houseplan/frontend/houseplan-card.js` — это путь к файлу
> на диске, который Home Assistant не отдаёт по HTTP (получите ошибку MIME `text/plain`, и элемент
> не зарегистрируется). Правильный URL, который раздаёт интеграция, — `/houseplan_files/houseplan-card.js`.
> Обе карточки (`houseplan-card` и `houseplan-space-card`) лежат в этом одном файле — отдельный ресурс
> не нужен.
Не используйте путь к файлу внутри `custom_components`: Home Assistant не
публикует его как JavaScript-модуль.
### Вручную
1. Скопируйте папку `custom_components/houseplan` в каталог `config/custom_components` вашего Home Assistant.
2. Перезапустите Home Assistant.
3. Добавьте интеграцию: **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
Скопируйте `custom_components/houseplan` в `config/custom_components`,
перезапустите Home Assistant и добавьте интеграцию House Plan.
### Добавление экрана с планом
### Добавление карточки
Создайте новую вкладку дашборда (удобнее всего — в режиме «Панель»/Panel) и добавьте карточку:
Создайте представление дашборда (лучше Panel) и добавьте карточку через UI либо:
```yaml
type: custom:houseplan-card
title: План дома
```
Больше ничего указывать не нужно — всё остальное настраивается прямо на экране.
Для нескольких экранов можно задать разные стартовые пространства:
---
```yaml
type: custom:houseplan-card
default_floor: ground
```
## Как пользоваться
Все карточки используют общие серверные комнаты и координаты. Текущий режим,
масштаб и выбранное пространство локальны для экрана. Одновременное
редактирование поддерживает синхронизацию и проверку ревизий, но один объект
лучше не менять параллельно в двух браузерах.
### Шаг 1. Добавьте пространство (этаж)
## Где искать подробности
При первом открытии план ещё пуст — House Plan сразу предложит создать первое пространство.
- [Полное руководство пользователя](docs/USER-GUIDE.ru.md)
- [Матрица mouse/touch/keyboard](docs/USER-GUIDE.ru.md#6-навигация-масштаб-и-жесты)
- [Инструменты плана](docs/USER-GUIDE.ru.md#инструменты-плана-в-короткой-таблице)
- [Редактор подложки](docs/DECOR-EDITOR.md)
- [Роботы-пылесосы](docs/VACUUM.md)
- [Поддержка touch](docs/TOUCH-SUPPORT.md)
Если в вашем Home Assistant уже настроены **этажи**, мастер предложит создать
пространство для каждого: названия подставятся сами, план попросит по очереди,
любой этаж можно пропустить.
<!-- docs-section: support -->
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png)
## Помощь и обратная связь
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка по умолчанию сохраняет пропорции, а подвинуть, изменить размер или повернуть её можно в любой момент в редакторе подложки.
- Вопросы и примеры планов: [Telegram @ha_houseplan](https://t.me/ha_houseplan).
- Баги и предложения: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues).
- Перед отчётом обновите House Plan, перезапустите HA и выполните жёсткое
обновление страницы (`Ctrl+F5`). Приложите версию, браузер, логи и шаги
воспроизведения; приватные entity ID можно заменить вымышленными.
![Диалог создания пространства](docs/images/03-space-dialog.png)
Скриншоты в документации получены воспроизводимой командой
`npm run build && node demo/docs/capture.mjs` только на синтетических данных. Версия сценариев,
fingerprint исходников и хеш каждого изображения находятся в
[индексе снимков](docs/images/screenshots.json).
> 💡 Подложку можно нарисовать в любом планировщике (например, РЕМПЛАННЕР) или сфотографировать бумажный план. Лучше всего SVG — он остаётся чётким при увеличении.
Позже можно добавить сколько угодно пространств (этажи, двор, гараж) кнопкой **+** рядом со вкладками.
### Шаг 2. Обведите комнаты
После добавления первого пространства карточка сама переходит в режим разметки. Кликайте по точкам сетки, соединяя их линиями, и замкните контур комнаты кликом по первой точке.
Как только контур замкнётся, появится окно сохранения комнаты. Здесь нужно **привязать комнату к зоне Home Assistant** — именно это включает автоматику. Для служебных помещений без устройств (холл, сауна) есть кнопка **«Без зоны»**.
![Разметка комнаты и её сохранение](docs/images/05-room-dialog.png)
Во время рисования у курсора показывается линейка с реальной длиной текущего отрезка (метры или футы+дюймы на имперской системе HA). Масштаб задаётся для каждого пространства — поле **«Масштаб (размер ячейки сетки)»** в диалоге пространства: сколько сантиметров в одной ячейке (по умолчанию 5 см).
Комнаты не могут пересекаться: клик строго внутри существующей комнаты или контур, охватывающий её, отклоняются. Ещё два инструмента помогают перекроить план позже:
- **Объединить** — кликните комнату, затем соседнюю с общей стеной; они сольются в одну. Диалог выбирает, чьё имя и зона останутся.
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
### Двери, окна, ворота и замки
В режиме разметки инструмент **«Проём»** ставит двери, окна и ворота: кликните рядом со стеной — проём
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
окно 120 см, ворота 300 см), датчик открытия и — для двери или ворот — **замок**.
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
подвижные части подсвечены акцентным цветом. Ворота не занимают полплана даже при ширине 3–4 м: две половинные створки показаны открытыми наружу всего на 10°, без большой дуги. Датчик, замок и пропуск света работают как у двери. У двери или ворот с замком рядом отображается замочек —
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
переключить — клик по проёму показывает карточку с обоими статусами.
Проёмы легко поправить позже: при наведении проём подсвечивается, его можно **перетащить вдоль
стен** (в том числе за угол), а **двойной клик открывает свойства**.
### Шаг 3. Устройства появляются сами
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти их, откройте редактор устройств и нажмите **«Скрытые и деактивированные»**: пользовательски скрытые устройства отображаются синими призраками. Деактивированное в HA устройство показывается серым служебным призраком и полностью исключается из данных и действий плана до повторной активации.
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
![Карточка устройства по клику](docs/images/08-info.png)
### Шаг 4. Масштаб
Колесо мыши или кнопки **- / ⊹ / +** приближают и отдаляют план; на сенсорном экране работает «щипок» двумя пальцами. При отдалении виден весь план целиком, при приближении — детали, и всё остаётся чётким. Масштаб запоминается отдельно для каждого пространства.
![Приближённый план — всё остаётся чётким](docs/images/09-zoom.png)
### Шаг 5. Расставьте значки по местам
Расставлять значки нужно на вкладке **«Устройства»**: там они перетаскиваются мышью, а клик открывает редактор. В режиме **«Просмотр»** ничего сдвинуть нельзя — панорамирование карты больше не сдвигает датчики (главная просьба пользователей). Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** возвращает автоматическую раскладку.
![Перетаскивание значков — доступно всегда](docs/images/06-edit.png)
### Управление с плана (tap actions)
По умолчанию тап по значку открывает инфо-карточку. В настройках карточки можно
переключить **«Тап по устройству»** на *Переключить* — тогда тап включает/выключает
свет, розетки, вентиляторы и увлажнители прямо с плана (режим настенного планшета).
Для безопасности общий toggle не действует на замки, сигнализации, шторы/ворота и
клапаны; для конкретного устройства toggle можно включить осознанно в его диалоге
(кроме замков и сигнализаций — они с плана не переключаются никогда). **Долгое
нажатие** всегда открывает инфо-карточку.
### Правила иконок
Какая MDI-иконка достанется устройству, решают **правила иконок** — редактируются
прямо в карточке (кнопка ⬡ в шапке): упорядоченный список «шаблон имени → иконка»
с живым тест-полем, двуязычные умолчания (EN/RU) и сброс одной кнопкой. Если ни одно
правило не подошло — решает *device class* сущности (термометр для датчиков
температуры и т.п.).
### Шаг 6. Добавление своих устройств вручную
Можно поставить и **отдельную сущность** (не только устройство целиком): начните печатать в поиске привязки — рядом с устройствами появятся отдельные сущности. Удобно, когда одно устройство отдаёт несколько значений (например, температуру и влажность), а вы хотите каждое своей иконкой.
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
В этом же диалоге настраивается вид устройства на плане. **Отображение** переключает значок,
анимированную **пульсацию присутствия** (расходящиеся кольца, пока сущность активна, и тусклая
точка в покое — идеально для датчиков движения) или то и другое сразу, с цветом и размером колец
на устройство. **Размер значка** (×0,5–3) и **поворот** — тоже индивидуальные: вентиль на стене
может быть маленьким и повёрнутым так, как он установлен.
![Добавление устройства вручную](docs/images/07-marker-dialog.png)
### Свои стили через card-mod (для продвинутых, без поддержки)
Карточка приезжает готовой, и поля для CSS у неё нет — но если у вас уже стоит [card-mod](https://github.com/thomasloven/lovelace-card-mod), у каждого объекта плана теперь есть стабильный «крючок», за который можно зацепиться: `data-hp="device"` (плюс `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. Мы обещаем их не переименовывать; сам card-mod мы не поставляем, не поддерживаем и за то, что ваш CSS сделает с карточкой, не отвечаем. Полная таблица, примеры и ограничения — в **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
---
## Удаление
1. Уберите карточку (или вкладку с планом) из дашборда.
2. **Настройки → Устройства и службы → House Plan → Удалить** запись интеграции.
3. Удалите интеграцию из **HACS** (или папку `custom_components/houseplan` при ручной установке) и перезапустите Home Assistant.
4. При желании удалите сохранённые данные плана: файлы `config/houseplan/` (подложки и вложения) и записи `houseplan.config` / `houseplan.layout` в каталоге `config/.storage`.
---
## Помощь и обмен опытом
- 💬 **[Чат в Telegram — @ha_houseplan](https://t.me/ha_houseplan)** — вопросы,
помощь с настройкой, идеи и скриншоты ваших планов. Самый быстрый способ
связаться с автором и другими пользователями.
- 🐞 [Issues на GitHub](https://github.com/Matysh/houseplan-card/issues) — баги
и запросы фич (пожалуйста, указывайте версию House Plan).
- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для
развёрнутых обсуждений.
- 📜 [История изменений](docs/CHANGELOG.ru.md) — что менялось в каждой версии.
Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в
**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно
быстрее.
---
## Часто задаваемые вопросы
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Откройте **«Скрытые и деактивированные»**: синий призрак можно показать в его диалоге, серый сначала нужно активировать в Home Assistant.
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
**Данные хранятся в облаке?** Нет. Всё хранится локально в вашем Home Assistant.
---
<p align="center"><sub>Скриншоты сделаны на реальной конфигурации Home Assistant.</sub></p>
Лицензия: [MIT](LICENSE).
+18 -6
View File
@@ -24,7 +24,12 @@ from .const import (
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
from .plans import collect_attachments, collect_plans, sweep_upload_temps
from .repairs import async_check_plan_files
from .store import HouseplanConfigEntry, async_save_layout_state, create_data
from .store import (
HouseplanConfigEntry,
async_save_config_state,
async_save_layout_state,
create_data,
)
_LOGGER = logging.getLogger(__name__)
@@ -60,6 +65,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
await data.config_store.async_load()
except Exception as err: # noqa: BLE001 — corrupt/unreadable .storage
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
try:
await data.virtual_light_store.async_load()
except Exception: # noqa: BLE001 — operational state fails safe to default on
_LOGGER.exception("House Plan: virtual-light storage is not readable; using default on")
entry.runtime_data = data
# server-side vacuum trails: the integration records the path itself
@@ -152,7 +161,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
rev = int(stored.get("rev", 0))
if cfg and migrate_config(cfg): # 2. the config half
rev += 1
await data.config_store.async_save({"config": cfg, "rev": rev})
await async_save_config_state(data, cfg, rev, previous_rev=rev - 1)
migrate_layout(layout, merged) # 3. the layout half + intent cleared
await async_save_layout_state(
data, lay_stored, layout, lay_rev + 1, remove=("geom_pending",)
@@ -186,11 +195,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
"layout_rev", layout_rev + (lay_stored.get("layout", {}) != target_layout)
))
if stored.get("config") != target_config or config_rev < target_config_rev:
previous_config_rev = config_rev
config_rev = max(config_rev, target_config_rev)
await data.config_store.async_save({
"config": target_config,
"rev": config_rev,
})
await async_save_config_state(
data,
target_config,
config_rev,
previous_rev=previous_config_rev,
)
if lay_stored.get("layout", {}) != target_layout or layout_rev < target_layout_rev:
layout_rev = max(layout_rev, target_layout_rev)
exact_metadata = pending.get("final_metadata")
+4 -3
View File
@@ -3,8 +3,9 @@
DOMAIN = "houseplan"
STORAGE_KEY = f"{DOMAIN}.layout"
STORAGE_CONFIG_KEY = f"{DOMAIN}.config"
STORAGE_VIRTUAL_LIGHTS_KEY = f"{DOMAIN}.virtual_lights"
STORAGE_VERSION = 1
STORAGE_MINOR_VERSION = 1
STORAGE_MINOR_VERSION = 2
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
PLANS_URL = "/houseplan_files/plans"
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
@@ -45,7 +46,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.63.0-beta.1"
VERSION = "1.67.0-beta.1"
# 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"},
}
@@ -0,0 +1,149 @@
"""Lossless, allow-listed canonicalisation of persisted geometry.
The frontend mirrors this module in src/coordinate-canonicalization.ts.
Keep the precision, scalar formula and field allow-list in lockstep; a shared
fixture is exercised by both runtimes.
"""
from __future__ import annotations
import copy
import math
from typing import Any
COORDINATE_DECIMALS = 9
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
def canonicalize_number(value: Any) -> Any:
"""Return one stable IEEE-754 representation for an allow-listed number."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return value
number = float(value)
if not math.isfinite(number):
return value
sign = -1.0 if math.copysign(1.0, number) < 0 else 1.0
result = sign * (
math.floor(abs(number) * COORDINATE_FACTOR + 0.5)
/ COORDINATE_FACTOR
)
if result == 0:
return 0.0
return result
def _record(value: Any) -> dict[str, Any] | None:
return value if isinstance(value, dict) else None
def _records(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, dict)]
def _fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
for name in names:
if name in record:
record[name] = canonicalize_number(record[name])
def _point(value: Any) -> None:
if not isinstance(value, list):
return
for index in range(min(2, len(value))):
value[index] = canonicalize_number(value[index])
def _points(value: Any) -> None:
if not isinstance(value, list):
return
for point in value:
_point(point)
def canonicalize_position(position: Any) -> Any:
"""Canonicalise x/y in one layout record without touching its metadata."""
result = copy.deepcopy(position)
record = _record(result)
if record is not None:
_fields(record, ("x", "y"))
return result
def canonicalize_layout_geometry(layout: Any) -> Any:
"""Canonicalise x/y in every layout record, preserving future fields."""
result = copy.deepcopy(layout)
record = _record(result)
if record is None:
return result
for position in record.values():
item = _record(position)
if item is not None:
_fields(item, ("x", "y"))
return result
def canonicalize_config_geometry(config: Any) -> Any:
"""Canonicalise only the named persisted geometry fields."""
result = copy.deepcopy(config)
root = _record(result)
if root is None:
return result
for space in _records(root.get("spaces")):
_fields(
space,
(
"plan_x",
"plan_y",
"plan_scale",
"plan_scale_x",
"plan_scale_y",
"plan_angle",
),
)
for room in _records(space.get("rooms")):
_fields(room, ("x", "y", "w", "h"))
_points(room.get("poly"))
for wall in _records(space.get("walls")):
_point(wall.get("a"))
_point(wall.get("b"))
for opening in _records(space.get("openings")):
_fields(opening, ("x", "y", "angle", "length"))
host = _record(opening.get("host"))
if host is not None:
_fields(host, ("t",))
for decor in _records(space.get("decor")):
kind = decor.get("kind")
if kind == "line":
_fields(decor, ("x1", "y1", "x2", "y2"))
elif kind in ("rect", "ellipse", "furniture"):
_fields(decor, ("x", "y", "w", "h", "angle"))
elif kind == "text":
_fields(decor, ("x", "y", "scale", "angle"))
for draft in _records(space.get("room_drafts")):
_points(draft.get("points"))
for partition in _records(space.get("partitions")):
_point(partition.get("a"))
_point(partition.get("b"))
for column in _records(space.get("wall_columns")):
_point(column.get("center"))
if column.get("shape") == "square":
_fields(column, ("angle",))
for span in _records(space.get("open_spans")):
_point(span.get("a"))
_point(span.get("b"))
for marker in _records(root.get("markers")):
_fields(marker, ("angle",))
return result
File diff suppressed because one or more lines are too long
+337 -33
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import copy
import hashlib
import json
import math
import re
import secrets
import time
@@ -16,6 +17,7 @@ import unicodedata
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urlsplit
import voluptuous as vol
@@ -47,12 +49,44 @@ from .validation import (
validate_marker_controls,
validate_marker_light_entities,
validate_marker_value_badges,
validate_opening_passages, validate_partition_opening_hosts,
MarkerControlError,
OpeningPassageError,
PartitionOpeningHostError,
PartitionOpeningJambMarginError,
)
FORMAT = "houseplan-export"
_PROTO_KEYS = {"__proto__", "prototype", "constructor"}
_SAFE_FILE = re.compile(r"[^A-Za-z0-9._-]+")
_LIVE_TEXT_TOKEN = re.compile(r"\{([^{}\r\n]+)\}")
_LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
_LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$")
_PLAN_ONLY_DASH = "—"
_SPACE_PLAN_FIELDS = (
"id", "title", "cell_cm", "plan_url", "plan_aspect", "plan_x", "plan_y",
"plan_scale", "plan_scale_x", "plan_scale_y", "plan_angle", "view_box",
)
_SPACE_DISPLAY_FIELDS = (
"show_borders", "show_names", "room_color", "bg_color", "room_opacity",
"fill_mode", "custom_fill", "glow_enabled", "temp_min", "temp_max",
"show_lqi", "hide_decor", "hide_openings", "label_temp", "label_hum",
"label_lqi", "label_light", "card_font_scale", "north_deg", "bg_mode",
"sun_rays",
)
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly")
_ROOM_DISPLAY_FIELDS = (
"fill_mode", "custom_fill", "glow", "name_scale", "label_scale",
)
_DECOR_COMMON_FIELDS = ("id", "kind", "color", "opacity", "width_cm", "width")
_DECOR_KIND_FIELDS = {
"line": ("x1", "y1", "x2", "y2", "line_style"),
"rect": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
"ellipse": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
"text": ("x", "y", "text", "size", "size_cm", "scale", "angle"),
"furniture": ("symbol", "x", "y", "w", "h", "angle"),
}
class ImportFailure(Exception):
@@ -73,6 +107,35 @@ def _json_copy(value: Any) -> Any:
return json.loads(json.dumps(value, ensure_ascii=False, allow_nan=False))
def _background_mode(settings: Any) -> str | None:
if not isinstance(settings, dict):
return None
mode = settings.get("bg_mode")
return mode if mode in ("static", "daynight") else None
def _materialize_global_background(config: dict[str, Any], fallback: str = "static") -> str:
"""Make the global mode portable instead of relying on a code default."""
settings = config.get("settings")
if not isinstance(settings, dict):
settings = {}
config["settings"] = settings
mode = _background_mode(settings) or fallback
settings["bg_mode"] = mode
return mode
def _materialize_space_background(space: dict[str, Any], fallback: str) -> str:
"""Make one space independent from the target installation's default."""
settings = space.get("settings")
if not isinstance(settings, dict):
settings = {}
space["settings"] = settings
mode = _background_mode(settings) or fallback
settings["bg_mode"] = mode
return mode
def _stored_model_version(config: dict[str, Any]) -> int:
"""Return the model actually stored without silently upgrading it."""
value = config.get("model_version", 0)
@@ -110,6 +173,136 @@ def live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any
}
def _pick_fields(source: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:
"""Copy only fields explicitly classified as portable plan data."""
return {key: _json_copy(source[key]) for key in fields if key in source}
def _is_live_text_reference(raw: str) -> bool:
"""Mirror ``liveTextReference`` without evaluating Home Assistant state."""
ref = raw.strip()
if not ref:
return False
entity = ref
attribute = ""
colon = ref.find(":")
if colon >= 0:
entity = ref[:colon].strip()
attribute = ref[colon + 1:].strip()
else:
parts = ref.split(".")
if len(parts) > 2:
entity = ".".join(parts[:2])
attribute = ".".join(parts[2:])
if _LIVE_TEXT_ENTITY.fullmatch(entity) is None:
return False
if colon >= 0 and not attribute:
return False
return not attribute or _LIVE_TEXT_ATTRIBUTE.fullmatch(attribute) is not None
def _plan_only_text(value: str) -> str:
"""Freeze every recognized live reference while preserving authored copy."""
replaced = _LIVE_TEXT_TOKEN.sub(
lambda match: _PLAN_ONLY_DASH
if _is_live_text_reference(match.group(1)) else match.group(0),
value,
)
return replaced.replace("{}", _PLAN_ONLY_DASH)
def _project_plan_only_room(room: dict[str, Any]) -> dict[str, Any]:
projected = _pick_fields(room, _ROOM_PLAN_FIELDS)
if "settings" in room:
settings = room.get("settings")
projected["settings"] = (
_pick_fields(settings, _ROOM_DISPLAY_FIELDS)
if isinstance(settings, dict) else None
)
return projected
def _project_plan_only_decor(shape: dict[str, Any]) -> dict[str, Any]:
kind = str(shape.get("kind", ""))
projected = _pick_fields(
shape, _DECOR_COMMON_FIELDS + _DECOR_KIND_FIELDS.get(kind, ()),
)
if kind == "text" and isinstance(projected.get("text"), str):
projected["text"] = _plan_only_text(projected["text"])
return projected
def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
"""Build the fail-closed geometry/presentation projection for #167."""
projected = _pick_fields(space, _SPACE_PLAN_FIELDS)
if "settings" in space:
projected["settings"] = _pick_fields(
space.get("settings") or {}, _SPACE_DISPLAY_FIELDS,
)
projected["rooms"] = [
_project_plan_only_room(room) for room in space.get("rooms") or []
]
collections: tuple[tuple[str, tuple[str, ...]], ...] = (
("walls", ("key", "cm", "a", "b")),
("room_drafts", ("id", "points", "segments")),
("partitions", ("id", "a", "b", "cm")),
("wall_columns", ("id", "shape", "center", "cm", "angle")),
("open_spans", ("a", "b")),
)
for name, fields in collections:
if name not in space:
continue
values = []
for item in space.get(name) or []:
selected = _pick_fields(item, fields)
if name == "room_drafts" and "segments" in selected:
selected["segments"] = [
_pick_fields(segment, ("cm",))
for segment in selected.get("segments") or []
]
values.append(selected)
projected[name] = values
if "openings" in space:
projected["openings"] = [
_pick_fields(
opening,
("id", "type", "x", "y", "angle", "length")
+ (() if opening.get("type") == "passage" else ("flip_h", "flip_v"))
+ (("host",) if opening.get("host") else ()),
)
for opening in space.get("openings") or []
]
if "decor" in space:
projected["decor"] = [
_project_plan_only_decor(shape) for shape in space.get("decor") or []
]
return projected
def _plan_only_room_label_layout(
layout: dict[str, Any], space: dict[str, Any],
) -> dict[str, Any]:
space_id = str(space.get("id", ""))
room_ids = {str(room.get("id", "")) for room in space.get("rooms") or []}
projected: dict[str, Any] = {}
for key, pos in layout.items():
if not (
isinstance(key, str)
and key.startswith("rl_")
and key[3:] in room_ids
and isinstance(pos, dict)
and str(pos.get("s", "")) == space_id
):
continue
value = _pick_fields(pos, ("x", "y", "s"))
scale = pos.get("k")
if isinstance(scale, (int, float)) and not isinstance(scale, bool) \
and math.isfinite(scale) and 0.5 <= scale <= 3:
value["k"] = scale
projected[key] = value
return projected
def _marker_owned(marker: dict[str, Any], space: dict[str, Any], layout: dict[str, Any]) -> bool:
marker_id = str(marker.get("id", ""))
room_ids = {str(room.get("id")) for room in space.get("rooms") or []}
@@ -156,6 +349,24 @@ def placement_manifest(config: dict[str, Any], layout: dict[str, Any]) -> list[d
def _internal_path(root: Path, url: str) -> tuple[str, Path] | None:
# A url is parsed as a url, not as a string: everything after "?" or "#"
# addresses the transfer, never the file. Legacy attachments carry a
# cache-buster (".../files/m1/doc.pdf?v=1783170649"), and while the string
# form fed "doc.pdf?v=1783170649" to sanitize_filename the name never
# matched itself — the reference read as internal-but-non-canonical and
# every backup holding one refused to import (issue #225). Path segments
# keep doing the guarding: dropping the query cannot widen what a segment
# is allowed to be.
#
# Only a same-document reference may be trusted this way: with a scheme or
# an authority the path belongs to another host, and taking it would let
# "https://evil.example/houseplan_files/files/m1/doc.pdf" resolve onto a
# local file (review CODE-REVIEW-225-r1, M1). Such a url stays external,
# which is also what _looks_internal says about it.
parsed = urlsplit(url)
if parsed.scheme or parsed.netloc:
return None
url = parsed.path
content_plan = CONTENT_URL + "/plans/_/"
if url.startswith(content_plan) or url.startswith(PLANS_URL + "/"):
prefix = content_plan if url.startswith(content_plan) else PLANS_URL + "/"
@@ -225,9 +436,12 @@ def create_export(
*,
kind: str,
space_id: str | None,
plan_only: bool = False,
card_version: str,
config_root: Path,
) -> tuple[dict[str, Any], str]:
if not isinstance(plan_only, bool) or plan_only and kind != "space":
raise ImportFailure("invalid_format", "Plan-only export requires one space")
raw_config = _json_copy(
config_data.get("config") or {"spaces": [], "markers": [], "settings": {}}
)
@@ -239,6 +453,7 @@ def create_export(
# payload would either duplicate it or tempt an exporter to silently stamp
# the current version over an older/future stored model.
config.pop("model_version", None)
global_background = _materialize_global_background(config)
layout = live_layout(config, LAYOUT_SCHEMA(_json_copy(layout_data.get("layout") or {})))
stamp = datetime.now(UTC).strftime("%Y-%m-%d_%H-%M-%S")
title = ""
@@ -247,41 +462,47 @@ def create_export(
space = next((sp for sp in config.get("spaces") or [] if str(sp.get("id")) == space_id), None)
if not space:
raise ImportFailure("space_not_found", "Space was not found")
_materialize_space_background(space, global_background)
title = str(space.get("title") or space.get("id") or "space")
selected_layout = {
key: pos for key, pos in layout.items()
if isinstance(pos, dict) and str(pos.get("s")) == str(space_id)
}
selected_markers = [
m for m in config.get("markers") or []
if m.get("removed") is not True and _marker_owned(m, space, selected_layout)
]
selected_ids = {str(marker.get("id")) for marker in selected_markers}
for marker in selected_markers:
controls = marker.get("controls")
if isinstance(controls, list):
kept = []
for ref in controls:
if isinstance(ref, str) and ref.startswith("marker:") \
and ref[len("marker:"):] not in selected_ids:
dropped_marker_links += 1
continue
kept.append(ref)
marker["controls"] = kept or None
badge = marker.get("value_badge")
source = badge.get("source") if isinstance(badge, dict) else None
ref = source.get("ref") if isinstance(source, dict) \
and source.get("kind") == "derived_marker_state" else None
if isinstance(ref, str) and ref.startswith("marker:") \
and ref[len("marker:"):] not in selected_ids:
badge["enabled"] = False
badge["source"] = None
dropped_marker_links += 1
config = {
"spaces": [_json_copy(space)],
"markers": _json_copy(selected_markers),
}
layout = selected_layout
if plan_only:
projected_space = _project_plan_only_space(space)
config = {"spaces": [projected_space], "markers": []}
layout = _plan_only_room_label_layout(selected_layout, projected_space)
else:
selected_markers = [
m for m in config.get("markers") or []
if m.get("removed") is not True and _marker_owned(m, space, selected_layout)
]
selected_ids = {str(marker.get("id")) for marker in selected_markers}
for marker in selected_markers:
controls = marker.get("controls")
if isinstance(controls, list):
kept = []
for ref in controls:
if isinstance(ref, str) and ref.startswith("marker:") \
and ref[len("marker:"):] not in selected_ids:
dropped_marker_links += 1
continue
kept.append(ref)
marker["controls"] = kept or None
badge = marker.get("value_badge")
source = badge.get("source") if isinstance(badge, dict) else None
ref = source.get("ref") if isinstance(source, dict) \
and source.get("kind") == "derived_marker_state" else None
if isinstance(ref, str) and ref.startswith("marker:") \
and ref[len("marker:"):] not in selected_ids:
badge["enabled"] = False
badge["source"] = None
dropped_marker_links += 1
config = {
"spaces": [_json_copy(space)],
"markers": _json_copy(selected_markers),
}
layout = selected_layout
elif kind != "full":
raise ImportFailure("invalid_format", "Unknown export kind")
document = {
@@ -296,7 +517,10 @@ def create_export(
"payload": {"config": config, "layout": layout},
"placement_manifest": placement_manifest(config, layout),
"content_manifest": content_manifest(config, config_root),
"transfer": {"dropped_marker_links": dropped_marker_links},
"transfer": {
"dropped_marker_links": dropped_marker_links,
**({"plan_only": True} if plan_only else {}),
},
}
if len(json.dumps(
document, ensure_ascii=False, separators=(",", ":"), allow_nan=False
@@ -358,6 +582,12 @@ def parse_document(raw: bytes) -> dict[str, Any]:
config = CONFIG_SCHEMA(_json_copy(payload.get("config")))
except (vol.Invalid, TypeError, ValueError) as err:
raise ImportFailure("invalid_config", str(err)) from err
if document["kind"] == "full":
_materialize_global_background(config)
else:
for space in config.get("spaces") or []:
if isinstance(space, dict):
_materialize_space_background(space, "static")
try:
layout = LAYOUT_SCHEMA(_json_copy(payload.get("layout") or {}))
except (vol.Invalid, TypeError, ValueError) as err:
@@ -379,10 +609,13 @@ def parse_document(raw: bytes) -> dict[str, Any]:
if document["kind"] == "space" and placement_ids != set(layout):
raise ImportFailure("invalid_format", "Placement manifest does not match layout")
dropped_marker_links = _transfer_dropped_marker_links(document)
plan_only = _transfer_plan_only(document)
document["transfer"] = {
**(document.get("transfer") or {}),
"dropped_marker_links": dropped_marker_links,
}
if plan_only:
_validate_plan_only_document(document, config, layout, placement)
if len(json.dumps(
config, ensure_ascii=False, separators=(",", ":"), allow_nan=False
).encode("utf-8")) > MAX_CONFIG_BYTES:
@@ -453,6 +686,51 @@ def _transfer_dropped_marker_links(document: dict[str, Any]) -> int:
return value
def _transfer_plan_only(document: dict[str, Any]) -> bool:
"""Read strict additive plan-only metadata without widening old exports."""
transfer = document.get("transfer")
if transfer is None:
return False
if not isinstance(transfer, dict):
raise ImportFailure("invalid_format", "Transfer metadata must be an object")
value = transfer.get("plan_only", False)
if not isinstance(value, bool):
raise ImportFailure("invalid_format", "Plan-only metadata must be a boolean")
if value and document.get("kind") != "space":
raise ImportFailure("invalid_format", "Plan-only metadata requires one space")
return value
def _validate_plan_only_document(
document: dict[str, Any],
config: dict[str, Any],
layout: dict[str, Any],
placement: list[Any],
) -> None:
"""Reject forged plan-only flags unless every privacy invariant is true."""
spaces = config.get("spaces") or []
if len(spaces) != 1 or config.get("markers") != []:
raise ImportFailure("invalid_format", "Plan-only export has invalid owners")
expected_config = CONFIG_SCHEMA({
"spaces": [_project_plan_only_space(spaces[0])],
"markers": [],
})
if config != expected_config:
raise ImportFailure("invalid_format", "Plan-only export contains private fields")
expected_layout = _plan_only_room_label_layout(layout, spaces[0])
if layout != expected_layout:
raise ImportFailure("invalid_format", "Plan-only export contains device layout")
expected_placement = placement_manifest(config, layout)
if placement != expected_placement:
raise ImportFailure("invalid_format", "Plan-only placement manifest is not canonical")
content = document.get("content_manifest")
if not isinstance(content, list) or any(
not isinstance(item, dict) or item.get("owner") != "space"
for item in content
):
raise ImportFailure("invalid_format", "Plan-only export contains private content")
def _drop_invalid_import_marker_links(
config: dict[str, Any], *, clean_ids: set[str] | None = None,
) -> int:
@@ -560,6 +838,7 @@ def build_space_merge(
if len(spaces) != 1:
raise ImportFailure("invalid_config", "A space export must contain exactly one space")
space = spaces[0]
_materialize_space_background(space, "static")
used = {
str(value)
for sp in current_config.get("spaces") or []
@@ -583,6 +862,16 @@ def build_space_merge(
for room in space.get("rooms") or []:
if room.get("open_to"):
room["open_to"] = [old_room_ids.get(str(value), str(value)) for value in room["open_to"]]
# Opening ownership is part of the same space-local id graph. Remap the
# nested reference together with the partition itself; otherwise the
# invariant validator correctly rejects a copied space whose host.id still
# names the source partition.
for opening in space.get("openings") or []:
host = opening.get("host") if isinstance(opening, dict) else None
if isinstance(host, dict) and host.get("kind") == "partition":
old_host_id = str(host.get("id"))
if old_host_id in id_map:
host["id"] = id_map[old_host_id]
space["id"] = new_space_id
space["title"] = _unique_title(
str(space.get("title") or old_space_id), current_config.get("spaces") or []
@@ -637,7 +926,7 @@ def build_space_merge(
for field in (
"area", "controls", "tap_action", "tap_target", "tap_confirm",
"vacuum", "is_light", "use_climate_temp", "glow_color",
"glow_radius_cm", "light_entity", "hidden", "removed",
"glow_radius_cm", "light_entity", "toggle_entity", "hidden", "removed",
"value_badge",
):
marker.pop(field, None)
@@ -722,7 +1011,12 @@ def build_space_merge(
validate_marker_controls(merged_config, current_config)
validate_marker_light_entities(merged_config, current_config)
validate_marker_value_badges(merged_config, current_config)
except MarkerControlError as err:
validate_opening_passages(merged_config, current_config)
validate_partition_opening_hosts(merged_config, current_config)
except (
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError,
) as err:
raise ImportFailure(err.code, str(err)) from err
try:
merged_layout = LAYOUT_SCHEMA(merged_layout)
@@ -881,6 +1175,13 @@ def create_preview(
current_config = current_config_data.get("config") or {"spaces": [], "markers": [], "settings": {}}
current_layout = current_layout_data.get("layout") or {}
details: dict[str, Any] = {}
try:
# Every imported opening is new to this installation. A forged full or
# one-space file must not use the broken-read exception reserved for an
# already stored legacy passage.
validate_opening_passages(incoming_config, validate_all=True)
except OpeningPassageError as err:
raise ImportFailure(err.code, str(err)) from err
if document["kind"] == "space":
_merged_config, _merged_layout, details = build_space_merge(
document, current_config, current_layout, duplicate_policy,
@@ -926,6 +1227,7 @@ def create_preview(
runtime.import_previews[token] = candidate
preview = {
"kind": document["kind"],
"plan_only": _transfer_plan_only(document),
"created_at": document.get("created_at"),
"card_version": document.get("card_version"),
"integration_version": document.get("integration_version"),
@@ -1005,6 +1307,7 @@ def revalidate_candidate(
return {
"preview": {
"kind": document["kind"],
"plan_only": _transfer_plan_only(document),
"counts": _counts(incoming["config"], incoming["layout"]),
"current_counts": _counts(current_config, current_layout),
"source": "same" if candidate.get("same_source") else "foreign",
@@ -1039,6 +1342,7 @@ def prepare_apply(
_detach_missing(imported_config, candidate.get("content") or [])
if document["kind"] == "full":
config = imported_config
_materialize_global_background(config)
# Full exports keep the data-model version in the portable envelope so
# the payload can be validated independently. Restore it before the
# configuration is persisted; otherwise every full round-trip silently
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.63.0-beta.1"
"version": "1.67.0-beta.1"
}
+95 -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,47 @@ 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,
)
from .coordinate_canonicalization import (
canonicalize_config_geometry,
canonicalize_layout_geometry,
)
_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 +70,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 +81,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 +113,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,
),
)
@@ -113,7 +161,7 @@ def layout_store_payload(
}
if metadata:
out.update(metadata)
out["layout"] = layout
out["layout"] = canonicalize_layout_geometry(layout)
out["rev"] = rev
return out
@@ -139,3 +187,44 @@ 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
canonical_config = canonicalize_config_geometry(config)
payload = {"config": canonical_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,
canonical_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
+31 -3
View File
@@ -10,6 +10,7 @@ want to see where the cleanup has already been).
from __future__ import annotations
import asyncio
import math
import time
from typing import Any
@@ -24,11 +25,33 @@ import logging
_LOGGER = logging.getLogger(__name__)
TRAIL_CAP = 2000 # raw points per run before decimation
TRAIL_RESUME_GRACE_S = 30 * 60 # same-map stop/pause belongs to one cleanup
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
MOVING_STATES = {"cleaning", "returning", "on"}
def can_resume_trail_run(run: Any, map_id: str, now: float) -> bool:
"""Whether an ended current run may be reopened for this point.
Store timestamps are untrusted persisted data. Only finite JSON-number
timestamps and a non-negative inclusive grace interval are accepted;
malformed values and wall-clock rollback fail closed into a new run.
"""
if not isinstance(run, dict) or run.get("map_id") != map_id:
return False
ended = run.get("ended")
if (
isinstance(ended, bool)
or not isinstance(ended, (int, float))
or isinstance(now, bool)
or not isinstance(now, (int, float))
):
return False
elapsed = now - ended
return math.isfinite(elapsed) and 0 <= elapsed <= TRAIL_RESUME_GRACE_S
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
"""Map-id normalisation contract, shared with the frontend.
@@ -64,7 +87,10 @@ class TrailBook:
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
rec = self.data.setdefault(marker, {})
cur = rec.get("current")
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
resumed = bool(cur and can_resume_trail_run(cur, map_id, now))
if resumed:
cur["ended"] = None
if not cur or cur.get("ended") is not None or cur.get("map_id") != map_id:
# a new run begins: the old one becomes "previous" (and the one
# before it is forgotten — we keep exactly two, per the owner)
if cur:
@@ -73,7 +99,9 @@ class TrailBook:
rec["current"] = cur
pts: list[list[float]] = cur["points"]
if pts and pts[-1][0] == x and pts[-1][1] == y:
return False
# Clearing ended is observable state even if the source repeats
# the dock point: it must still reach Store and live cards.
return resumed
pts.append([x, y])
if len(pts) > TRAIL_CAP:
# decimate by two but never lose the freshest point
@@ -85,7 +113,7 @@ class TrailBook:
def end_run(self, marker: str, now: float) -> bool:
cur = (self.data.get(marker) or {}).get("current")
if cur and not cur.get("ended"):
if cur and cur.get("ended") is None:
cur["ended"] = now
return True
return False
+272 -50
View File
@@ -9,6 +9,12 @@ import re
import voluptuous as vol
from custom_components.houseplan.coordinate_canonicalization import (
canonicalize_config_geometry,
canonicalize_layout_geometry,
canonicalize_position,
)
# ---------- limits and extension sets ----------
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
MAX_PLAN_BYTES = 8 * 1024 * 1024
@@ -32,6 +38,166 @@ class MarkerControlError(ValueError):
self.code = code
class OpeningPassageError(ValueError):
"""Semantic open-passage error with a stable public code and payload."""
code = "invalid_passage_fields"
def __init__(self, space_id: str, opening_id: str, fields: list[str]) -> None:
self.space_id = space_id
self.opening_id = opening_id
self.fields = tuple(sorted(fields))
super().__init__(
f"space={space_id}; opening={opening_id}; fields={','.join(self.fields)}"
)
class PartitionOpeningHostError(ValueError):
"""A write tried to strip explicit host identity from a surviving opening."""
code = "invalid_partition_opening_host"
class PartitionOpeningJambMarginError(ValueError):
"""A direct geometry write leaves no physical jamb at a wall endpoint."""
code = "invalid_partition_opening_jamb_margin"
def __init__(
self, space_id: str, opening_id: str, margin: float, margin_cm: float
) -> None:
self.space_id = space_id
self.opening_id = opening_id
self.margin = margin
self.margin_cm = margin_cm
super().__init__(
f"space={space_id}; opening={opening_id}; "
f"margin={margin:.12g}; margin_cm={margin_cm:.12g}"
)
# One normalized canvas width contains this many physical grid cells. Keep in
# sync with GRID_STEP_N/NORM_W in the frontend; it is a geometry scale, not a
# user setting.
NORMALIZED_CANVAS_CELLS = 240.0
def validate_partition_opening_hosts(
config: dict, previous: dict | None = None
) -> None:
"""Validate hosted-opening write deltas without rejecting legacy reads.
Deleting the opening together with its partition remains valid. Surviving
records keep their host, while new/direct geometry changes reserve half the
actual wall depth at both endpoints. Rigid host translation and unrelated
edits round-trip existing near-end records unchanged.
"""
old_spaces = {
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
}
for space in config.get("spaces") or []:
space_id = str(space.get("id", ""))
old_space = old_spaces.get(space_id)
old_openings = {
str(opening.get("id")): opening
for opening in (old_space or {}).get("openings") or []
}
partitions = {
str(partition.get("id")): partition
for partition in space.get("partitions") or []
}
old_partitions = {
str(partition.get("id")): partition
for partition in (old_space or {}).get("partitions") or []
}
for opening in space.get("openings") or []:
opening_id = str(opening.get("id", ""))
old = old_openings.get(opening_id)
if old and old.get("host") is not None and opening.get("host") is None:
raise PartitionOpeningHostError(
f"space={space_id}; opening={opening_id}; host removed"
)
host = opening.get("host")
if host is None:
continue
partition = partitions.get(str(host.get("id", "")))
if partition is None:
# SPACE_SCHEMA owns missing-host diagnostics.
continue
old_host = (old or {}).get("host")
old_partition = old_partitions.get(str((old_host or {}).get("id", "")))
ax, ay = partition["a"]
bx, by = partition["b"]
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
old_span = None
if old_partition is not None:
old_ax, old_ay = old_partition["a"]
old_bx, old_by = old_partition["b"]
old_span = ((old_bx - old_ax) ** 2 + (old_by - old_ay) ** 2) ** 0.5
strict = (
old is None
or old_host is None
or old_host.get("id") != host.get("id")
or old_host.get("t") != host.get("t")
or old.get("length") != opening.get("length")
or old_partition is None
or old_partition.get("cm") != partition.get("cm")
or abs(old_span - span) > 1e-9
)
if not strict:
continue
cell_cm = float(space.get("cell_cm", 5))
margin_cm = float(partition["cm"]) / 2
margin = margin_cm / cell_cm / NORMALIZED_CANVAS_CELLS
along = float(host["t"]) * span
half = float(opening["length"]) / 2
if (along - half < margin - 1e-9
or along + half > span - margin + 1e-9):
raise PartitionOpeningJambMarginError(
space_id, opening_id, margin, margin_cm
)
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
def validate_opening_passages(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Reject new/changed inapplicable fields while preserving dormant bad data.
A passage read from an older/future writer may already contain door-only
keys. Unrelated writes must remain possible, but imports and any write that
introduces or changes such a key are fail-closed.
"""
old_spaces = {
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
}
for space in config.get("spaces") or []:
space_id = str(space.get("id", ""))
old_space = None if validate_all else old_spaces.get(space_id)
old_openings = {
str(opening.get("id")): opening
for opening in (old_space or {}).get("openings") or []
}
for opening in space.get("openings") or []:
if opening.get("type") != "passage":
continue
opening_id = str(opening.get("id", ""))
present = sorted(PASSAGE_FORBIDDEN_FIELDS & set(opening))
if not present:
continue
old_opening = None if validate_all else old_openings.get(opening_id)
if not old_opening or old_opening.get("type") != "passage":
raise OpeningPassageError(space_id, opening_id, present)
changed = [
field for field in present
if field not in old_opening or opening[field] != old_opening[field]
]
if changed:
raise OpeningPassageError(space_id, opening_id, changed)
VALUE_BADGE_ATTRIBUTES = {
"current_temperature", "temperature", "current_humidity", "humidity",
"current_position", "percentage", "brightness", "volume_level",
@@ -135,7 +301,7 @@ def validate_marker_value_badges(
def validate_marker_light_entities(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate new/changed leading-light choices without rejecting dormant data.
"""Validate new/changed light/switch choices without rejecting dormant data.
The top-level schema must stay lossless: an old or future literal that the
current frontend cannot edit may round-trip unchanged. Imports validate the
@@ -152,16 +318,26 @@ def validate_marker_light_entities(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
value = marker.get("light_entity")
old_value = None if validate_all else (old_marker or {}).get("light_entity")
if not validate_all and value == old_value:
continue
if value is None:
continue
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
raise MarkerControlError(
"invalid_light_entity", "Leading light entity must be light.* or switch.*"
)
for field, code, message in (
(
"light_entity",
"invalid_light_entity",
"Leading light entity must be light.* or switch.*",
),
(
"toggle_entity",
"invalid_toggle_entity",
"Toggle entity must be light.* or switch.*",
),
):
value = marker.get(field)
old_value = None if validate_all else (old_marker or {}).get(field)
if not validate_all and value == old_value:
continue
if value is None:
continue
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
raise MarkerControlError(code, message)
def validate_marker_controls(
@@ -352,11 +528,18 @@ _URL = vol.All(str, vol.Length(max=MAX_URL))
CANVAS_LIMIT = 5000.0
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
POS_SCHEMA = vol.Schema(
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
POS_SCHEMA = vol.All(
vol.Schema(
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
),
canonicalize_position,
)
LAYOUT_SCHEMA = vol.All(
vol.Schema({str: POS_SCHEMA}),
vol.Length(max=MAX_LAYOUT),
canonicalize_layout_geometry,
)
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
@@ -703,6 +886,15 @@ WALL_COLUMN_SCHEMA = vol.All(
_strict_wall_column,
)
PARTITION_OPENING_HOST_SCHEMA = vol.Schema(
{
vol.Required("kind"): vol.Equal("partition"),
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("t"): vol.All(_finite, vol.Range(min=0, max=1)),
},
extra=vol.PREVENT_EXTRA,
)
def _space_geometry_invariants(value: dict) -> dict:
"""All stored geometry shares ids; draft segments also have a space cap."""
@@ -720,6 +912,29 @@ def _space_geometry_invariants(value: dict) -> dict:
)
if draft_segments > MAX_DRAFT_SEGMENTS:
raise vol.Invalid("too many saved room-draft segments")
partitions = {
item.get("id"): item for item in value.get("partitions", []) if item.get("id")
}
hosted_intervals: dict[str, list[tuple[float, float]]] = {}
for opening in value.get("openings", []):
host = opening.get("host")
if host is None:
continue
partition = partitions.get(host["id"])
if partition is None:
raise vol.Invalid("partition opening host must exist in the same space")
ax, ay = partition["a"]
bx, by = partition["b"]
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
length = float(opening["length"])
along = float(host["t"]) * span
if length > span or along - length / 2 < -1e-9 or along + length / 2 > span + 1e-9:
raise vol.Invalid("partition opening must fit inside its host")
lo, hi = along - length / 2, along + length / 2
occupied = hosted_intervals.setdefault(host["id"], [])
if any(max(lo, old_lo) < min(hi, old_hi) - 1e-9 for old_lo, old_hi in occupied):
raise vol.Invalid("partition openings must not overlap")
occupied.append((lo, hi))
return value
@@ -773,7 +988,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
vol.Schema(
{
vol.Required("id"): str,
vol.Required("type"): vol.Any("door", "window", "gate"),
vol.Required("type"): vol.Any("door", "window", "gate", "passage"),
vol.Required("x"): _GEOM,
vol.Required("y"): _GEOM,
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
@@ -785,6 +1000,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
vol.Optional("invert"): bool,
vol.Optional("flip_h"): bool,
vol.Optional("flip_v"): bool,
vol.Optional("host"): PARTITION_OPENING_HOST_SCHEMA,
},
extra=vol.ALLOW_EXTRA,
)
@@ -885,6 +1101,9 @@ MARKER_SCHEMA = vol.Schema(
# Semantic delta validation below the schema preserves unknown/future
# literals until that exact field is edited (lossless config doctrine).
vol.Optional("light_entity"): object,
# Exact own entity selected for Toggle. Delta validation preserves an
# untouched future literal while bounding every new/changed value.
vol.Optional("toggle_entity"): object,
vol.Optional("value_badge"): vol.Any(
None,
vol.Schema(
@@ -918,38 +1137,41 @@ MARKER_SCHEMA = vol.Schema(
},
extra=vol.ALLOW_EXTRA,
)
CONFIG_SCHEMA = vol.Schema(
{
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): _COLOR,
# sun on the plan (docs/SUN.md): global defaults
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
# legacy field so an existing stored config can still load; the
# frontend ignores it and removes it on the next settings save.
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
{
vol.Required("c"): _COLOR,
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
)
}
),
},
extra=vol.ALLOW_EXTRA,
),
},
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
CONFIG_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): _COLOR,
# sun on the plan (docs/SUN.md): global defaults
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
# legacy field so an existing stored config can still load; the
# frontend ignores it and removes it on the next settings save.
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
{
vol.Required("c"): _COLOR,
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
)
}
),
},
extra=vol.ALLOW_EXTRA,
),
},
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
),
canonicalize_config_geometry,
)
@@ -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"],
}
+155 -28
View File
@@ -25,6 +25,10 @@ from .const import (
MAX_SIGN_PATHS,
PLANS_DIR, PLANS_URL,
)
from .coordinate_canonicalization import (
canonicalize_config_geometry,
canonicalize_layout_geometry,
)
from .auth import may_write
from .import_export import (
ImportFailure,
@@ -44,14 +48,22 @@ from .store import (
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
HouseplanData,
async_save_config_state,
async_save_layout_state,
get_data,
get_entry,
)
from .virtual_lights import (
EVENT_VIRTUAL_LIGHT_UPDATED,
async_toggle_virtual_light,
async_virtual_light_snapshot,
)
from .registry_snapshot import import_registry_snapshot
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, sanitize_filename,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
PartitionOpeningHostError, PartitionOpeningJambMarginError, sanitize_filename,
validate_opening_passages, validate_partition_opening_hosts,
validate_marker_controls, validate_marker_light_entities,
validate_marker_value_badges, valid_space_id,
)
@@ -126,6 +138,7 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_layout_update)
websocket_api.async_register_command(hass, ws_layout_delete)
websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_virtual_light_toggle)
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_optimize)
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
@@ -194,10 +207,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,
@@ -255,6 +269,7 @@ async def _commit_import_pair(
vol.Required("type"): "houseplan/export/create",
vol.Required("kind"): vol.In(["full", "space"]),
vol.Optional("space_id"): str,
vol.Optional("plan_only", default=False): bool,
vol.Optional("card_version", default=""): str,
}
)
@@ -279,6 +294,7 @@ async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any])
layout_data,
kind=msg["kind"],
space_id=msg.get("space_id"),
plan_only=msg.get("plan_only", False),
card_version=msg.get("card_version", ""),
config_root=Path(hass.config.path("")),
)
@@ -414,8 +430,12 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
if kind == "full":
backup = {
"kind": "import",
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout") or {},
"config": canonicalize_config_geometry(
config_data.get("config") or DEFAULT_CONFIG
),
"layout": canonicalize_layout_geometry(
layout_data.get("layout") or {}
),
"created": int(time.time()),
"after_config_rev": new_config_rev,
"after_layout_rev": new_layout_rev,
@@ -432,16 +452,20 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
final_metadata[_OPTIMIZE_BACKUP] = backup
pending = {
"kind": "import",
"config": target_config,
"layout": target_layout,
"config": canonicalize_config_geometry(target_config),
"layout": canonicalize_layout_geometry(target_layout),
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"final_metadata": final_metadata,
}
rollback = {
"kind": "import_rollback",
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout") or {},
"config": canonicalize_config_geometry(
config_data.get("config") or DEFAULT_CONFIG
),
"layout": canonicalize_layout_geometry(
layout_data.get("layout") or {}
),
"config_rev": config_rev,
"layout_rev": layout_rev,
"final_metadata": original_metadata,
@@ -554,6 +578,9 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
return
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
if layout == data.get("layout", {}):
connection.send_result(msg["id"], {"ok": True, "rev": current_rev})
return
new_rev = current_rev + 1
await async_save_layout_state(
rt, data, layout, new_rev,
@@ -615,7 +642,10 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
return
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
layout[msg["device_id"]] = msg["pos"]
if layout.get(msg["device_id"]) == msg["pos"]:
connection.send_result(msg["id"], {"ok": True, "rev": int(data.get("rev", 0))})
return
layout = {**layout, msg["device_id"]: msg["pos"]}
# keep the revision: a point-wise write used to drop it, which made the
# optimistic locking on layout/set meaningless — every drag reset the
# counter to 0 (HP-1454-08)
@@ -723,7 +753,10 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
rt, data, new_layout, new_rev,
metadata={
**_optimizer_backup_after_layout_maintenance(data, new_rev),
"repair_backup": {"space": space_id, "positions": touched},
"repair_backup": {
"space": space_id,
"positions": canonicalize_layout_geometry(touched),
},
},
remove=("repair_backup",),
)
@@ -1058,14 +1091,28 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
async with rt.write_lock:
data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
config_rev = int(data.get("rev", 0))
try:
virtual_lights = await async_virtual_light_snapshot(
rt.virtual_light_store,
config,
config_rev,
)
except Exception: # noqa: BLE001 - config remains independently readable
_LOGGER.exception("House Plan: reading virtual-light state failed")
# Never expose a stale off bit after an unreadable/revision-gap
# operational store. Compatibility/default on is the safe frame.
virtual_lights = {"rev": 0, "config_rev": config_rev, "off": []}
connection.send_result(
msg["id"],
{
"config": config,
"rev": data.get("rev", 0),
"rev": config_rev,
"virtual_lights": virtual_lights,
"can_write": may_write(hass, getattr(connection, "user", None)),
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
"undo_kind": _undo_kind(data, layout_data),
@@ -1073,6 +1120,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.
@@ -1194,7 +1277,12 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
validate_marker_controls(msg["config"], data.get("config"))
validate_marker_light_entities(msg["config"], data.get("config"))
validate_marker_value_badges(msg["config"], data.get("config"))
except MarkerControlError as err:
validate_opening_passages(msg["config"], data.get("config"))
validate_partition_opening_hosts(msg["config"], data.get("config"))
except (
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError,
) as err:
connection.send_error(msg["id"], err.code, str(err))
return
# An internal plan url must name a file that exists. The card can pick a
@@ -1214,8 +1302,24 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
"Plan file no longer exists: " + ", ".join(sorted(missing)),
)
return
if msg["config"] == data.get("config"):
# A semantic no-op still has to reconcile Repairs with external
# file-system changes. It must not create a revision, event, or
# discard the optimizer snapshot merely to refresh diagnostics.
entry = get_entry(hass)
if entry is not None:
from .repairs import async_check_plan_files
hass.async_create_task(async_check_plan_files(hass, entry))
connection.send_result(msg["id"], {"ok": True, "rev": int(current_rev)})
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
@@ -1299,7 +1403,12 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
validate_marker_controls(msg["config"], config_data.get("config"))
validate_marker_light_entities(msg["config"], config_data.get("config"))
validate_marker_value_badges(msg["config"], config_data.get("config"))
except MarkerControlError as err:
validate_opening_passages(msg["config"], config_data.get("config"))
validate_partition_opening_hosts(msg["config"], config_data.get("config"))
except (
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError,
) as err:
connection.send_error(msg["id"], err.code, str(err))
return
@@ -1320,15 +1429,19 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
new_layout_rev = layout_rev + 1
backup = {
"kind": "optimize",
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout", {}),
"config": canonicalize_config_geometry(
config_data.get("config") or DEFAULT_CONFIG
),
"layout": canonicalize_layout_geometry(
layout_data.get("layout", {})
),
"created": int(time.time()),
"after_config_rev": new_config_rev,
"after_layout_rev": new_layout_rev,
}
pending = {
"config": msg["config"],
"layout": msg["layout"],
"config": canonicalize_config_geometry(msg["config"]),
"layout": canonicalize_layout_geometry(msg["layout"]),
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"clear_backup": False,
@@ -1340,7 +1453,12 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
await rt.config_store.async_save({"config": msg["config"], "rev": new_config_rev})
await async_save_config_state(
rt,
msg["config"],
new_config_rev,
previous_rev=config_rev,
)
await async_save_layout_state(
rt, layout_data, msg["layout"], new_layout_rev,
metadata={_OPTIMIZE_BACKUP: backup},
@@ -1393,8 +1511,12 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
backup = layout_data[_OPTIMIZE_BACKUP]
restored_kind = str(backup.get("kind") or "optimize")
restored_config = backup.get("config") or DEFAULT_CONFIG
restored_layout = backup.get("layout") or {}
restored_config = canonicalize_config_geometry(
backup.get("config") or DEFAULT_CONFIG
)
restored_layout = canonicalize_layout_geometry(
backup.get("layout") or {}
)
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
pending = {
@@ -1410,7 +1532,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 -3
View File
@@ -14,11 +14,27 @@ const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const targetRoot = resolve(valueArg('target-root') ?? '.');
const profile = valueArg('profile') ?? 'large-house-v1';
if (!['large-house-v1', 'large-house-isometric-v1'].includes(profile))
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
throw new Error(`unknown large-house profile: ${profile}`);
const isometric = profile === 'large-house-isometric-v1';
const planSnap = profile === 'large-house-plan-snap-v1';
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
const fixture = makeLargeHouseFixture();
if (planSnap) {
for (const [floor, space] of fixture.config.spaces.entries()) {
space.room_drafts = [0, 1].map((draft) => {
const y = 0.985 + draft * 0.025;
return {
id: `perf-draft-${floor}-${draft}`,
points: [[0.10, y], [0.38, y], [0.46, y + 0.035]],
segments: [{ cm: 15 }, { cm: 20 }],
};
});
}
fixture.counts = { ...fixture.counts, drafts: 6, pointerMoves: 120 };
}
const viewport = { width: 1440, height: 1000 };
const { page, browser } = await launch(
@@ -48,7 +64,10 @@ const rows = [];
try {
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const measuredSample = iteration - warmups;
const row = await page.evaluate(async ({ fixture, sample, cardContract, isometric, requiresIsometric }) => {
const row = await page.evaluate(async ({
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
requiresWallFace,
}) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
const started = performance.now();
@@ -104,6 +123,8 @@ try {
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
isoGeometry: card._isoGeometryCache?.size ?? 0,
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
wallFaceGraph: card._wallFaceGraphCache?.length ?? 0,
});
window.__card?.remove?.();
@@ -120,6 +141,7 @@ try {
card.setConfig({
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
});
let wsCalls = 0;
const connection = {
subscribeEvents: async () => () => undefined,
subscribeMessage: async () => () => undefined,
@@ -134,6 +156,7 @@ try {
three: { floor_id: 'three', name: 'Three', level: 2 },
},
callWS: async (message) => {
wsCalls++;
if (message.type === 'houseplan/config/get')
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
if (message.type === 'houseplan/layout/get')
@@ -155,6 +178,13 @@ try {
const loadLongTasks = startLongTaskWindow();
const loadStarted = performance.now();
host.replaceChildren(card);
if (requiresIsometric) {
if (typeof card._onLabsSnapshot !== 'function')
throw new Error('large-house-isometric-v1 candidate has no Labs fixture hook');
// The product flag expires at 1.65.0. Performance keeps exercising
// the dormant renderer without changing the public registry contract.
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
}
card.hass = hassFor(fixture.states);
window.__hpAssertCardContract(card, cardContract);
if (requiresIsometric && (typeof card._setProjection !== 'function'
@@ -196,6 +226,112 @@ try {
await card.updateComplete;
});
let planSnapDiagnostics = null;
const planSnapPointer = planSnap ? await duration(async () => {
card._setMode('plan');
card._tool = 'draw';
card._path = [];
card.requestUpdate();
await card.updateComplete;
await frame();
const stage = card.renderRoot.querySelector('.stage');
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
if (requiresPlanSnap && !overlay) throw new Error('plan-snap candidate has no overlay');
const staticLines = overlay?.querySelectorAll('.plan-snap-line').length ?? 0;
const staticNodes = overlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0;
const cacheValue = card._planSnapGeometryCache?.value ?? null;
const configBefore = JSON.stringify(card._serverCfg);
const callsBefore = wsCalls;
const wallFaceCacheBeforePointer = card._wallFaceGraphCache?.length ?? 0;
const view = card._viewOr(card._baseVb());
const rect = stage.getBoundingClientRect();
const fromPlan = (x, y) => ({
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
});
const firstEndpoint = overlay?.querySelector('.plan-snap-node[data-kind="endpoint"]');
const longLine = [...(overlay?.querySelectorAll('.plan-snap-line') || [])]
.map((line) => ({
line,
a: [+line.getAttribute('x1'), +line.getAttribute('y1')],
b: [+line.getAttribute('x2'), +line.getAttribute('y2')],
}))
.sort((a, b) => Math.hypot(b.b[0] - b.a[0], b.b[1] - b.a[1])
- Math.hypot(a.b[0] - a.a[0], a.b[1] - a.a[1]))[0];
const points = [
firstEndpoint
? [+firstEndpoint.getAttribute('cx'), +firstEndpoint.getAttribute('cy')]
: [40, 40],
longLine
? [(longLine.a[0] + longLine.b[0]) / 2, (longLine.a[1] + longLine.b[1]) / 2]
: [120, 40],
[10, 10],
];
const seenKinds = new Set();
for (let index = 0; index < 120; index++) {
const point = points[index % points.length];
stage.dispatchEvent(new PointerEvent('pointermove', {
...fromPlan(point[0], point[1]),
bubbles: true, composed: true, pointerId: 880, pointerType: 'mouse',
}));
await card.updateComplete;
const active = card.renderRoot.querySelector(
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
);
if (active) seenKinds.add(active.getAttribute('data-kind'));
if (requiresPlanSnap && card.renderRoot.querySelectorAll(
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
).length > 1) throw new Error('plan-snap rendered more than one active candidate');
}
const finalOverlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
planSnapDiagnostics = {
supported: requiresPlanSnap,
staticLines,
staticNodes,
activeKinds: [...seenKinds].sort(),
cacheStable: cacheValue != null && card._planSnapGeometryCache?.value === cacheValue,
domStable: (finalOverlay?.querySelectorAll('.plan-snap-line').length ?? 0) === staticLines
&& (finalOverlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0)
=== staticNodes,
configStable: JSON.stringify(card._serverCfg) === configBefore,
wsWrites: wsCalls - callsBefore,
wallFaceCacheStableOnPointer:
(card._wallFaceGraphCache?.length ?? 0) === wallFaceCacheBeforePointer,
};
if (requiresPlanSnap && (
staticLines < fixture.counts.rooms || staticNodes < fixture.counts.rooms
|| !planSnapDiagnostics.cacheStable || !planSnapDiagnostics.domStable
|| !planSnapDiagnostics.configStable || planSnapDiagnostics.wsWrites !== 0
|| !planSnapDiagnostics.wallFaceCacheStableOnPointer
|| !seenKinds.has('endpoint') || !seenKinds.has('line')
)) throw new Error(`plan-snap structural contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
if (requiresWallFace) {
const oldPath = card._path;
const oldDraftId = card._activeDraftId;
const oldCms = card._draftSegmentCms;
const beforePath = [[10, 10]];
card._path = [[10, 10], [20, 10]];
card._activeDraftId = 'perf-face-draft';
card._draftSegmentCms = [15];
const acceptedStarted = performance.now();
card._offerWallFaces(beforePath);
planSnapDiagnostics.wallFaceAcceptedClickMs = performance.now() - acceptedStarted;
planSnapDiagnostics.wallFaceCacheEntries = card._wallFaceGraphCache?.length ?? 0;
card._wallFaceBatch = null;
card._roomDialog = false;
card._path = oldPath;
card._activeDraftId = oldDraftId;
card._draftSegmentCms = oldCms;
if (planSnapDiagnostics.wallFaceAcceptedClickMs > 1000
|| planSnapDiagnostics.wallFaceCacheEntries < 1
|| planSnapDiagnostics.wallFaceCacheEntries > 4) {
throw new Error(`wall-face accepted-click contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
}
}
card._setMode('view');
await card.updateComplete;
}) : null;
const resizePreview = await duration(async () => {
card._setMode('plan');
card._tool = 'resize';
@@ -279,6 +415,10 @@ try {
modelReadyMs,
firstStableRenderMs,
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
...(planSnapPointer ? {
planSnapPointerMs: planSnapPointer.ms,
planSnapDiagnostics,
} : {}),
spaceSwitchMs: spaceSwitch.ms,
stateUpdateMs: stateUpdate.ms,
resizePreviewMs: resizePreview.ms,
@@ -288,6 +428,7 @@ try {
longTasks: {
load: loadLongTaskResult,
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
spaceSwitch: spaceSwitch.longTasks,
stateUpdate: stateUpdate.longTasks,
resizePreview: resizePreview.longTasks,
@@ -306,7 +447,7 @@ try {
return result;
}, {
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
isometric, requiresIsometric,
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
});
if (measuredSample >= 0) rows.push(row);
}
@@ -319,6 +460,7 @@ const metricNames = [
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
];
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
const report = {
schema: 2,
profile,
@@ -0,0 +1,96 @@
// #199: same-process production-builder baseline versus the complete Optimize
// preflight wrapper on the deterministic 3-floor large-house fixture.
import { performance } from 'node:perf_hooks';
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import {
checkOptimizeGeometry,
prepareSpacePhysicalGeometryInputs,
} from '../test-build/plan-geometry-preflight.js';
import { spaceModels } from '../test-build/space-geometry.js';
import {
floorFootprintGeometry,
wallBodiesGeometry,
} from '../test-build/wall-thickness.js';
const WARMUPS = 3;
const SAMPLES = 20;
const ABSOLUTE_P95_MS = 250;
const RELATIVE_RATIO = 1.2;
const RELATIVE_NOISE_MS = 15;
const fixture = makeLargeHouseFixture();
const models = spaceModels(fixture.config);
const prepared = fixture.config.spaces.map((space, index) =>
prepareSpacePhysicalGeometryInputs(space, models[index]));
const directProductionPass = () => {
for (const input of prepared) {
const hasWalls = input.walls.length > 0 || input.physicalBodies.length > 0;
const united = hasWalls
? wallBodiesGeometry(
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
input.physicalBodies,
)
: null;
if (hasWalls && united == null) throw new Error(`baseline wall failure: ${input.space.id}`);
if (input.space.rooms.length && united?.paperGeom == null) {
const floor = floorFootprintGeometry(
input.space.rooms, input.walls, input.openCuts,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
);
if (floor == null) throw new Error(`baseline floor failure: ${input.space.id}`);
}
}
};
const completePreflight = () => {
const result = checkOptimizeGeometry(fixture.config);
if (!result.ok || result.spaces.length !== LARGE_HOUSE_COUNTS.floors
|| result.spaces.some((space) => space.status !== 'ok')) {
throw new Error(`candidate preflight failure: ${JSON.stringify(result.spaces)}`);
}
};
const sample = (operation) => {
const start = performance.now();
operation();
return performance.now() - start;
};
const run = (operation) => {
for (let index = 0; index < WARMUPS; index++) operation();
return Array.from({ length: SAMPLES }, () => sample(operation));
};
const quantile = (values, ratio) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
};
const summary = (values) => ({
min: Math.min(...values),
median: quantile(values, 0.5),
p95: quantile(values, 0.95),
max: Math.max(...values),
});
const baseline = summary(run(directProductionPass));
const candidate = summary(run(completePreflight));
const relativeLimit = baseline.p95 * RELATIVE_RATIO + RELATIVE_NOISE_MS;
const pass = candidate.p95 <= ABSOLUTE_P95_MS && candidate.p95 <= relativeLimit;
const report = {
issue: 199,
fixture: LARGE_HOUSE_COUNTS,
warmups: WARMUPS,
samples: SAMPLES,
baseline,
candidate,
budgets: {
absoluteP95Ms: ABSOLUTE_P95_MS,
relativeRatio: RELATIVE_RATIO,
relativeNoiseMs: RELATIVE_NOISE_MS,
relativeLimitP95Ms: relativeLimit,
},
pass,
};
console.log(JSON.stringify(report, null, 2));
if (!pass) process.exitCode = 1;
+29
View File
@@ -29,3 +29,32 @@ export async function assertFreshDemoBundle(page, root = process.cwd()) {
}
return expected;
}
/** Env switch that lets a debugging session run against a stale bundle. */
export const ALLOW_STALE_BUNDLE = 'HP_ALLOW_STALE_BUNDLE';
/**
* The freshness gate for every browser check, escape hatch included (#236).
*
* The smoke launcher had no freshness check at all, while golden runs and
* benchmarks did. A smoke against a stale `demo/srv/assets/houseplan-card.js`
* does not fail cleanly: on #234 three assertions went red and a fourth went
* GREEN, because the old code was wrong in two places that agreed with each
* other. A partly-red partly-green result looks like a logic defect and sends
* the reader hunting in the wrong file.
*
* Skipping is allowed for debugging, but never silently: a skipped guard that
* says nothing is the same silent success this project keeps digging out.
*/
export async function assertFreshDemoBundleUnlessAllowed(
page, root = process.cwd(), env = process.env,
) {
if (env[ALLOW_STALE_BUNDLE]) {
console.warn(
`[houseplan] ${ALLOW_STALE_BUNDLE} is set — bundle freshness NOT verified. `
+ 'A red result may mean a stale bundle rather than a defect (#236).',
);
return null;
}
return assertFreshDemoBundle(page, root);
}
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env node
// Issue #211: human-reviewable Reference SVG <-> Runtime matrix.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { mdiLightbulbSpot } from '@mdi/js';
import { launch } from './serve.mjs';
const artifactDir = resolve('artifacts/device-icon-reference');
mkdirSync(artifactDir, { recursive: true });
const referenceAsset = (theme, file, coreSize) => {
let source = readFileSync(resolve('demo/srv/reference/device-icons', theme, file), 'utf8');
if (file === 'Lock.svg') {
const old = theme === 'Dark' ? '#252525' : 'black';
source = source.replaceAll(old, '#66D17A');
if (theme === 'Dark') source = source.replaceAll('fill="white"', 'fill="#252525"');
}
if (file === 'Unlock.svg') {
source = source.replaceAll(theme === 'Dark' ? '#1DC21D' : '#F0A00C', '#F0410C');
}
const nativeWidth = Number(source.match(/<svg[^>]*width="([\d.]+)"/)?.[1] || 127);
return {
url: `data:image/svg+xml;base64,${Buffer.from(source).toString('base64')}`,
displayWidth: nativeWidth * coreSize / 80,
};
};
const { page, browser } = await launch(
{ width: 1280, height: 960 }, 1, [], { colorScheme: 'dark' },
);
await page.evaluate((path) => { window.__ICONS['mdi:lightbulb-spot'] = path; }, mdiLightbulbSpot);
await page.evaluate(async () => {
const c = window.__card;
const marker = (id, patch) => ({
...(c._serverCfg.markers || []).find((item) => item.id === id),
id, binding: `device:${id}`, ...patch,
});
const replacements = new Map([
['d_light1', marker('d_light1', { display: 'badge', icon: 'mdi:lightbulb-spot' })],
['d_tv', marker('d_tv', { display: 'value' })],
['d_temp', marker('d_temp', {
display: 'badge',
value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: 'sensor.living_temp' },
position: 'right',
},
})],
]);
c._serverCfg.markers = [
...(c._serverCfg.markers || []).filter((item) => !replacements.has(item.id)),
...replacements.values(),
];
c.hass = {
...c.hass,
states: {
...c.hass.states,
'sensor.living_temp': {
...c.hass.states['sensor.living_temp'],
state: '23',
attributes: { ...c.hass.states['sensor.living_temp']?.attributes, unit_of_measurement: '%' },
},
'media_player.tv': {
...c.hass.states['media_player.tv'],
state: 'Working',
},
},
};
c._regSignature = '';
c._cfgEpoch++;
c._maybeRebuildDevices();
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
const qaStyle = document.createElement('style');
qaStyle.textContent = '.devtip{display:none!important}';
(c.renderRoot || c.shadowRoot).append(qaStyle);
await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)));
});
const selector = (id) => `.dev[data-id="${id}"]`;
async function runtimePng(theme, row, size) {
await page.mouse.move(1, 1);
await page.evaluate(({ id, themeName, classes, px, clearValues }) => {
const node = (window.__card.renderRoot || window.__card.shadowRoot)
.querySelector(`.dev[data-id="${id}"]`);
for (const marker of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.dev'))
marker.style.visibility = marker === node ? 'visible' : 'hidden';
node.classList.remove(...[
'theme-light', 'theme-dark', 'on', 'open', 'alarm', 'unavail', 'virtual',
'sel', 'lock-locked', 'lock-unlocked',
]);
node.classList.add(`theme-${themeName}`, ...classes);
node.style.setProperty('--device-base-size', `${px}px`);
node.style.setProperty('--dev-scale', '1');
node.querySelector('.device-core')?.style.setProperty('transition', 'none');
node.querySelector('.device-shell-frame')?.style.setProperty('transition', 'none');
if (clearValues) node.querySelectorAll('.value-badge').forEach((value) => value.remove());
node.blur();
}, {
id: row.id,
themeName: theme.toLowerCase(),
classes: row.classes || [],
px: size,
clearValues: row.clearValues || false,
});
if (row.hover) {
await page.hover(selector(row.id));
await page.waitForTimeout(180);
}
if (row.focus) {
await page.$eval(selector(row.id), (node) => node.focus());
}
await page.$eval(selector(row.id), (node) => {
for (const tooltip of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.devtip'))
tooltip.style.setProperty('display', 'none', 'important');
node.querySelector('.lqi')?.style.setProperty('display', 'none');
});
const clip = await page.$eval(selector(row.id), (node) => {
const shell = node.querySelector('.device-shell-frame').getBoundingClientRect();
const pad = 22;
return {
x: Math.max(0, shell.left - pad),
y: Math.max(0, shell.top - pad),
width: shell.width + pad * 2,
height: shell.height + pad * 2,
};
});
return (await page.screenshot({ clip })).toString('base64');
}
const rows = [
{ label: 'Default', file: 'Icon Default.svg', id: 'd_light1' },
{ label: 'Hover', file: 'Icon Hover.svg', id: 'd_light1', hover: true },
{ label: 'Active', file: 'Icon Active.svg', id: 'd_light1', classes: ['on'] },
{ label: 'Lock', file: 'Lock.svg', id: 'd_lock', classes: ['lock-locked'] },
{ label: 'Unlock', file: 'Unlock.svg', id: 'd_lock', classes: ['lock-unlocked'] },
{ label: 'Selected', file: 'Selected.svg', id: 'd_light1', classes: ['sel'] },
{ label: 'Focus', file: 'Focus Visible.svg', id: 'd_light1', focus: true },
{ label: 'Alert', file: 'Alert Value.svg', id: 'd_temp', classes: ['alarm'] },
{ label: 'Virtual', file: 'Virtual Device Default.svg', id: 'd_motion', classes: ['virtual'] },
{ label: 'Unavailable', file: 'Unavailable.svg', id: 'd_light1', classes: ['unavail'] },
{ label: 'Text', file: 'Text Default.svg', id: 'd_tv' },
{ label: 'Double Right', file: 'Double Default Right.svg', id: 'd_temp' },
];
const matrix = [];
for (const theme of ['Light', 'Dark']) {
for (const row of rows) {
matrix.push({
theme,
row,
size: 56,
runtime: await runtimePng(theme, row, 56),
});
}
for (const size of [32, 96]) {
const row = rows[0];
matrix.push({ theme, row, size, runtime: await runtimePng(theme, row, size) });
}
const textRow = rows.find((row) => row.label === 'Text');
matrix.push({ theme, row: textRow, size: 96, runtime: await runtimePng(theme, textRow, 96) });
}
const escapeHtml = (value) => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
const body = matrix.map(({ theme, row, size, runtime }) => {
const reference = referenceAsset(theme, row.file, size);
return `
<tr>
<td>${theme}</td><td>${escapeHtml(row.label)}</td><td>${size}px</td>
<td class="preview"><img style="width:${reference.displayWidth}px" src="${reference.url}" alt="Reference ${escapeHtml(row.label)}"></td>
<td class="preview runtime"><img src="data:image/png;base64,${runtime}" alt="Runtime ${escapeHtml(row.label)}"></td>
</tr>`;
}).join('');
const html = `<!doctype html>
<html><head><meta charset="utf-8"><title>Device icon reference/runtime matrix</title>
<style>
body{margin:24px;background:#777;color:#111;font:16px system-ui,sans-serif}
h1,p{max-width:1100px} table{border-collapse:collapse;width:100%;background:#aaa}
th,td{border:1px solid #555;padding:8px;text-align:left} th{position:sticky;top:0;background:#ddd;z-index:2}
.preview{width:38%;text-align:center;background:linear-gradient(135deg,#d5d5d5 50%,#666 50%)}
.preview img{display:block;margin:auto;max-width:300px;max-height:180px}.runtime img{image-rendering:auto}
</style></head><body>
<h1>House Plan device icons: package 1.1.1 vs runtime</h1>
<p>Issues #211/#217. Reference SVG is loaded directly from the designer package; Runtime is a fresh browser capture. Default covers 32/56/96 px and Text has an additional large 96 px row so its outer stadium curvature is reviewable. Dark Unlock is evaluated using the owner's amber override from #179.</p>
<table><thead><tr><th>Theme</th><th>State/layout</th><th>Core</th><th>Reference SVG</th><th>Runtime</th></tr></thead>
<tbody>${body}</tbody></table></body></html>`;
const htmlPath = resolve(artifactDir, 'device-icons-reference-runtime.html');
writeFileSync(htmlPath, html);
await page.setViewportSize({ width: 1600, height: 1000 });
await page.setContent(html, { waitUntil: 'load' });
await page.screenshot({
path: resolve(artifactDir, 'device-icons-reference-runtime.png'),
fullPage: true,
});
await browser.close();
console.log(`OK device icon reference/runtime matrix: ${htmlPath}`);
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
import { launch } from '../serve.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const OUTPUT = resolve(ROOT, 'docs/images');
const BUNDLE = resolve(ROOT, 'dist/houseplan-card.js');
const DEMO_BUNDLE = resolve(ROOT, 'demo/srv/assets/houseplan-card.js');
const INTEGRATION_BUNDLE = resolve(ROOT, 'custom_components/houseplan/frontend/houseplan-card.js');
const SCRIPT = fileURLToPath(import.meta.url);
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
export const DOC_SCREENSHOT_VERSION = 1;
export const DOC_SCREENSHOTS = Object.freeze([
{
id: 'view-desktop', file: '01-view-desktop.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'view-touch', file: '02-view-touch.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true, kiosk: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 390, height: 760 }, capture: 'page',
},
{
id: 'space-create', file: '03-space-create.png', fixture: 'empty', noFloors: true,
title: 'House Plan', language: 'en', theme: 'dark',
viewport: { width: 900, height: 850 }, capture: 'page', expectDialog: true,
},
{
id: 'room-contour-close', file: '04-room-contour-close.png', fixture: 'visual',
space: 'golden-geometry', mode: 'plan',
wallJunctionPreview: {
path: [[0.18, 0.18], [0.40, 0.18], [0.40, 0.40], [0.18, 0.40]],
pointer: [0.18, 0.18], cms: [440, 440, 440], cm: 15,
},
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'plan-context-tray', file: '05-plan-context-tray.png', fixture: 'visual',
space: 'golden-geometry', mode: 'plan', editorTray: 'plan-selection',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'device-editor', file: '06-device-editor.png', fixture: 'visual',
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
deviceName: 'Living-room ceiling light',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
},
{
id: 'device-display-preview', file: '06-device-display-preview.png', fixture: 'visual',
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
deviceName: 'Living-room ceiling light', devicePresentationPreview: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
},
{
id: 'background-editor', file: '07-background-editor.png', fixture: 'visual',
space: 'golden-geometry', mode: 'decor', editorTray: 'decor-selection',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'room-card', file: '08-room-card.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'room-card',
},
{
id: 'device-info', file: '09-device-info.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', dialog: 'device-info',
deviceId: 'golden-light-two', deviceName: 'Living-room ceiling light',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 900 }, capture: 'page', expectDialog: true,
},
]);
const roomCardClip = (page) => page.evaluate(() => {
const card = window.__goldenCard;
const roomCards = [...(card?.renderRoot?.querySelectorAll('.roomlabel') || [])];
const target = roomCards.find((item) => item.querySelector('.rlm')) || roomCards[0];
if (!target) throw new Error('documentation room card is missing');
const rect = target.getBoundingClientRect();
const marginX = 80;
const marginY = 70;
return {
x: Math.max(0, rect.left - marginX),
y: Math.max(0, rect.top - marginY),
width: Math.min(innerWidth, rect.right + marginX) - Math.max(0, rect.left - marginX),
height: Math.min(innerHeight, rect.bottom + marginY) - Math.max(0, rect.top - marginY),
};
});
/**
* Documentation-only presentation state. Keep these mutations out of the
* golden harness: changing that release fixture would invalidate every visual
* baseline even though the production component and golden matrix are intact.
*/
const applyDocumentationState = (page, scenario) => page.evaluate(async (current) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const card = window.__goldenCard;
if (!card) throw new Error(`documentation card is missing: ${current.id}`);
if (current.title) {
card.setConfig({ ...card._config, title: current.title });
}
if (current.roomMetrics) {
const space = card._serverCfg?.spaces?.find((item) => item.id === current.space);
if (!space) throw new Error(`documentation room metrics space is missing: ${current.space}`);
space.settings = {
...(space.settings || {}),
label_temp: true,
label_hum: true,
label_lqi: true,
label_light: true,
};
card._cfgEpoch += 1;
card._modelCache = null;
}
if (current.fixture === 'empty') {
card._serverCfg = { ...(card._serverCfg || {}), spaces: [] };
card._cfgEpoch += 1;
card._modelCache = null;
card._space = '';
card._onboardingShown = true;
card.hass = { ...card.hass, floors: {} };
card._openSpaceDialog('create');
}
if (current.dialog === 'device-info') {
const device = card._devices.find((item) => item.id === current.deviceId);
if (!device) throw new Error(`documentation device is missing: ${current.deviceId}`);
card._infoCard = device;
}
card.requestUpdate();
await card.updateComplete;
await frame();
if (current.devicePresentationPreview) {
const dialog = card.renderRoot.querySelector('hp-dialog');
const body = dialog?.querySelector('.body');
const preview = dialog?.querySelector('hp-device-preview');
await preview?.updateComplete;
if (!body || !preview)
throw new Error('documentation device presentation preview is missing');
const bodyRect = body.getBoundingClientRect();
const previewRect = preview.getBoundingClientRect();
body.scrollTop += previewRect.top - bodyRect.top - 180;
await frame();
const visibleBody = body.getBoundingClientRect();
const visiblePreview = preview.getBoundingClientRect();
if (visiblePreview.top < visibleBody.top - 1 || visiblePreview.bottom > visibleBody.bottom + 1)
throw new Error('documentation viewport does not show the device presentation preview');
}
return { dialog: !!card.renderRoot.querySelector('hp-dialog') };
}, scenario);
mkdirSync(OUTPUT, { recursive: true });
copyFileSync(BUNDLE, DEMO_BUNDLE);
copyFileSync(BUNDLE, INTEGRATION_BUNDLE);
const { page, browser } = await launch();
const browserErrors = [];
page.on('pageerror', (error) => browserErrors.push(error.message));
try {
const fingerprint = await assertFreshDemoBundle(page, ROOT);
const scenarios = {};
for (const scenario of DOC_SCREENSHOTS) {
await prepareGoldenScenario(page, scenario);
const runtime = await applyDocumentationState(page, scenario);
if (scenario.expectDialog && !runtime.dialog)
throw new Error(`documentation scenario did not open its dialog: ${scenario.id}`);
const clip = scenario.capture === 'room-card'
? await roomCardClip(page)
: await goldenClip(page, scenario.capture);
const image = await page.screenshot({
...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css',
});
writeFileSync(resolve(OUTPUT, scenario.file), image);
scenarios[scenario.id] = {
file: scenario.file,
viewport: scenario.viewport,
theme: scenario.theme,
language: scenario.language,
sourceSha256: fingerprint,
imageSha256: sha256(image),
};
console.log(`captured ${scenario.id} -> docs/images/${scenario.file}`);
}
if (browserErrors.length) throw new Error(`browser errors: ${browserErrors.join(' | ')}`);
const manifest = {
version: DOC_SCREENSHOT_VERSION,
fixture: 'synthetic-only',
sourceFingerprint: fingerprint,
captureScriptSha256: sha256(readFileSync(SCRIPT)),
command: 'npm run build && node demo/docs/capture.mjs',
scenarios,
};
writeFileSync(resolve(OUTPUT, 'screenshots.json'), `${JSON.stringify(manifest, null, 2)}\n`);
} finally {
await browser.close();
}
+59
View File
@@ -0,0 +1,59 @@
// AC-13 for #157. Usage:
// node demo/downgrade_open_passage.mjs --bundle=/absolute/v1.64.0/dist/houseplan-card.js
// The v1.64.0 frontend does not understand `passage`; this executable fixture
// pins its documented best-effort fallback (door symbol) and, critically,
// rejects any pageerror/unhandled exception while reading the newer literal.
import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { launch, checkAll, finish } from './serve.mjs';
const value = process.argv.find((arg) => arg.startsWith('--bundle='))?.slice('--bundle='.length);
if (!value) {
console.error('usage: node demo/downgrade_open_passage.mjs --bundle=/absolute/v1.64.0/houseplan-card.js');
process.exit(2);
}
const bundle = isAbsolute(value) ? value : resolve(value);
if (!existsSync(bundle)) {
console.error(`v1.64.0 bundle not found: ${bundle}`);
process.exit(2);
}
const currentDemo = fileURLToPath(new URL('./srv', import.meta.url));
const serveRoot = mkdtempSync(join(tmpdir(), 'hp-157-downgrade-'));
let browser;
try {
cpSync(currentDemo, serveRoot, { recursive: true });
cpSync(bundle, join(serveRoot, 'assets', 'houseplan-card.js'));
const launched = await launch(undefined, 1, [], {}, serveRoot);
browser = launched.browser;
const out = await launched.page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const space = card._serverCfg.spaces.find((item) => item.id === card._space);
space.openings = [{
id: 'future-passage', type: 'passage', x: 0.3, y: 0.14,
angle: 0, length: 0.09, future_material: 'stone',
}];
card._setMode('plan');
card._cfgEpoch++;
card.requestUpdate();
await card.updateComplete;
await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)));
const opening = root().querySelector('[data-hp="opening"][data-id="future-passage"]');
const stored = space.openings[0];
return {
newerLiteralLoads: !!opening,
documentedDoorFallback: !!opening?.querySelector('.op-leaf,.op-arc'),
readDoesNotRewriteConfig: stored.type === 'passage'
&& stored.future_material === 'stone' && space.openings.length === 1,
};
});
checkAll(out);
await finish(browser, out);
browser = undefined;
} finally {
await browser?.close?.();
rmSync(serveRoot, { recursive: true, force: true });
}
+75 -6
View File
@@ -57,6 +57,11 @@ const lightingRooms = [
poly: [[0.50, 0.10], [0.93, 0.10], [0.93, 0.88], [0.50, 0.88]] },
];
const applianceRooms = [
{ id: 'appliance-room', name: 'Laundry', area: 'golden_appliance',
poly: [[0.08, 0.10], [0.92, 0.10], [0.92, 0.90], [0.08, 0.90]] },
];
const geometrySpace = {
id: 'golden-geometry',
title: 'Geometry matrix',
@@ -122,7 +127,25 @@ const lightingSpace = {
decor: [],
};
const runtime = () => {
const applianceSpace = {
id: 'golden-appliance',
title: 'Appliance lifecycle',
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: {
fill_mode: 'none', glow_enabled: false, show_borders: true, show_names: true,
sun_rays: false, bg_mode: 'static',
},
rooms: applianceRooms,
walls: wallsFor('appliance', applianceRooms, 15),
openings: [],
partitions: [],
wall_columns: [],
decor: [],
};
const runtime = (includeAppliance = false) => {
const devices = {};
const entities = {};
const states = {
@@ -136,7 +159,8 @@ const runtime = () => {
// The production projection must preserve them.
const layout = {};
const areas = Object.fromEntries(
[...geometryRooms, ...lightingRooms].map((room) => [room.area, { area_id: room.area, name: room.name }]),
[...geometryRooms, ...lightingRooms, ...(includeAppliance ? applianceRooms : [])]
.map((room) => [room.area, { area_id: room.area, name: room.name }]),
);
const add = (id, domain, area, x, y, state, attributes = {}) => {
const entityId = `${domain}.${id.replaceAll('-', '_')}`;
@@ -171,6 +195,44 @@ const runtime = () => {
add('golden-right-linkquality', 'sensor', 'golden_light_right', 0.66, 0.70, '190', {
unit_of_measurement: 'lqi',
});
if (includeAppliance) {
const washerId = 'golden-washer';
devices[washerId] = {
id: washerId,
name: 'Golden washing machine',
model: 'GOLDEN-WASHER-COMPOSITE',
area_id: 'golden_appliance',
identifiers: [['houseplan_golden', washerId]],
config_entries: ['golden_entry'],
entry_type: null,
via_device_id: null,
disabled_by: null,
};
const addWasherEntity = (entityId, state, attributes = {}, registry = {}) => {
entities[entityId] = {
entity_id: entityId,
device_id: washerId,
platform: 'houseplan_golden',
config_entry_id: 'golden_entry',
disabled_by: null,
...registry,
};
states[entityId] = {
entity_id: entityId,
state,
attributes: { friendly_name: registry.original_name || entityId, ...attributes },
};
};
addWasherEntity('switch.golden_washer_power', 'on', {}, { original_name: 'Power' });
addWasherEntity('switch.golden_washer_child_lock', 'off', {}, { original_name: 'Child lock' });
addWasherEntity('sensor.golden_washer_status', 'done', {}, {
original_name: 'Status', translation_key: 'status',
});
addWasherEntity('sensor.golden_washer_stage', 'Rinse', {}, { original_name: 'Stage' });
addWasherEntity('sensor.golden_washer_program', 'mixed_wash', {}, { original_name: 'Program' });
layout[washerId] = { s: 'golden-appliance', x: 0.5, y: 0.5 };
}
return { devices, entities, states, layout, areas };
};
@@ -182,9 +244,12 @@ export const VISUAL_MATRIX_COUNTS = Object.freeze({
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
});
export const makeVisualMatrixFixture = () => ({
export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) => ({
config: {
spaces: [structuredClone(geometrySpace), structuredClone(lightingSpace)],
spaces: [
structuredClone(geometrySpace), structuredClone(lightingSpace),
...(applianceLifecycle ? [structuredClone(applianceSpace)] : []),
],
// A persisted marker is part of the fixture contract for scenarios that
// override per-source Glow controls. The device/layout alone are not a
// saved marker configuration and must not be silently treated as one.
@@ -201,6 +266,10 @@ export const makeVisualMatrixFixture = () => ({
},
},
},
...runtime(),
counts: VISUAL_MATRIX_COUNTS,
...runtime(applianceLifecycle),
counts: applianceLifecycle ? {
...VISUAL_MATRIX_COUNTS,
spaces: VISUAL_MATRIX_COUNTS.spaces + 1,
rooms: VISUAL_MATRIX_COUNTS.rooms + applianceRooms.length,
} : VISUAL_MATRIX_COUNTS,
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 59 KiB

+86 -49
View File
@@ -1,56 +1,93 @@
{
"schema": 1,
"matrixVersion": 17,
"acceptedAt": "2026-08-13T14:30:05.929Z",
"sourceFingerprint": "66f31850eafc963848acdc4e56e363a364bbb9b189bf4ade73d3c111ae2fc375",
"matrixVersion": 34,
"acceptedAt": "2026-08-22T14:09:45.021Z",
"sourceFingerprint": "2d8295e95a0721f11075dd0388ab4960c79b480ae199a381d3bbd1086e5e5b34",
"chromium": "151.0.7922.34",
"scenarios": {
"isometric-geometry-view-dark": "6db601f322fe55e33a1a89fe62f5d3a7d53f12bac4a68c7911b12f6382ef4fca",
"isometric-geometry-view-light": "c4c005fa55f64280abf9fccccdf7dc8efc3497bd626572bc11f45cc3cddaa159",
"isometric-live-layers-dark": "869c62bf9cd762c36d342d1a3bbd992969425b46b132f3243439735e2c75c14f",
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
"isometric-touch-kiosk-dark": "36c83bbe39809f346ebb5a0f4ed633c938e2fed028defaa48097c281db8e23a3",
"isometric-large-warm-remount-dark": "798d312671dffebf59034a39f2865a65ece27a84b150e77bc59038bb2567d07c",
"geometry-view-dark-fit": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
"geometry-view-light-fit": "0c57dee930f30a1f9c16e4e704675156f57a7623a4edf839c14a4fecbf12881c",
"geometry-plan-editor-dark": "6b06213324c5ff50c7176451307a33d8ac31ee636c96698f8efe61e8db763bae",
"opening-placement-door-thick-wall-dark": "24f472818f2246eeaa6568648ca4a6c42d9d84723fbcdd879435253399e2995d",
"geometry-devices-editor-dark": "c850e83f1af747f063b895109fe26d6a5804356fab505b0976073c28ad146104",
"geometry-decor-editor-dark": "44a95fd0b397c2729fea65c750fedcb11df10042ae198f3690151ead077c84e1",
"tray-wide-selection-en": "468ac6acfd7fcdcbfa947e032843ff117a32ae01b4ec30937a6cddecff534504",
"tray-wide-tool-ru": "388e03d7bf7a2391d0581e8446d0692048b9792e80bf3b9dd9bca86222fe9418",
"tray-medium-group-en": "190d5c356461ad2aa8b63b132416e8958a285047f8dea92301213678c7ac5a92",
"tray-medium-selection-ru": "ab91e88583b2863305434ce2775e31328638414671ff0a4f8ee82c8274c04405",
"tray-narrow-palette-en": "fb63483e7101d457d7fb1c83ae50435410ed797771adcbfde14dc2798a000ce1",
"tray-narrow-tool-ru": "60ce3c75de88b72fdb5d3fe7a16790184a8265c74f0f97e5be4fd83dbe0259fd",
"geometry-diagonal-45-opening-dark": "3736a75163d47a71f60c45cd554d604f27cfbc81119b5b55e8134da61a2a0f0a",
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
"lighting-glow-sun-dark": "a98eee332f25a43c8d9d126c118c8cfea4ee73752b1060227548efedaf0efcdd",
"device-value-badge-positions-dark": "1ad43f2bd866733aa75c34de38fb97d469661799b540a8ae22150067d788cec8",
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
"isometric-geometry-view-dark": "9bc8eb0da8746bddc0b5245b337eef7477d4ce6fb39ac576cab1e49057fd837c",
"isometric-geometry-view-light": "144f1cc3107562bc252cdcb54165f2c92569f4cf3484554148a7fdb78de372ec",
"isometric-live-layers-dark": "01660f4cf3e7344a87c9acc9e80239757b0ef5f365a37257c4b147144399b7cc",
"isometric-no-borders-dark": "ac92e782e5f58e514c3731f742ead6939cde3344810fe2f2080876b3deb1335b",
"isometric-touch-kiosk-dark": "745482a797e236058e3ddd56cb5d85d700c9333a3aeb764333addeaba40cf6b0",
"isometric-large-warm-remount-dark": "37b47189e395d56e746501955a5b215298a3261a49c7f27e248b7957b2f31e46",
"geometry-view-dark-fit": "438817c56cd8f91ef63778a3d3d22a4ee1fb675064bc24bf613fb752d6c503d9",
"geometry-view-light-fit": "667d38fd55a946ea6930b8674c4036e75c261ba4f111f893367ae72ed19500fc",
"room-label-parity-view-dark": "3a636de579cd2d1de338d017ed6b53c3e2f70a901a0f5c81dfca85870d1b4bd5",
"room-label-parity-plan-dark": "e782f0386a6b1e75c7bb9f135e9ec01a9ff4b540f0384d518c7ea9f2198683ea",
"room-label-parity-view-light": "6eb2ba0023932c9b4e82a45b7ca9565a9ed1b89d14e543facbaf1f84aa5db9ff",
"room-label-parity-plan-light": "333139affbf8936554072aebcaf58b4640779adbd6a64686c0929ca071063733",
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
"day-cycle-dawn-dark": "289e6edd6c206308d3257775b2948085a24698398995bc6ce72201745705c58c",
"day-cycle-day-dark": "df27409e83f4492d3f69dc6cebf2a6e7799e154698425b17fb3ab8cfddb426a6",
"day-cycle-dusk-dark": "9798e9dac27e69727adbe9c9a782a3bd3dc5dd0850e7a49852c069d54b382f9a",
"day-cycle-night-dark": "1abf9528ac05dc3a963eb19a8b3ed4b7bb9ce647f7b83dab4fd2e736756cd770",
"geometry-plan-editor-dark": "a3828bdd877c23d283dcd30dc88287c2104c8d39e849aa826a76190d7daa30ae",
"plan-snap-endpoint-light": "2df0bd2abcb615d6bd1a4422b29747f95a1edd7b02d4ae3a6b333e24b27e9f78",
"plan-snap-line-gaps-dark": "a84364cf6bbb7b23fa9ec28ba8edbb03db7a4e401944b4a8e2c4a42871533bc3",
"wall-junctions-plan-preview-light": "f50afa3358d34552ef92947ec07403e3ae507b12881f688742f630e8e54ffe43",
"wall-junctions-plan-t-dark": "ec2b29ca87bba9b41e4c22ce94d2c4fe06c6d9c0833152d3a6224371763ae6bc",
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
"junction-patch-resilience-plan-dark": "c42203eea92d2bc972eed829aa726ad51f36fc76d0ae049730d0f4155ef8efbf",
"junction-patch-resilience-view-dark": "d531f2a01def73fb074e55543c8543112af158df65f24dc549e0da5a23f1af40",
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
"opening-placement-door-thick-wall-dark": "2bd1a6e3f7b676fa4820f67a9ed0f9f87c452c318711523cb22cab8750b6e7ba",
"opening-placement-passage-thick-wall-dark": "8b1955eaa2dd12f492d614d7642a71646d879810e52bd434adb5cfc5d1a36f18",
"opening-placement-passage-thick-wall-light": "96cc1d50db1e86d71ddfc55b89640b28d65c7315936f3af9e38b75c881dae7ef",
"geometry-devices-editor-dark": "2ec2647797c71f6c6406f6e33fcbf70d5cb6d36a01a65c70ecabc7ad11e86183",
"geometry-decor-editor-dark": "46798a59d99982c4e73596861e20b6e466f6e7aa1d70a26bf917e1bd3bf73cb1",
"tray-wide-selection-en": "457b53c4e5ae5f691457f98f3c2fd255e5b7ed1c599af92c303c93c11f87b7d4",
"tray-wide-tool-ru": "1fcc25ee374c95f51c71b33720daa37f82a92ccd52119b8bdb48de5484591e49",
"tray-medium-group-en": "3bebd852929ff67cff11827d09c4859e32e17fa0be36fcabd4a591c4728a4827",
"tray-medium-selection-ru": "e40dcdadf3ebdbec200175dc6648382daa91a232d081c8add1f6646c7d1a0ee5",
"tray-narrow-palette-en": "861adc403fbf0aff1e45b27fc07c4856f3ca409b58ef87cc4a81d870a09dbb9a",
"tray-narrow-tool-ru": "ccf11295b104cecd4bf1e1bc95417003e2c40cfba29b5555ac9ba021b90d16a6",
"geometry-diagonal-45-opening-dark": "dd93866af62313806a4444b707943c693e2b7709367156ebdc3b80168233bbe8",
"openings-thick-wall-dark": "0c33cdb4637ee9788e75931ba142099e1d068b770e26ca24f4a59f8631f3373f",
"openings-filled-tunnel-dark": "bb2a4343a285e2fd867c6090108589549facf87b1a9471f3ae331d743af6653c",
"openings-hidden-view-dark": "19ab4fd2ffce0fea0fe609b007a3e60f7abd84b40935cbf3c5fa9dc1fda21046",
"lighting-glow-sun-dark": "3f02abbcb3379b2e224ba7dd3126ec4cb736c0f71edbcdd2f2961c33d7d1b12a",
"device-value-badge-positions-dark": "831f04e8b97e4cb473e2c9e53976a5422dd3718eb74086ed2503ffc6d3f5ad87",
"device-icon-state-table-light": "b956debb1e3845c03b1a69ba1a45f65b682ea6ee058f40003657bb6f5ad09ee8",
"device-icon-state-table-dark": "bf3caed74f7832a142f730481e50f9e2706aeeee009b39118a9ac4d011954e7c",
"device-text-shell-long-light": "4ad4f05ebd24d65b98bd4c32f71184088062ae75a52a5a7cc54300ea1961b924",
"device-text-shell-long-dark": "f35d426dccd0705465bfedc112776df06233aaa5ffc37765dd71222787cfb3d6",
"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",
"lighting-temp-glow-dark": "ecaed039fb6aab4e1fdc9f1856c89e5f563219b8ff813877027737cecbeca41a",
"lighting-temp-glow-light": "5bc8a35aaa94c427d465d198f1cd5eecfdc704f5abeded9e407f32ce93aa7d32",
"lighting-custom-glow-dark": "899e334dfc0d3490ca291b7239bf7b88ca9d1ef3be199ddf347314bab1513f27",
"lighting-opaque-glow-two-doorways-dark": "413f5a8e39193ba941f72955a391ccac954c09f24322b7bf67494c7691275980",
"lighting-custom-glow-light": "266bba4ae1744a884b2cd224b37fdc36447d57405a1c5298ca30027e2957cc8a",
"lighting-temp-glow-no-sources-dark": "5100c81543fc30a7934a6db6f9e67e2c4fa185df0a974be5911879e43c0d3fc9",
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
"hover-nested-room-dark": "b70e385829a4fe45a77f5e1c3f8af4e18efec5282fb9594b26fab08c1f113d52",
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
"device-dialog-desktop-en": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
"decor-color-popover-mobile-ru": "16d2859d1ed3715c1c4d3d451a8428c37e91ba00da17272c59cf83420a7b6c31",
"backup-full-preview-desktop-en": "7f12943d1027c89f6fe46978fa1f4e1bcdbf85a1216281d850d467e68f52cbda",
"backup-space-preview-mobile-ru": "998c6b52c1cc95109feb440a9966cade738154ceeae387246d40f8c56f9e8a3a"
"lighting-fill-light-axis-split-dark": "4cb3f7591f005768d1d8e42f651184a7317b439318302d6bc48fcca000266c82",
"lighting-fill-temp-axis-split-dark": "47383a2e81b34cd7cf385a0f33efe755906c1cb20de8345dc69f599597378801",
"lighting-fill-lqi-axis-split-dark": "480b518fb2da24fc7d70297a4456a92485f5f5629a6bc951a36eedcc7631e5dc",
"lighting-temp-glow-dark": "a5c8f7b981d4f310ca8cf6c19f72236dc29abdd88a1d60be6ef916dd9091f7b8",
"lighting-temp-glow-light": "86620d7b68e797469fbec36438bf2725bd913bc8b4641dbe75c03098210d17f7",
"lighting-custom-glow-dark": "86a1ba7ac8e6bc9515a4df550065c60fd600ce6898bdc5447122c9fc87fb3965",
"lighting-opaque-glow-two-doorways-dark": "0cff53678f935bb9cee4570a47bf34a05d71b1fd512d76158e846c0b1aa7a5c5",
"lighting-custom-glow-light": "2568f1ca7be270faabd2c97d955fd6e27dec00e921d8e6f21ab7425a86e7f958",
"lighting-temp-glow-no-sources-dark": "ee37a7dda19a0bfe02405d4e9cf61023cecb623896388a4197adc4a5afe6b584",
"lighting-temp-glow-room-override-dark": "fa20be4d2a29710143f437d5049c6e71959c6a56df2a12a9d2e9728e7833d9ed",
"lighting-manual-auto-spill-overlap-dark": "9bb31a72bfa073eae4630ea5edf01b5a7dbb1885eb25153c4bf3b2574fc6e5c5",
"hover-over-glow-dark": "d505d1514e492dd3f5e299ebace0392e14734f21626310a8efdc0d148a00ddf0",
"hover-nested-room-dark": "2db78e53a76fa9b7cdc4597f23fc2c8ae439e5a56d82895109ca968d73075640",
"large-house-zoom-040-dark": "a21faa9eafea706cf8d6366e93f526a8f83798b6b6cf058ee43010607182677d",
"large-house-zoom-250-dark": "48b13045d83ac83bc35aba7182057ec56023ac448ad57f6b7dc9981349cea563",
"large-house-warm-remount-dark": "99ef82407bd148cef968e99a593f25da06da2df7363af842f617113349c7ee85",
"device-dialog-desktop-en": "ea341f6ea9db5f61bbcb6fd7ca5078fd2f347023de4279cb70bf190c1379c364",
"device-dialog-mobile-ru": "c90be98e65c37963fd4443e413848ca68a412c9ee568d648aba76bf9232ee59d",
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
"device-help-popover-light-ru": "c74c83ecfb4ee6cd693d87d4516e9ee4920ad453f5b7b9606cd98853b7273568",
"decor-color-popover-mobile-ru": "af002e4c3106c008bbdb58ed1f00d8f8415dc86a8dfab2552defb4d16d8bbe71",
"decor-color-popover-desktop-en": "11189416fd3752310979444aa92ce38764996655871009ff028ba9b4b31959a8",
"general-color-popover-desktop-en": "da85d0159802afce90fd87ecf940ef1b97ffd57c27fbdc79469cc11082b9a482",
"device-ripple-color-popover-mobile-ru": "3ab12c339106004c76fe3c7741d242269e157c40ddfa29afd8f13b445b0a7023",
"space-room-color-popover-desktop-ru": "a5d62d68097af1ff5d2556be0c889efc25768ffc61af5d302f0b5775e082f296",
"backup-full-preview-desktop-en": "6f0cfecf587b73f38088d414f489b67cc68ed71c37c96d4e307e66f6d97bda9f",
"backup-plan-only-export-desktop-en": "1e1c8a9cc5383394b91e24af8342d92103a5e85b3e5c9b53c45ab0e9b0d57da4",
"backup-space-preview-mobile-ru": "a4719cbe29b378ef7baa63bb7ff23e201b2943025008d939f2c08cee63bb9038",
"optimize-preflight-dialog-dark-en": "9f6e35897adba06648d448e53755410cc831311038300affae396a396388b3d1",
"optimize-preflight-dialog-light-ru": "cbfca454d9eab95aaab1b12330b3833c3eae21eac735eb2c09529082c5211abe"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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