Compare commits

...
Author SHA1 Message Date
Sergey Matyunin 2523c38a4e Merge issue #300 into dev
Owner-approved review exception: the external reviewer is unavailable. The exact branch SHA passed its recorded gates; the integration keeps #296 diagnostic geometry above masonry and #300 resize measurements above wall bodies. Generated bundles were rebuilt from the combined sources.

Issue: #300
User-Visible: no
2026-08-24 23:14:34 +03:00
Sergey Matyunin 5ecfebcf67 Merge issue #298 into dev
Owner-approved review exception: the external reviewer is unavailable. Both r1 High findings were fixed, the exact branch SHA passed all recorded gates, and generated bundles were rebuilt after conflict resolution with #296.

Issue: #298
User-Visible: no
2026-08-24 23:12:16 +03:00
Sergey Matyunin e13e8ff9e9 Merge issue #296 into dev
Owner-approved review exception: the external reviewer is unavailable. The exact branch SHA passed its implementation gates and the issue records the evidence.

Issue: #296
User-Visible: no
2026-08-24 23:10:33 +03:00
Sergey Matyunin 01992a9a87 test: accept #298 documentation screenshots
Issue: #298
User-Visible: no
2026-08-24 23:05:59 +03:00
Sergey Matyunin 2d19ce04ad fix: close #298 review gaps
Issue: #298
User-Visible: no
2026-08-24 23:04:32 +03:00
Sergey Matyunin 390f77bd65 feat: улучшить измерения при Resize
Issue: #300
User-Visible: yes
2026-08-24 22:59:33 +03:00
claude[bot] d5670ad141 docs: review document for #298
Issue: #298
User-Visible: no
2026-08-24 19:54:40 +00:00
Sergey Matyunin 3cf8ed74db test: accept #296 documentation screenshots
Issue: #296
User-Visible: no
2026-08-24 22:53:07 +03:00
Sergey Matyunin b9db5d4750 fix: close #296 review gaps
Issue: #296
User-Visible: yes
2026-08-24 22:50:00 +03:00
Sergey Matyunin 2cad797d2c fix: preserve legacy resize lattice endpoints
Issue: #298
User-Visible: no
2026-08-24 22:41:14 +03:00
claude[bot] e4dfef19e1 docs: review document for #300
Issue: #300
User-Visible: no
2026-08-24 19:38:50 +00:00
claude[bot] 8d165fd1c2 docs: review document for #296
Issue: #296
User-Visible: no
2026-08-24 19:37:27 +00:00
Sergey Matyunin 23d208f30c docs: согласовать footprint кнопки с zoom
Issue: #300
User-Visible: no
2026-08-24 22:34:40 +03:00
claude[bot] e4139da3e6 docs: review document for #300
Issue: #300
User-Visible: no
2026-08-24 19:33:00 +00:00
Sergey Matyunin 3fb2edc904 fix: preserve resize wall thickness carriers
Issue: #298
User-Visible: yes
2026-08-24 22:27:05 +03:00
Sergey Matyunin 4969f46e0e docs: учесть масштаб кнопки комнаты
Issue: #300
User-Visible: no
2026-08-24 22:26:11 +03:00
claude[bot] ed28951b9d docs: review document for #300
Issue: #300
User-Visible: no
2026-08-24 19:25:23 +00:00
Sergey Matyunin 2b7c98e6bc docs: уточнить раскладку подписей Resize
Issue: #300
User-Visible: no
2026-08-24 22:18:52 +03:00
claude[bot] 8187dd9bda docs: review document for #300
Issue: #300
User-Visible: no
2026-08-24 19:15:06 +00:00
Sergey Matyuninandclaude[bot] 89ac96a8af fix: reconcile hidden walls during Optimize
Issue: #296
User-Visible: yes
2026-08-24 19:10:27 +00:00
claude[bot] 033ad11bf7 docs: review document for #296
Issue: #296
User-Visible: no
2026-08-24 19:10:27 +00:00
Sergey Matyuninandclaude[bot] f7a19a35f1 docs(spec): define hidden obstacle optimization
Issue: #296
User-Visible: no
2026-08-24 19:10:27 +00:00
claude[bot] 451932c8e9 docs: review document for #298
Issue: #298
User-Visible: no
2026-08-24 19:08:31 +00:00
Sergey Matyunin ab9a83e755 docs: спецификация подписей Resize
Issue: #300
User-Visible: no
2026-08-24 22:08:17 +03:00
Sergey Matyunin 55db16df6e docs(spec): add resize user contract
Issue: #298
User-Visible: no
2026-08-24 22:02:46 +03:00
claude[bot] 8221ad9b84 docs: review document for #298
Issue: #298
User-Visible: no
2026-08-24 19:01:30 +00:00
Sergey Matyunin 45066631ae docs(spec): define resize wall carrier contract
Issue: #298
User-Visible: no
2026-08-24 21:53:54 +03:00
Claude 4feeebc355 test: обход правок собирает test-build сам, как чистый CI и требует
Смок импортировал `../test-build/*` статически и на моей стороне работал только
потому, что каталог остался от `npm test`. На чистом Linux CI его нет: в job
`smoke` идут `npm ci` и `npm run bundle:sync`, сборки тестов там не бывает, и
смок падал на импорте до запуска браузера.

Ровно эту ошибку уже проходил `smoke_lattice_write_barrier.mjs` — там об этом и
написано в комментарии. Лечение то же: собрать `tsconfig.test.json` перед
динамическим импортом.

Проверено в чистом worktree без `test-build`: шесть прогонов, все совпадают с
таблицей KNOWN, OK.

Issue: #297
User-Visible: no
2026-08-24 21:27:38 +03:00
claude[bot] 158601d67d docs: review document for #294
Issue: #294
User-Visible: no
2026-08-24 18:25:12 +00:00
Sergey Matyuninandclaude[bot] 671d1af4c3 test: stabilize wall Escape smoke and accept docs screenshot
Issue: #294
User-Visible: no
2026-08-24 18:16:46 +00:00
claude[bot] 67777e47c0 docs: review document for #294
Issue: #294
User-Visible: no
2026-08-24 18:16:46 +00:00
Sergey Matyuninandclaude[bot] 953f6756bc feat: detach wall chain on Escape
Issue: #294
User-Visible: yes
2026-08-24 18:16:46 +00:00
claude[bot] 578eb5ebd3 docs: review document for #294
Issue: #294
User-Visible: no
2026-08-24 18:16:46 +00:00
Sergey Matyuninandclaude[bot] 874838c954 docs: close wall Escape documentation gaps
Issue: #294
User-Visible: no
2026-08-24 18:16:46 +00:00
claude[bot] 0ac92cb551 docs: review document for #294
Issue: #294
User-Visible: no
2026-08-24 18:16:46 +00:00
Sergey Matyuninandclaude[bot] 142ec8d1c7 docs: specify wall chain detach on Escape
Issue: #294
User-Visible: no
2026-08-24 18:16:46 +00:00
Claude 1816042a1a test: обход последовательностей правок с инвариантами после каждого жеста
Все прежние гейты проверяют снимок модели. Дефекты геометрии рождаются в
редактировании: #289, #296 и #298 прошли решётку, кладку, роли, ключи и аудит
ручек, потому что такая геометрия снимок не портит — она портит следующий жест.

demo/smoke_edit_walk.mjs расшатывает реальный план продуктовыми жестами
(_rszEdgeDown/_rszMove/_rszUp, _confirmRoomDelete, optimizePlans) по фиксированному
семени и после каждого шага судит конфиг в node. Второго представления редактора
не появляется — принцип #292.

Подшаговый шум остаётся наблюдением, а не нарушением: координата пишется девятью
знаками, 304/240 = 1.266666667, отклонение 8e-8 шага неустранимо форматом
хранения и уйдёт на этапе 1 ADR #282. Судится только «вне сетки».

Таблица KNOWN работает в обе стороны: обход падает и когда находок больше, и
когда меньше. Молча позеленевший гейт не сообщает о починке — так
partition-mt2on9ou-0 прожил в плане владельца от беты 9 до rc.1.

Новый инвариант checkHiddenObstacles: перегородка на стене комнаты, незакрытый
контур на стене комнаты, черновик, который не может стать комнатой (#296).

Найдено сразу, в пяти прогонах из шести — на первом жесте: #298 (ресайз уводит
конец записи толщины мимо решётки и мимо ребра), #299 («Оптимизировать» и
удаление комнаты сливают записи через границу роли).

Issue: #297
User-Visible: no
2026-08-24 21:14:30 +03:00
Sergey Matyunin 8c03656412 build: continue beta.10 candidate as v1.67.0-rc.1
Issue: #291
User-Visible: yes
2026-08-24 19:24:59 +03:00
Sergey Matyunin fc81bd8a32 test: accept beta.10 lattice wall-union goldens
Issue: #291
User-Visible: no
Release: v1.67.0-beta.10
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32748758050
2026-08-24 19:13:22 +03:00
Sergey Matyunin 74d306b510 test: make lattice and Optimize smokes CI-clean
Issue: #291
User-Visible: no
2026-08-24 19:13:21 +03:00
Sergey Matyunin 80dc5860ca test: accept beta.10 docs fingerprint
Issue: #291
User-Visible: no
2026-08-24 19:05:03 +03:00
Sergey Matyunin 04110bf741 build: prepare v1.67.0-beta.10 candidate
Issue: #288
Issue: #289
Issue: #290
Issue: #291
Issue: #292
Issue: #293
User-Visible: yes
2026-08-24 19:03:02 +03:00
claude[bot] 55fed02d24 docs: review document for #292
Validate / process-workflow-sync (push) Successful in 29s
Validate / provenance (push) Successful in 39s
Validate / changes (push) Successful in 42s
Validate / process-gate (push) Failing after 45s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 43s
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke (3) (push) Skipped
Validate / smoke_done (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Validate / docs (push) Successful in 31s
Issue: #292
User-Visible: no
2026-08-24 15:46:52 +00:00
Sergey Matyunin 2fda6fa086 docs: accept resize audit screenshot fingerprint
Issue: #292
User-Visible: no
2026-08-24 18:25:22 +03:00
Sergey Matyunin cd88633535 fix: audit resize availability on real plans
Issue: #292
User-Visible: yes
2026-08-24 18:21:50 +03:00
claude[bot]andSergey Matyunin 314b498e12 docs: review document for #292
Issue: #292
User-Visible: no
2026-08-24 18:11:08 +03:00
Sergey Matyunin e03238ec1c docs: clarify resize reasons and rollback
Issue: #292
User-Visible: no
2026-08-24 18:11:08 +03:00
claude[bot]andSergey Matyunin 474dbd62ca docs: review document for #292
Issue: #292
User-Visible: no
2026-08-24 18:11:08 +03:00
Sergey Matyunin e1f0d2310a docs: specify resize availability audit
Issue: #292
User-Visible: no
2026-08-24 18:11:08 +03:00
claude[bot] 1d4bf9b018 docs: review document for #293
Validate / docs (push) Failing after 25s
Validate / process-workflow-sync (push) Successful in 33s
Validate / provenance (push) Successful in 45s
Validate / changes (push) Successful in 37s
Validate / process-gate (push) Failing after 42s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 42s
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke (3) (push) Skipped
Validate / smoke_done (push) Skipped
Validate / backend (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Issue: #293
User-Visible: no
2026-08-24 15:10:36 +00:00
Sergey Matyunin 0a52b6757e docs: accept screenshots after resize rebase
Issue: #293
User-Visible: no
2026-08-24 17:52:44 +03:00
Sergey Matyunin 97acb0b415 test: require resize capture past hit area
Issue: #293
User-Visible: no
2026-08-24 17:49:48 +03:00
Sergey Matyunin e2c1dbec0d fix: preserve resize undo through write repair
Issue: #293
User-Visible: yes
2026-08-24 17:46:20 +03:00
Sergey Matyunin 08dce8603d test: cover both resize commit barriers
Issue: #293
User-Visible: no
2026-08-24 17:35:45 +03:00
Sergey Matyunin c4694acea9 test: harden resize capture verification
Issue: #293
User-Visible: no
2026-08-24 17:35:45 +03:00
Sergey Matyunin 6c5163c3c9 fix: restore resize pointer gesture
Issue: #293
User-Visible: yes
2026-08-24 17:35:45 +03:00
claude[bot]andSergey Matyunin f2e8fe5e93 docs: review document for #293
Issue: #293
User-Visible: no
2026-08-24 17:35:11 +03:00
Sergey Matyunin 8750708c5b docs: add risks and rollback to resize pointer spec
Issue: #293
User-Visible: no
2026-08-24 17:35:11 +03:00
claude[bot]andSergey Matyunin e6e80934e0 docs: review document for #293
Issue: #293
User-Visible: no
2026-08-24 17:35:11 +03:00
Sergey Matyunin 8eb351baa3 docs: specify resize pointer recovery
Issue: #293
User-Visible: no
2026-08-24 17:35:11 +03:00
claude[bot] a4ef0e2088 docs: review document for #291
Validate / docs (push) Successful in 23s
Validate / process-workflow-sync (push) Successful in 39s
Validate / provenance (push) Successful in 46s
Validate / process-gate (push) Failing after 44s
Validate / changes (push) Successful in 34s
Validate / frontend (push) Skipped
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / reuse (push) Successful in 46s
Validate / smoke (3) (push) Skipped
Validate / golden (push) Skipped
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke_done (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Issue: #291
User-Visible: no
2026-08-24 14:34:43 +00:00
Sergey Matyunin 3c754729f2 docs: accept screenshots after lattice rebase
Issue: #291
User-Visible: no
2026-08-24 17:16:23 +03:00
Sergey Matyunin 3f6338889e test: prove lattice barrier across editor controllers
Issue: #291
User-Visible: no
2026-08-24 17:13:28 +03:00
claude[bot]andSergey Matyunin 35ab1dc0c3 docs: review document for #291
Issue: #291
User-Visible: no
2026-08-24 17:13:28 +03:00
Sergey Matyunin 4ccf4a3ccc fix: enforce exact lattice coordinates
Issue: #291
User-Visible: yes
2026-08-24 17:13:21 +03:00
Sergey Matyunin c8a3878568 docs: define lattice shift precision
Issue: #291
User-Visible: no
2026-08-24 17:11:27 +03:00
claude[bot]andSergey Matyunin 0146d5787e docs: review document for #291
Issue: #291
User-Visible: no
2026-08-24 17:11:27 +03:00
Sergey Matyunin feff436942 docs: define lattice report translations
Issue: #291
User-Visible: no
2026-08-24 17:11:27 +03:00
claude[bot]andSergey Matyunin 374081b490 docs: review document for #291
Issue: #291
User-Visible: no
2026-08-24 17:11:27 +03:00
Sergey Matyunin e9bf11a8e3 docs: record optimize report decision
Issue: #291
User-Visible: no
2026-08-24 17:11:27 +03:00
Sergey Matyunin 66193c5862 docs: add product context and rollback
Issue: #291
User-Visible: no
2026-08-24 17:11:27 +03:00
claude[bot]andSergey Matyunin 894a4e5803 docs: review document for #291
Issue: #291
User-Visible: no
2026-08-24 17:11:26 +03:00
Sergey Matyunin 1d220628ff docs: specify lattice coordinate write barrier
Issue: #291
User-Visible: no
2026-08-24 17:11:26 +03:00
claude[bot] 52c74301f9 docs: review document for #290
Validate / docs (push) Successful in 28s
Validate / process-workflow-sync (push) Successful in 36s
Validate / provenance (push) Successful in 40s
Validate / process-gate (push) Failing after 40s
Validate / changes (push) Successful in 37s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 47s
Validate / smoke (2) (push) Skipped
Validate / smoke (3) (push) Skipped
Validate / smoke_done (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Validate / smoke (1) (push) Skipped
Issue: #290
User-Visible: no
2026-08-24 14:11:02 +00:00
Sergey Matyunin 01512081db test: enforce shared near-axis threshold source
Issue: #290
User-Visible: no
2026-08-24 17:02:48 +03:00
claude[bot] 1245beaaf6 docs: review document for #290
Issue: #290
User-Visible: no
2026-08-24 14:01:23 +00:00
Sergey Matyunin d33ae88da4 docs: accept screenshots after near-axis rebase
Issue: #290
User-Visible: no
2026-08-24 16:33:20 +03:00
claude[bot]andSergey Matyunin f6cb957bba docs: review document for #290
Issue: #290
User-Visible: no
2026-08-24 16:30:43 +03:00
Sergey Matyunin f44df5d05d test: reconcile near-axis resize proof
Issue: #290
User-Visible: no
2026-08-24 16:30:25 +03:00
claude[bot]andSergey Matyunin 6983f2b088 docs: review document for #290
Issue: #290
User-Visible: no
2026-08-24 16:30:25 +03:00
Sergey Matyunin f6cf2c93ae fix: scope near-axis resize validation
Keep unrelated pre-existing near-axis edges from disabling exact Resize handles, make tracked single-space fixtures visible to the invariants CLI, and add the three missing mutation gates plus real-plan coverage.

Issue: #290
User-Visible: no
2026-08-24 16:30:25 +03:00
Sergey Matyunin edf8b068ed fix: straighten near-axis wall geometry
Issue: #290
User-Visible: yes
2026-08-24 16:30:11 +03:00
claude[bot]andSergey Matyunin 6551c5d53d docs: review document for #290
Issue: #290
User-Visible: no
2026-08-24 16:29:51 +03:00
Sergey Matyunin e102f34156 docs: add real-plan proof and rollback
Issue: #290
User-Visible: no
2026-08-24 16:29:51 +03:00
claude[bot]andSergey Matyunin 7fd9ca0f6a docs: review document for #290
Issue: #290
User-Visible: no
2026-08-24 16:29:50 +03:00
Sergey Matyunin 6e1007a363 docs: specify near-axis authoring and repair
Issue: #290
User-Visible: no
2026-08-24 16:29:50 +03:00
claude[bot] 10fa0dc7fb docs: review document for #288
Validate / process-gate (push) Failing after 44s
Validate / changes (push) Successful in 29s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 1m0s
Validate / smoke_done (push) Skipped
Validate / backend (push) Skipped
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke (3) (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / docs (push) Failing after 39s
Validate / process-workflow-sync (push) Successful in 38s
Validate / provenance (push) Successful in 43s
Issue: #288
User-Visible: no
2026-08-24 13:14:55 +00:00
Sergey Matyunin f2c0721a9d docs: recapture screenshots after corridor integration
Issue: #288
User-Visible: no
2026-08-24 16:02:03 +03:00
claude[bot]andSergey Matyunin e8a4db7972 docs: review document for #288
Issue: #288
User-Visible: no
2026-08-24 15:57:49 +03:00
Sergey Matyunin 406e46ecb1 docs: recapture screenshots after fixture repair
Issue: #288
User-Visible: no
2026-08-24 15:57:49 +03:00
claude[bot]andSergey Matyunin 4201644d33 docs: review document for #288
Issue: #288
User-Visible: no
2026-08-24 15:57:25 +03:00
Sergey Matyunin 17086d3cf2 fix: preserve walls beyond short junction rays
Issue: #288
User-Visible: yes
2026-08-24 15:57:25 +03:00
claude[bot]andSergey Matyunin 74fdf2182c docs: review document for #288
Issue: #288
User-Visible: no
2026-08-24 15:56:48 +03:00
Sergey Matyunin 2d8ade3b2b docs: add risks and rollback to junction spec
Issue: #288
User-Visible: no
2026-08-24 15:56:48 +03:00
claude[bot]andSergey Matyunin 6f67d53665 docs: review document for #288
Issue: #288
User-Visible: no
2026-08-24 15:56:48 +03:00
Sergey Matyunin e333f0b2f1 docs: specify bounded multiwall corridor
Issue: #288
User-Visible: no
2026-08-24 15:56:48 +03:00
claude[bot] f472af88a3 docs: review document for #289
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke (3) (push) Skipped
Validate / smoke_done (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Validate / docs (push) Failing after 23s
Validate / process-workflow-sync (push) Successful in 37s
Validate / provenance (push) Successful in 56s
Validate / process-gate (push) Failing after 57s
Validate / changes (push) Successful in 44s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 36s
Issue: #289
User-Visible: no
2026-08-24 12:50:45 +00:00
Sergey Matyunin a7618151cc test: accept safe resize baselines
Accepted the complete Linux artifact after visual review. Only the dark and light safe-resize handle views change intentionally; all other passing scenarios keep their prior bytes.

Issue: #289
User-Visible: no
Release: v1.67.0-beta.10
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32726613816
2026-08-24 15:31:26 +03:00
Sergey Matyunin 10ca913fad test: keep resize smokes self-contained
Issue: #289
User-Visible: no
2026-08-24 15:30:23 +03:00
claude[bot]andSergey Matyunin 849b3e5f76 docs: review document for #289
Issue: #289
User-Visible: no
2026-08-24 15:19:47 +03:00
Sergey Matyunin bca3bfd52c fix: prevent mixed-role walls during resize
Issue: #289
User-Visible: yes
2026-08-24 15:19:40 +03:00
claude[bot]andSergey Matyunin 2ad20dca2b docs: review document for #289
Issue: #289
User-Visible: no
2026-08-24 15:19:20 +03:00
Sergey Matyunin 0bca6dfb14 docs: add evidence and rollback to resize spec
Issue: #289
User-Visible: no
2026-08-24 15:19:20 +03:00
claude[bot]andSergey Matyunin 8877e8c6d6 docs: review document for #289
Issue: #289
User-Visible: no
2026-08-24 15:19:20 +03:00
Sergey Matyunin 6a4e665d33 docs: specify mixed-role resize guard
Issue: #289
User-Visible: no
2026-08-24 15:19:20 +03:00
Sergey Matyunin 5272287671 test: accept fixture wall key baselines
Validate / docs (push) Failing after 32s
Validate / process-workflow-sync (push) Successful in 32s
Validate / frontend (push) Successful in 8m9s
Validate / provenance (push) Successful in 47s
Validate / process-gate (push) Successful in 40s
Validate / changes (push) Successful in 34s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / reuse (push) Successful in 48s
Validate / smoke (1) (push) Failing after 20m38s
Validate / backend (push) Skipped
Validate / smoke (3) (push) Failing after 22m20s
Validate / smoke (2) (push) Failing after 22m32s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 22m59s
Validate / performance_smoke (push) Failing after 19m3s
Accepted the complete Linux artifact after visual review. Only the dark and light isometric geometry views change intentionally; 106 passing scenarios keep their prior bytes.

Issue: #260
User-Visible: no
Release: v1.67.0-beta.10
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32725286757
2026-08-24 15:13:41 +03:00
ClaudeandSergey Matyunin c14dcecb98 test(fixtures): write the real wall key in one place
Validate / smoke (3) (push) Failing after 18m12s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 20m2s
Validate / performance_smoke (push) Failing after 25m25s
Validate / docs (push) Failing after 25s
Validate / process-workflow-sync (push) Successful in 32s
Validate / provenance (push) Successful in 42s
Validate / process-gate (push) Failing after 41s
Validate / changes (push) Successful in 32s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / reuse (push) Successful in 46s
Validate / backend (push) Skipped
Validate / frontend (push) Successful in 9m15s
Validate / smoke (1) (push) Failing after 18m47s
Validate / smoke (2) (push) Failing after 19m9s
Refresh the shared wall-key fixture contract and keep generated frontend bundles synchronized with the fingerprinted geometry fixtures.

Issue: #260
User-Visible: no
2026-08-24 15:06:06 +03:00
Claude 523190d8f4 test: one thickness record must not describe two wall roles
Validate / process-workflow-sync (push) Successful in 35s
Validate / provenance (push) Successful in 46s
Validate / changes (push) Successful in 31s
Validate / process-gate (push) Failing after 45s
Validate / reuse (push) Successful in 46s
Validate / hacs (push) Failing after 16s
Validate / hassfest (push) Failing after 18s
Validate / frontend (push) Successful in 9m19s
Validate / backend (push) Failing after 9m31s
Validate / smoke (1) (push) Failing after 17m56s
Validate / smoke (3) (push) Failing after 17m52s
Validate / smoke (2) (push) Failing after 18m9s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 23m10s
Validate / performance_smoke (push) Failing after 51m44s
Validate / docs (push) Failing after 26s
The continuity gate cannot see the defect from the owner's 66.json: masonry is
continuous there and the record agrees with what is painted. The record itself
is wrong — a partial resize left 43 steps of a former shared boundary as an
exterior wall while it kept the 20 cm of that boundary, next to 30 cm exterior
neighbours. Thickness followed the key, not the role of the edge.

A width check would not have caught it, and I built one before throwing it away.
It compares the painted body against the record, and here the two agree. On real
plans it also fires where masonry is legitimately wider — columns, junction
influence, abutting parallel walls: 76 to 82 steps measured against 4 expected,
every case legal. A gate that needs explaining half the time is noise.

The defect is expressible in a single state instead: one record whose span is
partly shared and partly exterior. Nobody sets that on purpose. Roles come from
the polygons — a stretch is shared when another room's edge covers it — so the
check needs neither a build nor product code.

Two traps found by measurement, both of which produced false positives. Count
distinct rooms rather than edges: in a corner one room owns two edges, and
counting edges called every exterior corner a shared boundary — 12 and 14 false
positives. And do not sample the endpoints: an endpoint is a node, where a wall
legitimately touches two rooms, and including them reported 95 per cent exterior
on every wall abutting a shared one.

Mutation coverage, stated honestly: the endpoint mutant is killed by the
real-plan test. The rooms-versus-edges mutant survived — once endpoints are
excluded, counting edges gives the same answer on real plans, so that choice is
not load-bearing. I removed the mutant rather than ship a surviving one, and said
so in the code.

Issue: #287
User-Visible: no
2026-08-24 13:20:34 +03:00
Claude d5659478aa test: add a second real plan to the masonry gate
The second floor covers one class: the multi-wall corridor eating a neighbouring
wall. The first floor brings what neither it nor any synthetic fixture has —
three virtual spans, two wall columns, two solid edges with no thickness record
at all, and 127 noisy coordinates.

The two plans pin opposite states, which is worth more than two plans with the
same defect: one records a known debt of 181 steps, the other records cleanliness
at zero. A regression is caught in both directions.

A declared virtual span is a declared break, not a defect, so the rule from #285
would have reddened on the first floor's own contract. Samples lying on
open_spans are now excluded: 830 of 6800 on that plan, and the remaining gap
count is zero.

The two zero-thickness solid edges are two grid steps long and covered by the
bodies of their neighbours, so the browser sees no break. Their count is
therefore pinned in the model test rather than the smoke — if it grows, or if the
neighbours stop covering them, that shows up before it becomes a hole.

Both fixtures must stay noisy: the project's synthetic models carry exactly zero
noise, which is why #258 and #248 are not reproducible on them. A profile check
asserts at least a hundred noisy coordinates each, so a future write barrier run
over the fixtures cannot quietly rob them of their purpose.

Privacy: names, ids, markers, device bindings and layout are gone; geometry
stays, coordinates unchanged, because they are the point.

Issue: #286
User-Visible: no
2026-08-24 13:10:33 +03:00
Claude c654e0ec42 test(smoke): masonry of a real plan must not break
Eight closed issues on wall junctions — #271, #272, #275, #276, #277, #278,
\#279, #280 — shipped in beta.9, and the break in the owner's real plan
survived. Every one of them was accepted on synthetic fixtures: a cell-5
mixed-depth T, a rectilinear T with three equal half-depths. On those the fixes
work.

The smoke asks the product itself, through isPointInFill on the wall path,
whether masonry exists where the model promises it. That is independent of both
resolution and the pixel-diff thresholds which miss this class: a 45-step break
on a large plan is a fraction of a per cent of the frame, well under the 0.05
per cent scene tolerance.

Measured on the shipped code: four breaks, 181 grid steps in total, 45.25 each.
That number is derivable rather than incidental — the node joins a 30 cm
exterior wall, a 30 cm spur and an arm five steps long; half-depth 15,
MITRE_LIMIT 4, corridor radius 60, and 60 − 15 = 45 steps are cut out of the
neighbouring 20 cm wall. The corridor eats the masonry, which is what #271 and
\#275 describe.

The fixture is privacy-minimised — neutral room names and ids, no device
bindings — and keeps its coordinates, because they are the point. It lives in
test/fixtures rather than demo/fixtures on purpose: sourceFingerprint hashes the
.mjs of demo/fixtures and demo/golden, so anything added there staleizes the
committed bundle and the screenshot manifest at once. Verified after this
commit: bundle still fresh, screenshots still current.

The debt is recorded as numbers and compared exactly. Better and the test asks
for the numbers to be updated, which proves the improvement; worse and it
catches the regression. Verified by execution in both directions.

Issue: #285
User-Visible: no
2026-08-24 13:02:56 +03:00
Sergey Matyunin d8696315eb test: stabilize opening distance smoke
Issue: #281
User-Visible: no
2026-08-24 11:04:17 +03:00
Sergey Matyunin ed606c0f4a docs: refresh beta 9 screenshots
Issue: #279
Issue: #280
Issue: #281
User-Visible: no
2026-08-24 10:55:56 +03:00
Sergey Matyunin 89658c20fe build: prepare v1.67.0-beta.9 candidate
Issue: #279
Issue: #280
Issue: #281
User-Visible: yes
2026-08-24 10:54:02 +03:00
Claude a988f7c6f1 test: measure how far the stored geometry sits off the lattice
Stage 0 of ADR #282. A lattice node is k/240, which has no exact binary
representation, and a stored coordinate is a float. Nobody could say how much of
a real plan is affected, and Optimize promises to remove coordinate noise
without a definition of noise that can be checked.

latticeProfile splits every coordinate of the model into three populations,
because they are three different problems: exactly on a node, near a node but
not exact, and legitimately off grid. The middle one is the defect class behind
\#258, \#279 and the non-converging Optimize; the last one is authored geometry
the current model allows and must not be called a violation.

Measured on the owner's installation: space 1 has 208 coordinates, 33.65 per
cent exactly on a node and 65.38 per cent in the noise class; space 2 has 21.23
against 78.77. The worst deviation is 8e-8 of a step — invisible, and enough to
put a wall key in the neighbouring bucket.

The counterpart is what makes it worth having: every shipped fixture has zero
noise, all of its off-grid values being authored. Our own test data therefore
cannot reproduce this class by construction, which is why the owner finds these
defects and the gates do not. A test pins that property so it cannot drift.

No violations are produced, no gate turns red, and nothing is repaired: what to
do with a vertex 8e-8 from a node is the owner's decision, and this measures its
price first.

Issue: #283
User-Visible: no
2026-08-24 10:45:48 +03:00
Sergey Matyunin d73790aaab merge: synchronize dev into issue 281
Issue: #281
User-Visible: no
2026-08-24 10:44:23 +03:00
Sergey Matyunin 07bd1a6781 fix: reconcile outer walls before resize
Issue: #281
User-Visible: yes
2026-08-24 10:43:33 +03:00
Claude 0963f5b4dd docs(adr): decide the wall geometry representation
Nine of the last ten specs are one class of defect, 23 fix commits in thirty
days, 595 tolerance mentions across ten geometry modules, one every seventh line
in resize.ts. The specs rate their own risk at 9 and 10 of 10, and two titles
show where that arrived: a nearly orthogonal T junction, and a union failure
that must merely damage less.

The ADR names three properties of the data model that produce the stream — a
float coordinate on a 1/240 step, an identity derived instead of stored, and a
wall living in two representations at once — and records four stages against
them. Stage 0 is accepted for implementation; the rest are direction, in the
same sense as #34.

Issue: #282
User-Visible: no
2026-08-24 10:41:53 +03:00
Sergey Matyunin a3e5f30a3c docs: specify honest resize eligibility
Issue: #281
User-Visible: no
2026-08-24 10:35:13 +03:00
Sergey Matyunin 38ea8a9b9e fix: validate optimize opening rehosts
Issue: #280
User-Visible: yes
2026-08-24 10:33:05 +03:00
Sergey Matyunin 93fad8cfb4 docs: specify optimize rehost validation
Issue: #280
User-Visible: no
2026-08-24 10:28:59 +03:00
Sergey Matyunin c0c30d645a fix: preserve near-orthogonal wall junctions
Issue: #279
User-Visible: yes
2026-08-24 10:27:13 +03:00
Sergey Matyunin cd9916d72a docs: specify near-orthogonal junction protection
Issue: #279
User-Visible: no
2026-08-24 10:24:03 +03:00
Sergey Matyunin 8aa7fc970c Release v1.67.0-beta.8 candidate
Package the reviewed Optimize reconciliation, safe fixed-topology Resize and wall-union isolation fixes from #276, #277 and #278 as the eighth v1.67 prerelease candidate.

Issue: #278
User-Visible: yes
2026-08-24 09:39:11 +03:00
claude[bot] bb81ede47c docs: review document for #278
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke (3) (push) Skipped
Validate / smoke_done (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Validate / docs (push) Failing after 31s
Validate / process-workflow-sync (push) Successful in 31s
Validate / provenance (push) Successful in 37s
Validate / process-gate (push) Failing after 38s
Validate / changes (push) Successful in 35s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 48s
Issue: #278
User-Visible: no
2026-08-24 06:35:58 +00:00
Sergey Matyunin 79285ab1d2 docs: correct wall union mutation identifiers
Issue: #278
User-Visible: no
2026-08-24 09:30:29 +03:00
claude[bot] c123757f50 docs: review document for #278
Issue: #278
User-Visible: no
2026-08-24 06:29:20 +00:00
Sergey Matyunin 495043791e test: accept wall union isolation baselines
Issue: #278
User-Visible: no
Release: v1.67.0-beta.8
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32695276495
2026-08-24 09:04:20 +03:00
Sergey Matyunin 598e7437f2 test: isolate optimize fixture from pending geometry write
Issue: #278
User-Visible: no
2026-08-24 08:55:38 +03:00
Sergey Matyunin df1a8330f4 docs: accept wall union screenshots
Issue: #278
User-Visible: no
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32694995425
2026-08-24 08:53:04 +03:00
Sergey Matyunin 8156f80140 fix: isolate wall union failures and guard geometry writes
Issue: #278
User-Visible: yes
2026-08-24 08:48:37 +03:00
claude[bot]andSergey Matyunin 930a911a36 docs: review document for #278
Issue: #278
User-Visible: no
2026-08-24 08:48:37 +03:00
Sergey Matyunin 4dd074d9a3 docs: address wall union spec review
Issue: #278
User-Visible: no
2026-08-24 08:48:37 +03:00
claude[bot]andSergey Matyunin bdd4f38cb9 docs: review document for #278
Issue: #278
User-Visible: no
2026-08-24 08:48:37 +03:00
Sergey Matyunin 4412f905bd docs: specify wall union isolation
Issue: #278
User-Visible: no
2026-08-24 08:48:37 +03:00
claude[bot] e68b494555 docs: review document for #277
Validate / process-workflow-sync (push) Successful in 47s
Validate / docs (push) Successful in 52s
Validate / provenance (push) Successful in 45s
Validate / process-gate (push) Failing after 32s
Validate / changes (push) Successful in 32s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 43s
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke (3) (push) Skipped
Validate / smoke_done (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Validate / golden (push) Skipped
Issue: #277
User-Visible: no
2026-08-24 05:15:13 +00:00
Sergey Matyunin a548d4f17d docs: accept Resize performance screenshots
Reviewed artifact: https://github.com/Matysh/houseplan-card/actions/runs/32691836025

Issue: #277
User-Visible: no
2026-08-24 07:58:13 +03:00
Sergey Matyunin 9c5b66c3e4 perf: fingerprint Resize handles once per frame
Issue: #277
User-Visible: no
2026-08-24 07:56:49 +03:00
claude[bot] 448ceea399 docs: review document for #277
Issue: #277
User-Visible: no
2026-08-24 04:53:17 +00:00
Sergey Matyunin 241821c40e test: accept beta.8 safe resize baselines
Accept the complete 106-frame Linux golden artifact. All 104 existing scenarios passed; six byte-only PNG refreshes have zero pixel diff, and both new safe-resize light/dark candidates were manually reviewed.

Issue: #277
User-Visible: no
Release: v1.67.0-beta.8
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32689229827
2026-08-24 07:21:19 +03:00
Sergey Matyunin b02a37123d test: align mixed-thickness smoke with safe resize
Issue: #277
User-Visible: no
2026-08-24 07:20:25 +03:00
Sergey Matyunin 03b45f2e37 build: synchronize safe resize release bundle
Issue: #277
User-Visible: no
2026-08-24 07:13:56 +03:00
Sergey Matyunin a5a0f6da2c docs: accept safe resize screenshots
Reviewed artifact: https://github.com/Matysh/houseplan-card/actions/runs/32689026469

Issue: #277
User-Visible: no
2026-08-24 07:12:32 +03:00
Sergey Matyunin 2e22ed7938 fix: stop resize before lossy metadata rekey
Issue: #277
User-Visible: yes
2026-08-24 07:10:27 +03:00
Sergey Matyunin cd0a682ad7 test: align legacy resize smokes with safe mode
Issue: #277
User-Visible: no
2026-08-24 07:07:20 +03:00
Sergey Matyunin 6335b0e519 fix: make room resize topology-safe
Issue: #277
User-Visible: yes
2026-08-24 07:03:24 +03:00
claude[bot]andSergey Matyunin 8b227962b1 docs: review document for #277
Issue: #277
User-Visible: no
2026-08-24 06:31:15 +03:00
Sergey Matyunin 1bbd53ee52 docs: address safe resize spec review
Issue: #277
User-Visible: no
2026-08-24 06:31:15 +03:00
claude[bot]andSergey Matyunin 7be7f8cd99 docs: review document for #277
Issue: #277
User-Visible: no
2026-08-24 06:31:15 +03:00
Sergey Matyunin 9e35650470 docs: specify safe fixed-topology resize
Issue: #277
User-Visible: no
2026-08-24 06:31:15 +03:00
claude[bot] 29a0305558 docs: review document for #276
Validate / backend (push) Failing after 16m13s
Validate / smoke (1) (push) Failing after 24m11s
Validate / smoke (2) (push) Failing after 25m33s
Validate / smoke (3) (push) Failing after 16m12s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 18m2s
Validate / performance_smoke (push) Failing after 33m56s
Validate / process-workflow-sync (push) Successful in 34s
Validate / docs (push) Failing after 36s
Validate / process-gate (push) Failing after 41s
Validate / provenance (push) Successful in 42s
Validate / changes (push) Successful in 22s
Validate / hacs (push) Failing after 13s
Validate / reuse (push) Successful in 40s
Validate / hassfest (push) Failing after 17s
Validate / frontend (push) Successful in 6m56s
Issue: #276
User-Visible: no
2026-08-24 03:29:16 +00:00
Sergey Matyunin ff2f2063cf test: stabilize Optimize overhead benchmark
Use paired ABBA/BAAB batches for the p95 gate and accept the complete 104-frame Linux golden artifact. The four new #276 scenes were visually reviewed; every existing scene passed, and the nine byte changes contain zero pixels above their comparison threshold.

Issue: #276
User-Visible: no
Release: v1.67.0-beta.8
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32684802336
2026-08-24 06:19:13 +03:00
claude[bot] b5519e997b docs: review document for #276
Issue: #276
User-Visible: no
2026-08-24 03:13:45 +00:00
Sergey Matyunin 82fe98bcd3 test: prove coincident partition acceptance
Issue: #276
User-Visible: no
2026-08-24 05:57:57 +03:00
claude[bot] 57fdfdbd83 docs: review document for #276
Issue: #276
User-Visible: no
2026-08-24 02:51:26 +00:00
Sergey Matyunin 496e79a6df docs: refresh screenshot fingerprint
Issue: #276
User-Visible: no
2026-08-24 05:32:14 +03:00
Sergey Matyunin 434995beb2 test: strengthen coincident partition mutation
Issue: #276
User-Visible: no
2026-08-24 05:29:01 +03:00
Sergey Matyunin a605611105 fix: reconcile coincident partitions during Optimize
Issue: #276
User-Visible: yes
2026-08-24 05:27:20 +03:00
claude[bot] ec315ad7c1 docs: review document for #276
Issue: #276
User-Visible: no
2026-08-24 02:11:52 +00:00
Sergey Matyunin cfc8f2114d docs: cover nested coincident partition thickness
Issue: #276
User-Visible: no
2026-08-24 05:03:58 +03:00
claude[bot] d5a0452fb5 docs: review document for #276
Issue: #276
User-Visible: no
2026-08-24 02:01:38 +00:00
Sergey Matyunin ca5166430b docs: address spec review for partition reconciliation
Issue: #276
User-Visible: no
2026-08-24 04:56:33 +03:00
claude[bot] 5c2c2578e1 docs: review document for #276
Issue: #276
User-Visible: no
2026-08-24 01:55:39 +00:00
Sergey Matyunin a06c94b967 docs: specify coincident partition reconciliation
Issue: #276
User-Visible: no
2026-08-24 04:48:13 +03:00
Sergey Matyunin 240316a639 test: accept beta.7 Linux golden baselines
Accept the complete 100-frame Linux golden artifact after manual review of every diff and all four non-passing candidate frames.

Issue: #275
User-Visible: no
Release: v1.67.0-beta.7
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32673773610
2026-08-24 02:38:19 +03:00
Sergey Matyunin 678b1996fc docs: accept beta.7 canonical screenshots
Accept the complete 10-frame Linux docs artifact captured from the exact beta.7 candidate SHA.

Reviewed artifact: https://github.com/Matysh/houseplan-card/actions/runs/32673778582

Issue: #275
User-Visible: no
2026-08-24 02:38:14 +03:00
Sergey Matyunin ca8390366f Release v1.67.0-beta.7 candidate
Package the reviewed orthogonal multi-wall strip containment fix from #275 as the seventh v1.67 prerelease candidate.

Issue: #275
User-Visible: yes
2026-08-24 02:29:41 +03:00
claude[bot] 8c4dd16754 docs: review document for #275
Issue: #275
User-Visible: no
2026-08-23 23:10:44 +00:00
Sergey Matyunin 7cef3014a2 test: keep multi-wall regression mutants executable
Issue: #275
User-Visible: no
2026-08-24 01:49:12 +03:00
Sergey Matyunin 7bcc5b285b fix: preserve orthogonal wall strips at multi-wall joins
Issue: #275
User-Visible: yes
2026-08-24 01:47:22 +03:00
claude[bot] 29f6ad2969 docs: review document for #275
Issue: #275
User-Visible: no
2026-08-23 22:26:09 +00:00
Sergey Matyunin 3bf5a1db26 docs: protect orthogonal ray strips in multi-wall joins
Issue: #275
User-Visible: no
2026-08-24 01:18:56 +03:00
claude[bot] 423c20cd23 docs: review document for #275
Issue: #275
User-Visible: no
2026-08-23 22:14:48 +00:00
Sergey Matyunin 62dd02d823 docs: constrain multi-wall containment to rectilinear nodes
Issue: #275
User-Visible: no
2026-08-24 01:04:17 +03:00
claude[bot] e04e168ac4 docs: review document for #275
Issue: #275
User-Visible: no
2026-08-23 22:02:29 +00:00
Sergey Matyunin 762e9f4b32 docs: specify multi-wall strip containment
Issue: #275
User-Visible: no
2026-08-24 00:55:02 +03:00
Sergey Matyunin 1daa852622 docs: accept beta.6 canonical screenshots
Accept the complete ten-frame Linux artifact captured from candidate 8d2a050. All five changed frames were reviewed against the committed set; they preserve the intended UI while recording the tapered #272 geometry and beta.6 source fingerprint. Run: https://github.com/Matysh/houseplan-card/actions/runs/32667107212

Issue: #272
User-Visible: no
2026-08-24 00:20:20 +03:00
Sergey Matyunin 8d2a0505e3 Release v1.67.0-beta.6 candidate
Package the reviewed S8 fixes for finite and hole-free wall junctions, topology-safe Optimize cleanup, and wireless-controller presentation parity.

Issue: #271
Issue: #272
Issue: #273
Issue: #274
User-Visible: yes
2026-08-24 00:13:35 +03:00
Sergey Matyunin b05eb68c3a perf: taper multi-wall exterior corridors
Keep the finite #272 exit as a safe subset of the square connector while removing two contour corners per bevel. The simpler canonical path preserves the zero-hole and finite-ray contracts and restores headroom in the large-house Glow gate.

Issue: #272
User-Visible: no
2026-08-24 00:10:57 +03:00
Sergey Matyunin 22d0f05650 docs: accept canonical screenshots for bevel batching
Accept the complete ten-frame Linux artifact from the exact #272 performance gate-fix source. The content matches the reviewed UI while replacing the non-canonical Windows captures that had entered dev with #274.

Issue: #272
User-Visible: no
2026-08-23 23:33:39 +03:00
Sergey Matyunin 4266b625fe perf: batch multi-wall bevel cuts
Subtract each node's combined local bevel mask once instead of traversing the large wall and paper geometry once per triangle and exterior connector. The equivalent mask keeps the reviewed #272 geometry while restoring the large-house Glow state-update budget.

Issue: #272
User-Visible: no
2026-08-23 23:29:42 +03:00
Sergey Matyunin 57a967b9a5 test(golden): accept beta.6 wall junction baselines
Accepted the complete 98-scenario Linux artifact after visual review. Intentional differences are limited to the finite degree-3 junction repair and the bounded multi-wall bevel closures; all remaining candidate bytes come from the same complete canonical capture.

Issue: #271
Issue: #272
User-Visible: no
Release: v1.67.0-beta.6
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32661228757
2026-08-23 22:57:57 +03:00
claude[bot] ad1ee9d13d docs: review document for #274
Validate / docs (push) Successful in 30s
Validate / process-workflow-sync (push) Successful in 29s
Validate / provenance (push) Successful in 38s
Validate / changes (push) Successful in 27s
Validate / process-gate (push) Failing after 33s
Validate / reuse (push) Successful in 50s
Validate / hacs (push) Failing after 16s
Validate / hassfest (push) Failing after 15s
Validate / backend (push) Failing after 9m9s
Validate / frontend (push) Successful in 9m54s
Validate / smoke (1) (push) Failing after 17m51s
Validate / smoke (3) (push) Failing after 18m23s
Validate / smoke (2) (push) Failing after 18m55s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 25m55s
Validate / performance_smoke (push) Failing after 50m54s
Issue: #274
User-Visible: no
2026-08-23 19:45:24 +00:00
Sergey Matyunin c9b8497f58 fix: keep wireless controller face consistent
Issue: #274
User-Visible: yes
2026-08-23 22:24:32 +03:00
claude[bot] 7b23035e21 docs: review document for #274
Issue: #274
User-Visible: no
2026-08-23 19:15:04 +00:00
Sergey Matyunin 9b764277db docs(spec): define wireless controller presentation parity
Issue: #274
User-Visible: no
2026-08-23 22:08:19 +03:00
claude[bot] d83b0c512f docs: review document for #272
Issue: #272
User-Visible: no
2026-08-23 18:44:54 +00:00
Sergey Matyunin 5fd4e2249a docs: refresh merged junction evidence
Issue: #272
User-Visible: no
2026-08-23 21:29:35 +03:00
Sergey Matyunin 3fedcaf1d5 fix: open multi-wall bevel holes to exterior
Issue: #272
User-Visible: yes
2026-08-23 21:23:42 +03:00
claude[bot]andSergey Matyunin a28158c041 docs: review document for #272
Issue: #272
User-Visible: no
2026-08-23 21:20:28 +03:00
Sergey Matyunin 21b04ed0c2 spec(walls): tighten multi-wall hole evidence
Issue: #272
User-Visible: no
2026-08-23 21:20:28 +03:00
claude[bot]andSergey Matyunin f3d4a79787 docs: review document for #272
Issue: #272
User-Visible: no
2026-08-23 21:20:28 +03:00
Sergey Matyunin dd4ebb7401 docs(spec): require hole-free multi-wall joins
Issue: #272
User-Visible: no
2026-08-23 21:20:28 +03:00
claude[bot] 67863d68a4 docs: review document for #271
Issue: #271
User-Visible: no
2026-08-23 18:19:52 +00:00
claude[bot]andSergey Matyunin c0b231fa06 docs: review document for #271
Issue: #271
User-Visible: no
2026-08-23 21:09:56 +03:00
Sergey Matyunin c190a698c5 docs: refresh merged finite-ray evidence
Issue: #271
User-Visible: no
2026-08-23 21:09:56 +03:00
Sergey Matyunin 8bd910ed84 test: cover finite wall beside an opening
Issue: #271
User-Visible: no
2026-08-23 21:09:56 +03:00
claude[bot]andSergey Matyunin dc94e016ca docs: review document for #271
Issue: #271
User-Visible: no
2026-08-23 21:06:20 +03:00
Sergey Matyunin e3b635aba2 fix(walls): bound multi-wall repairs to finite rays
Issue: #271
User-Visible: yes
2026-08-23 21:06:20 +03:00
claude[bot]andSergey Matyunin ffbbb217b5 docs: review document for #271
Issue: #271
User-Visible: no
2026-08-23 21:06:00 +03:00
Sergey Matyunin 154af2692f docs(spec): define finite multi-wall ray contract
Issue: #271
User-Visible: no
2026-08-23 21:06:00 +03:00
claude[bot] d68e876f78 docs: review document for #273
Issue: #273
User-Visible: no
2026-08-23 17:33:06 +00:00
Sergey Matyunin 3ab3cdc1e4 docs: record optimizer topology evidence
Issue: #273
User-Visible: no
2026-08-23 20:23:08 +03:00
Sergey Matyunin 105a8f70eb fix: collapse thickness island beside one T-node
Issue: #273
User-Visible: yes
2026-08-23 20:20:03 +03:00
claude[bot] 3e8733ad27 docs: review document for #273
Issue: #273
User-Visible: no
2026-08-23 17:10:22 +00:00
Sergey Matyunin e2de0dbcc4 spec(optimize): narrow topology island guard
Issue: #273
User-Visible: no
2026-08-23 20:03:50 +03:00
claude[bot] 6e2247aa3d docs: review document for #273
Issue: #273
User-Visible: no
2026-08-23 16:50:00 +00:00
Sergey Matyunin b5fbe633a6 docs(spec): handle topology-adjacent thickness islands
Issue: #273
User-Visible: no
2026-08-23 19:41:22 +03:00
Sergey Matyunin b86da0d419 test(golden): accept v1.67.0-beta.5 wall baselines
Accepted the complete 98-scenario Linux artifact after visual review. The intentional changes are the new wall-key round-trip regression scene and four large-house frames where #258 restores the fixture's real walls; all 93 passing candidates remain on their prior bytes.

Issue: #258
User-Visible: no
Release: v1.67.0-beta.5
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32649309598
2026-08-23 18:57:59 +03:00
Sergey Matyunin 1d0e0c5ab8 chore(release): prepare v1.67.0-beta.5
Issue: #258
Issue: #261
Issue: #262
User-Visible: yes
2026-08-23 18:40:44 +03:00
claude[bot] 3e3fb9ceaa docs: review document for #262
Issue: #262
User-Visible: no
2026-08-23 13:54:22 +00:00
Sergey Matyunin 5fa2dc2893 docs: mark child re-add ready for review
Issue: #262
User-Visible: no
2026-08-23 16:41:15 +03:00
Sergey Matyunin 2d6b6006fe docs: record canonical screenshots for child re-add
Issue: #262
User-Visible: no
2026-08-23 16:40:24 +03:00
Sergey Matyunin 8cdf6b4894 fix: restore one child of a deleted device
Issue: #262
User-Visible: yes
2026-08-23 16:32:12 +03:00
claude[bot] a0b39b626f docs: review document for #262
Issue: #262
User-Visible: no
2026-08-23 13:25:44 +00:00
Sergey Matyunin 7b3ea3769a docs: specify child entity re-add lifecycle
Issue: #262
User-Visible: no
2026-08-23 16:19:48 +03:00
claude[bot] 98dce8d0b4 docs: review document for #261
Issue: #261
User-Visible: no
2026-08-23 13:13:14 +00:00
Sergey Matyunin 0d0a9dd87a docs: record rebased implementation evidence
Issue: #261
User-Visible: no
2026-08-23 15:41:55 +03:00
Sergey Matyunin 4eda889922 chore: refresh validation after rebase
Issue: #261
User-Visible: no
2026-08-23 15:41:18 +03:00
Sergey Matyunin 2e53cb0e05 test: register and refresh junction evidence
Issue: #261
User-Visible: no
2026-08-23 15:35:39 +03:00
Sergey Matyunin 0dc6c39784 fix: retain bounded T-junction masonry
Issue: #261
User-Visible: yes
2026-08-23 15:35:27 +03:00
claude[bot]andSergey Matyunin 61b437fd3f docs: review document for #261
Issue: #261
User-Visible: no
2026-08-23 15:35:26 +03:00
Sergey Matyunin 9f6efd0c11 docs: specify multi-wall wedge repair
Issue: #261
User-Visible: no
2026-08-23 15:35:26 +03:00
Claude 18d504631c test(smoke): cover the binding picker
git grep _bindingCandidates -- test/ demo/ was empty: the function that decides
what the user is offered under Add had no test and no smoke. Tombstones were
covered from every side, the list they filter was never asked. That is how #262
reached us through a user report instead of a gate — smoke_hidden_flag assigns
binding straight into _markerDialog and bypasses the picker entirely.

Twelve checks against the real bundle: a deleted device is offered again, a
deleted plain entity is offered again behind the checkbox, the checkbox itself
is the trap (a device entity is absent with it off, present with it on, and it
starts off for a new marker), a placed binding is not duplicated, and re-adding
replaces the tombstone and leaves the picker.

The twelfth pins the known defect #262 as current behaviour: a device tombstone
still hides its child entities. Fixing it turns the check red and forces it to
be flipped, so the fix cannot pass the coverage by.

smoke-links registers only the pure tombstone helpers. The picker names itself
and is found by direct match; the helpers are not named anywhere in the
scenario. Verified by probe: touching src/devices.ts alone selects this smoke
as a registered link, and without the entry nothing would select it.

Issue: #263
User-Visible: no
2026-08-23 15:32:57 +03:00
claude[bot] 9f63163339 docs: review document for #258
Validate / docs (push) Failing after 21s
Validate / process-workflow-sync (push) Successful in 26s
Validate / changes (push) Successful in 32s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / provenance (push) Successful in 1m4s
Validate / process-gate (push) Successful in 51s
Validate / reuse (push) Successful in 44s
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke (3) (push) Skipped
Validate / smoke_done (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Issue: #258
User-Visible: no
2026-08-23 11:46:19 +00:00
Sergey Matyunin 61bf537b58 docs: correct wall-key changelog claim
Issue: #258
User-Visible: no
2026-08-23 14:40:14 +03:00
claude[bot] aa85a95b32 docs: review document for #258
Issue: #258
User-Visible: no
2026-08-23 11:38:37 +00:00
Sergey Matyunin 28eaf86662 fix: stabilize wall keys across storage round-trips
Issue: #258
User-Visible: yes
2026-08-23 14:21:36 +03:00
claude[bot]andSergey Matyunin 46a83de181 docs: review document for #258
Issue: #258
User-Visible: no
2026-08-23 14:18:10 +03:00
Sergey Matyunin b9a5f6c4e4 docs(spec): define stable wall key round-trip
Issue: #258
User-Visible: no
2026-08-23 14:18:10 +03:00
Claude 5e95a28406 test: grade the wall-key invariant by what the product does
Validate / docs (push) Failing after 25s
Validate / process-workflow-sync (push) Successful in 1m13s
Validate / provenance (push) Failing after 1m16s
Validate / process-gate (push) Failing after 1m25s
Validate / changes (push) Successful in 1m9s
Validate / reuse (push) Successful in 58s
Validate / hacs (push) Failing after 17s
Validate / hassfest (push) Failing after 20s
Validate / frontend (push) Successful in 10m20s
Validate / backend (push) Failing after 9m54s
Validate / smoke (1) (push) Failing after 18m2s
Validate / smoke (2) (push) Failing after 17m49s
Validate / smoke (3) (push) Failing after 15m44s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 15m1s
Validate / performance_smoke (push) Failing after 13m49s
The first cut of checkWallKeys compared the stored key against endpoints
snapped to the lattice and called any mismatch a violation. Both halves were
wrong, and measurement says so: wallIntervals reports the query key for the
disputed edge as 0.887500,0.195833@1.5706, i.e. the form built from the
coordinates as stored, and the two owner configurations that differ in exactly
these keys produce byte-identical wall bodies and multi-wall node maps. The
check would have reddened a plan that renders correctly.

Graded now: a drift inside the tolerant fallback's half-pitch reach is an
observation, a key beyond it or one that does not parse as coordinates is a
violation. The threshold is expressed in grid steps with a 1e-3 slack — with a
relative 1e-6 the four identically drifted records of one plan split between
the two classes on their last bits, so the check repeated the very rounding tie
it exists to expose.

visual-matrix leaves KEY_CONTRACT_DEBT: its keys drift inside the reach and now
read as observations. large-house stays — its labels do not parse, and
wallIntervals shows all 80 solid edges resolving to zero thickness (#260).

Issue: #259
User-Visible: no
2026-08-23 14:11:22 +03:00
Claude 2970b8091b ci(process): ask the reviewer about the wall key invariant
Issue: #259
User-Visible: no
2026-08-23 13:51:37 +03:00
Claude 7b267fb632 test: check the wall key against the lattice edge
#258 lost two thickness records to a rounding tie: wallKey quantises the
midpoint with Math.round, and a wall of odd step length has its midpoint
exactly on the tie, where the exact node 83/240 and the stored 0.345833333
fall on opposite sides. The #254 invariants pass on that file — their edge
tolerance is 0.004 and the drift is 0.00417, so the check sits on its own
boundary.

checkWallKeys compares strings, and against endpoints snapped to the lattice:
keying from the raw endpoints flags the healthy state and passes the broken
one, which is what the first formulation in #258 got wrong. Measured on the
owner's before/after pair: 0 findings before, exactly the two artefact walls
after.

Two shipped fixtures write keys off the contract (#260); recorded as a number,
so the debt can neither grow nor be silently fixed.

Issue: #259
User-Visible: no
2026-08-23 13:50:37 +03:00
Sergey Matyunin 8c7e8bc9d1 chore(release): prepare v1.67.0-beta.4
Issue: #249
Issue: #251
Issue: #252
Issue: #253
Issue: #256
User-Visible: yes
2026-08-23 12:06:29 +03:00
claude[bot] f5ec6718a2 docs: review document for #253
Issue: #253
User-Visible: no
2026-08-23 08:59:49 +00:00
Sergey Matyunin ffb609d471 docs: refresh screenshots for wall resize
Issue: #253
User-Visible: no
2026-08-23 11:46:43 +03:00
Sergey Matyunin bfdeb493cb fix: preserve wall thickness across partial resize
Issue: #253
User-Visible: yes
2026-08-23 11:42:43 +03:00
claude[bot] 9d6a4e34fd docs: review document for #253
Issue: #253
User-Visible: no
2026-08-23 08:30:43 +00:00
Sergey Matyunin d9f7861c05 docs: specify lossless wall resize
Issue: #253
User-Visible: no
2026-08-23 11:19:47 +03:00
claude[bot] 6dd59a1925 docs: review document for #252
Issue: #252
User-Visible: no
2026-08-23 08:06:22 +00:00
claude[bot] b9e4be3799 docs: review document for #252
Issue: #252
User-Visible: no
2026-08-23 08:06:22 +00:00
Sergey Matyuninandclaude[bot] 86a0786e2d docs: refresh screenshot fingerprint after rebase
Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32625765217

Issue: #252
User-Visible: no
2026-08-23 08:06:22 +00:00
Sergey Matyuninandclaude[bot] 0dbc94739b chore: remove obsolete Optimize warning copy
Issue: #252
User-Visible: no
2026-08-23 08:06:22 +00:00
Sergey Matyuninandclaude[bot] 074a9ee86d chore: revalidate rebased issue branch
Issue: #252
User-Visible: no
2026-08-23 08:06:22 +00:00
claude[bot] e383fc2989 docs: review document for #252
Issue: #252
User-Visible: no
2026-08-23 08:06:22 +00:00
Sergey Matyuninandclaude[bot] 4edb2bb2eb test: assert Optimize cleanup selection copy
Issue: #252
User-Visible: no
2026-08-23 08:06:22 +00:00
Sergey Matyuninandclaude[bot] b56f119f7a test(golden): accept orphan cleanup dialogs
Accepted the complete 97-scenario Linux artifact after visual review. The intentional golden changes are limited to the EN/Dark and RU/Light Optimize orphan-cleanup dialogs; all 95 passing raster candidates were restored to their prior bytes and hashes. The canonical docs artifact used the same Chromium and refreshed its source fingerprint; eight frames were identical, while two differed by only 2 and 17 sub-threshold pixels.

Issue: #252
User-Visible: no
Release: v1.67.0-beta.4
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32623704126
2026-08-23 08:06:22 +00:00
Sergey Matyuninandclaude[bot] 36b5a8c36c fix: clean orphaned Optimize positions
Issue: #252
User-Visible: yes
2026-08-23 08:06:22 +00:00
claude[bot] a41035d185 docs: review document for #252
Issue: #252
User-Visible: no
2026-08-23 08:06:22 +00:00
Sergey Matyuninandclaude[bot] eefe0fab94 docs: clarify orphan cleanup invariant
Issue: #252
User-Visible: no
2026-08-23 08:06:22 +00:00
claude[bot] 759111beb3 docs: review document for #252
Issue: #252
User-Visible: no
2026-08-23 08:06:22 +00:00
Sergey Matyuninandclaude[bot] 63db3ae4c6 docs: specify orphan layout cleanup
Issue: #252
User-Visible: no
2026-08-23 08:06:22 +00:00
Claude 4b6331f8d8 docs(process): описать приведение ветки к dev до код-ревью
Issue: #257
User-Visible: no
2026-08-23 10:56:41 +03:00
Claude 10999a5c5c ci(process): привести ветку к dev до код-ревью, а не после
Ревью шло по ветке как есть, слияние делало ребейз: проверенный SHA и
слитый SHA были разными коммитами. Текстовое расхождение ловил конфликт,
смысловое git склеивал молча — так пришёл регресс #234. Заодно конфликт
обнаруживался после сорока минут работы ревьюера, хотя виден до них.

Новый шаг для этапа code, сразу после выбора ветки: потомок dev —
ничего; отстала и ребейзится — ребейз, push с --force-with-lease, ревью
приведённого состояния и запись о ребейзе в промпт (§7.2 требует полного
разбора); конфликт — возврат в S6-in-progress без запуска ревью.

Issue: #257
User-Visible: no
(cherry picked from commit 793a6486d8)
2026-08-23 10:55:44 +03:00
claude[bot] 2d1fca1dfa docs: review document for #256
Issue: #256
User-Visible: no
2026-08-23 07:31:55 +00:00
Matysh a952f5fd07 feat(api): let config/get and layout/get return less
Issue: #256
User-Visible: no
2026-08-23 10:22:51 +03:00
Matysh 6d0fa3b819 build: stop committing the stand copy of the bundle
Issue: #255
User-Visible: no
2026-08-23 10:15:45 +03:00
Matysh 7e69b4bb28 ci: shard the smoke suite and stop validating review docs
Issue: #254
User-Visible: no
2026-08-23 09:55:04 +03:00
Matysh 74d2285d1b ci: ask the reviewer about numbers seen twice
Issue: #254
User-Visible: no
2026-08-23 09:50:06 +03:00
Matysh dc3c083352 test: one number, one source — as a checkable rule
Issue: #254
User-Visible: no
2026-08-23 09:49:22 +03:00
Matysh 59be468506 test: run model invariants over every shipped model
Issue: #254
User-Visible: no
2026-08-23 09:40:50 +03:00
Matysh 2c74b12101 docs: put the invariant check into the local gate
Issue: #254
User-Visible: no
2026-08-23 09:34:48 +03:00
Matysh 5cf52707c4 test: check model invariants for references and wall records
Issue: #254
User-Visible: no
2026-08-23 09:34:32 +03:00
claude[bot] c8d7d282a4 docs: review document for #251
Issue: #251
User-Visible: no
2026-08-23 06:01:30 +00:00
Sergey Matyunin 4d5f7f50f1 docs: refresh capture fingerprint after golden fix
Update the documentation capture source fingerprint after the golden-matrix correction. All ten canonical frames were reviewed; two sub-threshold raster-noise candidates were kept byte-identical to HEAD and only the manifest changed.

Capture: https://github.com/Matysh/houseplan-card/actions/runs/32621056471

Issue: #251
User-Visible: no
2026-08-23 08:46:37 +03:00
Sergey Matyunin 527081919a test(golden): accept rebased junction baselines
Accept the complete 96-scenario Linux artifact after the #251 rebase exposed the unaccepted #249 junction visuals. Reviewed changes are limited to thirteen bounded-junction frames and the new multi-wall bevel scene; all passed raster-noise candidates were restored.

Issue: #251
User-Visible: no
Release: v1.67.0-beta.4
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32620456718
2026-08-23 08:37:04 +03:00
Sergey Matyunin f12418ba1c test(golden): correct multiwall bevel probe
Use the discarded-wedge centre derived by the #249 unit geometry instead of a point that lies inside the legitimate incident wall body. This restores the semantic golden contract exposed while rebasing #251; product rendering is unchanged.

Issue: #251
User-Visible: no
2026-08-23 08:28:51 +03:00
Sergey Matyunin bde9a40ef4 docs: refresh screenshots after controller rebase
Accept the complete canonical Linux documentation capture for the rebased #249 + #251 source tree. All eight changed frames were reviewed; the two unchanged PNGs remain byte-identical.

Capture: https://github.com/Matysh/houseplan-card/actions/runs/32619669409

Issue: #251
User-Visible: no
2026-08-23 08:12:25 +03:00
claude[bot]andSergey Matyunin cba2b5ab50 docs: review document for #251
Issue: #251
User-Visible: no
2026-08-23 08:08:34 +03:00
Sergey Matyunin 4b19376a81 test(smoke): mark cover controller available
Keep the cover-precedence scenario focused on target state mirroring by explicitly providing a live own entity after #251 separated controller availability from target availability.

Issue: #251
User-Visible: no
2026-08-23 08:08:34 +03:00
Sergey Matyunin 0108b26655 test(golden): accept v1.67.0-beta.4 controller states
Accepted the complete Linux artifact after visual review. Only device-icon-state-table light/dark change intentionally; seven within-threshold renderer-noise images were restored to their prior bytes and hashes.

Issue: #251
User-Visible: no
Release: v1.67.0-beta.4
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32617372743
2026-08-23 08:08:34 +03:00
claude[bot]andSergey Matyunin fbc689095b docs: review document for #251
Issue: #251
User-Visible: no
2026-08-23 08:08:34 +03:00
Sergey Matyunin 2ae523a8b1 docs: refresh screenshots for controller states
Docs screenshots run: 32617490601

Issue: #251
User-Visible: no
2026-08-23 08:08:34 +03:00
Sergey Matyunin 2cb7c73cad fix: separate controller and target availability
Issue: #251
User-Visible: yes
2026-08-23 08:08:17 +03:00
claude[bot]andSergey Matyunin e5bda27cc3 docs: review document for #251
Issue: #251
User-Visible: no
2026-08-23 08:07:29 +03:00
Sergey Matyunin 903bc0a345 docs(spec): separate controller and target availability
Issue: #251
User-Visible: no
2026-08-23 08:07:29 +03:00
claude[bot] 6ef814cea7 docs: review document for #249
Issue: #249
User-Visible: no
2026-08-23 03:47:19 +00:00
Sergey Matyuninandclaude[bot] 21db2b2898 fix: preserve bounded multi-wall floor geometry
Issue: #249
User-Visible: yes
2026-08-23 03:47:19 +00:00
claude[bot] ee0a1f82f2 docs: review document for #249
Issue: #249
User-Visible: no
2026-08-23 03:47:19 +00:00
Sergey Matyuninandclaude[bot] 29904df6ee fix: bound multi-wall junction bevels
Issue: #249
User-Visible: yes
2026-08-23 03:47:19 +00:00
claude[bot] 56e3da9b8a docs: review document for #249
Issue: #249
User-Visible: no
2026-08-23 03:47:19 +00:00
Sergey Matyuninandclaude[bot] cb745c2035 docs(spec): address #249 review findings
Issue: #249
User-Visible: no
2026-08-23 03:47:19 +00:00
claude[bot] d44d2e8830 docs: review document for #249
Issue: #249
User-Visible: no
2026-08-23 03:47:19 +00:00
Sergey Matyuninandclaude[bot] 5276cfdb15 docs(spec): define bounded multi-wall junctions
Issue: #249
User-Visible: no
2026-08-23 03:47:19 +00:00
Matysh 0a1465d20c test(smoke): cover wall-face overlap, duplicate and nesting
Validate / docs (push) Failing after 22s
Validate / reuse (push) Successful in 37s
Validate / changes (push) Successful in 1m20s
Validate / provenance (push) Successful in 1m26s
Validate / process-gate (push) Failing after 1m25s
Validate / hacs (push) Failing after 14s
Validate / hassfest (push) Failing after 17s
Validate / frontend (push) Successful in 6m28s
Validate / backend (push) Failing after 6m39s
Validate / golden (push) Failing after 15m20s
Validate / performance_smoke (push) Failing after 18m21s
Validate / smoke (push) Failing after 36m34s
Issue: #177
User-Visible: no
2026-08-23 05:11:01 +03:00
Sergey Matyunin e0b613e94f test(golden): accept v1.67.0-beta.3 baselines
Issue: #244
Issue: #250
User-Visible: no
Release: v1.67.0-beta.3
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32605955756
2026-08-23 02:47:34 +03:00
Sergey Matyunin 9c5eacfd02 chore(release): prepare v1.67.0-beta.3
Issue: #244
Issue: #248
Issue: #250
User-Visible: yes
2026-08-23 02:42:37 +03:00
claude[bot] 4e13d71d78 docs: review document for #250
Issue: #250
User-Visible: no
2026-08-22 23:23:02 +00:00
Sergey Matyunin ed7ea29dc9 test: restore gate flip mutation coverage
Issue: #250
User-Visible: no
2026-08-23 02:16:32 +03:00
claude[bot] 7180eee0ee docs: review document for #250
Issue: #250
User-Visible: no
2026-08-22 23:15:25 +00:00
Sergey Matyunin 7269550bbb docs: accept screenshots for opening centerline
Issue: #250
User-Visible: no
2026-08-23 02:03:45 +03:00
Sergey Matyunin 0eb9ffe480 fix: keep flipped openings on wall centerline
Issue: #250
User-Visible: yes
2026-08-23 01:59:26 +03:00
claude[bot] 2534916c94 docs: review document for #250
Issue: #250
User-Visible: no
2026-08-22 22:51:53 +00:00
Sergey Matyunin 2c776b0c6d docs: specify opening centerline invariant
Issue: #250
User-Visible: no
2026-08-23 01:46:22 +03:00
claude[bot] a3166eb1b0 docs: review document for #248
Issue: #248
User-Visible: no
2026-08-22 22:37:51 +00:00
claude[bot] e468414c24 docs: review document for #248
Issue: #248
User-Visible: no
2026-08-22 22:26:16 +00:00
Sergey Matyunin 120b491eba test: require schema-valid Optimize fixture
Issue: #248
User-Visible: no
2026-08-23 01:24:49 +03:00
Sergey Matyunin 5ad4280ef7 test: complete Optimize storage fixtures
Issue: #248
User-Visible: no
2026-08-23 01:22:56 +03:00
Sergey Matyunin cbdd5a7e73 fix: keep Optimize idempotent across storage reload
Issue: #248
User-Visible: yes
2026-08-23 01:13:27 +03:00
claude[bot] 56fe4240d1 docs: review document for #248
Issue: #248
User-Visible: no
2026-08-22 22:02:13 +00:00
Sergey Matyunin 4d73d031a4 docs: specify optimize write-reload idempotence
Issue: #248
User-Visible: no
2026-08-23 00:56:25 +03:00
claude[bot] 6473d5e4b3 docs: review document for #244
Validate / docs (push) Failing after 25s
Validate / reuse (push) Successful in 32s
Validate / provenance (push) Successful in 1m8s
Validate / process-gate (push) Failing after 1m20s
Validate / changes (push) Successful in 1m12s
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: #244
User-Visible: no
2026-08-22 21:49:34 +00:00
claude[bot]andSergey Matyunin fe70c1e803 docs: review document for #244
Issue: #244
User-Visible: no
2026-08-23 00:35:48 +03:00
Sergey Matyunin 348dd8468b docs: accept screenshot fingerprint for #244
Captured by the canonical Docs screenshots workflow: https://github.com/Matysh/houseplan-card/actions/runs/32599244099

Issue: #244
User-Visible: no
2026-08-23 00:35:48 +03:00
Sergey Matyunin 6a151d1ead fix: preserve markers when deleting final space
Issue: #244
User-Visible: yes
2026-08-23 00:35:48 +03:00
Sergey Matyunin 9c2f08bd40 test(smoke): bind the space-delete blocker fixture
Issue: #244
User-Visible: no
2026-08-23 00:34:55 +03:00
claude[bot]andSergey Matyunin 259fbc97ac docs: review document for #244
Issue: #244
User-Visible: no
2026-08-23 00:34:55 +03:00
Sergey Matyunin 5c9aae24e2 build: refresh issue 244 bundle fingerprints
Issue: #244
User-Visible: no
2026-08-23 00:34:55 +03:00
Sergey Matyunin a4e6a22662 test(golden): cover orphan-reference scenarios
Issue: #244
User-Visible: no
2026-08-23 00:34:55 +03:00
Sergey Matyunin bae897bd22 docs: accept screenshot fingerprint for #244
Captured by the canonical Docs screenshots workflow: https://github.com/Matysh/houseplan-card/actions/runs/32593897715

Issue: #244
User-Visible: no
2026-08-23 00:34:55 +03:00
Sergey Matyunin f6f877e393 fix(spaces): repair orphaned plan references
Issue: #244
User-Visible: yes
2026-08-23 00:34:55 +03:00
claude[bot]andSergey Matyunin f60383d274 docs: review document for #244
Issue: #244
User-Visible: no
2026-08-23 00:34:32 +03:00
Sergey Matyunin dcfabcc8ef docs(spec): state touch and kiosk impact
Close SPEC-REVIEW-244-r1 M1 by making restored-marker parity in desktop, touch, kiosk, and Static explicit while retaining the desktop-first editor contract.

Issue: #244
User-Visible: no
2026-08-23 00:34:32 +03:00
claude[bot]andSergey Matyunin 2a6e6b5e96 docs: review document for #244
Issue: #244
User-Visible: no
2026-08-23 00:34:32 +03:00
Sergey Matyunin d34c7563e3 docs(spec): define orphan space reference recovery
Specify deterministic Optimize and import repair, safe marker detachment, guarded space deletion, and invalid default-floor feedback for issue #244.

Issue: #244
User-Visible: no
2026-08-23 00:34:32 +03:00
Matysh 0c5683d3bf test: read the fixture basename on either separator
Issue: #247
User-Visible: no
2026-08-23 00:19:54 +03:00
Sergey Matyunin 005eb7fa1b test: accept reviewed v1.67.0-beta.2 goldens
Accepted the complete Linux artifact after visual review. The intended visual changes are the decor layer above room and Glow-base fills (#231), centered opening symbols with preserved flip direction (#242), and exact before/after space-tab drop indicators (#243). The unchanged large-house zoom 0.40 frame remained within its existing threshold and was restored to its prior bytes and hash.

Issue: #231
Issue: #242
Issue: #243
User-Visible: no
Release: v1.67.0-beta.2
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32597653292
2026-08-22 23:57:06 +03:00
Sergey Matyunin 1af9cfce42 Release v1.67.0-beta.2 candidate
Issue: #231
Issue: #242
Issue: #243
User-Visible: yes
2026-08-22 23:46:11 +03:00
claude[bot] b49983fd8d docs: review document for #242
Validate / docs (push) Failing after 24s
Validate / reuse (push) Successful in 36s
Validate / provenance (push) Successful in 1m11s
Validate / changes (push) Successful in 1m11s
Validate / process-gate (push) Failing after 1m17s
Validate / hacs (push) Failing after 19s
Validate / hassfest (push) Failing after 20s
Validate / backend (push) Failing after 8m40s
Validate / frontend (push) Successful in 8m46s
Validate / golden (push) Failing after 15m26s
Validate / performance_smoke (push) Failing after 14m38s
Validate / smoke (push) Failing after 41m9s
Issue: #242
User-Visible: no
2026-08-22 18:35:35 +00:00
Sergey Matyunin 1885c779e8 docs: refresh opening screenshot provenance
Accept the canonical Docs screenshots artifact after visually reviewing the opening-symbol geometry in the changed plan editor frame.

Issue: #242
User-Visible: no
2026-08-22 21:17:01 +03:00
Sergey Matyunin a65db3985d fix(openings): preserve gate flip turn direction
Address code review H1 by keeping gate flip direction observable without a second vertical mirror. Add fail-closed golden contracts, smoke coverage, and a mutation guard for the affected opening-symbol geometry.

Issue: #242
User-Visible: yes
2026-08-22 21:14:37 +03:00
claude[bot] 2af7b0b5a2 docs: review document for #242
Issue: #242
User-Visible: no
2026-08-22 17:56:41 +00:00
Sergey Matyunin 5e13e34024 docs: accept centered opening screenshots
Captured by the canonical Docs screenshots workflow: https://github.com/Matysh/houseplan-card/actions/runs/32587586391

Issue: #242
User-Visible: no
2026-08-22 20:27:08 +03:00
Sergey Matyunin 185eef7af2 test(openings): link centered symbol smokes
Issue: #242
User-Visible: no
2026-08-22 20:25:57 +03:00
Sergey Matyunin 6d6db12c79 fix(openings): center symbols across wall depth
Issue: #242
User-Visible: yes
2026-08-22 20:23:20 +03:00
claude[bot] 4241bb56f8 docs: review document for #242
Issue: #242
User-Visible: no
2026-08-22 17:15:50 +00:00
Sergey Matyunin 0033138ab2 docs: specify centered opening symbols
Issue: #242
User-Visible: no
2026-08-22 20:08:48 +03:00
claude[bot] 5bf1868490 docs: review document for #243
Issue: #243
User-Visible: no
2026-08-22 16:58:34 +00:00
Sergey Matyunin e4fe502e0c docs: accept screenshot fingerprint for #243
Captured by the canonical Docs screenshots workflow: https://github.com/Matysh/houseplan-card/actions/runs/32585516781

Issue: #243
User-Visible: no
2026-08-22 19:43:53 +03:00
claude[bot]andSergey Matyunin 0fa7c2881a docs: review document for #243
Issue: #243
User-Visible: no
2026-08-22 19:42:15 +03:00
Sergey Matyunin e6ac0337d0 fix(tabs): restore captured mouse reordering
Issue: #243
User-Visible: yes
2026-08-22 19:42:15 +03:00
claude[bot]andSergey Matyunin 02a523045b docs: review document for #243
Issue: #243
User-Visible: no
2026-08-22 19:40:31 +03:00
Sergey Matyunin 133fdc9c10 docs(spec): define reliable space tab drop target
Issue: #243
User-Visible: no
2026-08-22 19:40:31 +03:00
Sergey Matyunin 56a3977363 docs: accept CI-captured screenshots
Issue: #246
User-Visible: no
2026-08-22 19:37:18 +03:00
Matysh 5fa3733c7e ci: make the screenshot verdict compare browsers, not counts
Issue: #246
User-Visible: no
2026-08-22 19:30:48 +03:00
Matysh e50c01245a ci: capture documentation screenshots in one place
Issue: #246
User-Visible: no
2026-08-22 19:12:27 +03:00
Matysh 4799c6be0c fix: keep the docs fingerprint blind to the product version
Issue: #245
User-Visible: no
2026-08-22 19:02:11 +03:00
claude[bot] 99ceb1e57f docs: review document for #231
Issue: #231
User-Visible: no
2026-08-22 15:58:00 +00:00
Sergey Matyuninandclaude[bot] cf3010cd0c docs: record complete decor golden impact
Issue: #231
User-Visible: no
2026-08-22 15:58:00 +00:00
claude[bot] bd860a7594 docs: review document for #231
Issue: #231
User-Visible: no
2026-08-22 15:58:00 +00:00
Sergey Matyuninandclaude[bot] 09ab9266c0 fix: render decor above room fills
Issue: #231
User-Visible: yes
2026-08-22 15:58:00 +00:00
Matysh 6683e526d8 docs: put the smoke shortlist into the gate rules
Issue: #241
User-Visible: no
2026-08-22 18:33:17 +03:00
Matysh c0a4fa29ee ci: derive the browser-smoke shortlist from the diff
Issue: #241
User-Visible: no
2026-08-22 18:32:52 +03:00
441 changed files with 60630 additions and 10339 deletions
+84
View File
@@ -0,0 +1,84 @@
# Скриншоты документации снимаются здесь и только здесь (#246).
#
# Съёмка на машине исполнителя даёт байтово разный PNG при одинаковом кадре:
# сглаживание и хинтинг зависят от окружения. Измерено на истории — пересъёмка
# в #231 изменила два файла из девяти на 7–8 байт, набор с беты все девять
# целиком. Одно окружение убирает этот шум насовсем.
#
# Джоба ничего не коммитит: она публикует артефакт, который человек принимает
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
# попадают в репозиторий через явное решение, а не через бота.
name: Docs screenshots
on:
workflow_dispatch:
inputs:
ref:
description: Ветка или SHA, с которого снимать
required: false
default: dev
permissions:
contents: read
jobs:
capture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
# Тот же кэш и тот же отказ от --with-deps, что в smoke/golden (#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 the bundle the screenshots must come from
run: npm run build
- name: Capture
run: node demo/docs/capture.mjs
# Вердикт до всякой приёмки. Само число изменившихся файлов ничего не
# говорит: набор, снятый другим браузером, меняет их все, и это нормально
# ровно один раз — при переходе на канонический прогон. Сравнивать надо
# браузер: тот же Chromium и десять изменившихся картинок означают, что
# изменился продукт (или что-то не так), другой Chromium — ожидаемую
# разницу рендеринга.
- name: Вердикт
run: |
git status --porcelain docs/images
changed=$(git diff --name-only docs/images | grep -c png || true)
before=$(git show HEAD:docs/images/screenshots.json | node -e \
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).chromium||'')}catch{console.log('')}})")
after=$(node -e "console.log(require('./docs/images/screenshots.json').chromium)")
echo "--- изменившихся PNG: $changed"
echo "--- Chromium: было «${before:-не записан}», стало «$after»"
if [ "$before" = "$after" ] && [ "$changed" -gt 0 ]; then
echo "ВЕРДИКТ: тот же браузер, а картинки изменились — изменился продукт."
echo "Смотрите на кадры: если изменение ожидаемое, принимайте."
elif [ "$before" != "$after" ]; then
echo "ВЕРДИКТ: браузер другой, поэтому переписаны все кадры сразу."
echo "Это ожидаемо один раз — при переходе на канонический прогон."
echo "Если Chromium сменился неожиданно, сверьте закреплённую версию в package-lock."
else
echo "ВЕРДИКТ: ничего не изменилось, принимать нечего."
fi
- name: Upload candidate
uses: actions/upload-artifact@v7
with:
name: docs-screenshots
path: |
docs/images/*.png
docs/images/screenshots.json
if-no-files-found: error
+2 -2
View File
@@ -147,9 +147,9 @@ jobs:
- name: Build both exact source trees
run: |
npm --prefix candidate run build
cp candidate/dist/houseplan-card.js candidate/demo/srv/assets/houseplan-card.js
(cd candidate && node scripts/bundle-sync.mjs)
npm --prefix baseline run build
cp baseline/dist/houseplan-card.js baseline/demo/srv/assets/houseplan-card.js
(cd baseline && node scripts/bundle-sync.mjs)
- name: Capture base and candidate profiles
working-directory: candidate
+131 -8
View File
@@ -229,22 +229,110 @@ jobs:
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
fi
# Ревьюер обязан смотреть тот же код, который уедет в dev (#257). Раньше
# ревью шло по ветке как есть, а слияние делало ребейз — проверенный SHA и
# слитый SHA были разными коммитами. Пока расхождение с dev текстовое,
# ребейз упирается в конфликт и это видно; смысловое расхождение git
# склеивает молча, и в dev уезжает комбинация, которую никто не читал.
# Именно так пришёл регресс #234.
#
# Заодно снимается плата за конфликт: он обнаруживался ПОСЛЕ сорока минут
# ревью и потраченных лимитов подписки, хотя виден за пять секунд до них.
#
# Этап spec не затрагивается: ветку ТЗ в dev никто не сливает, и трогать
# чужую ветку без нужды — лишний риск.
- name: Привести ветку к dev
id: rebase
if: needs.guard.outputs.stage == 'code' && steps.branch.outputs.name != ''
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
# rebase, в отличие от commit, не принимает -c user.*: он запускает
# свои процессы и требует личность в окружении, иначе падает с
# «unable to auto-detect email address».
GIT_AUTHOR_NAME: claude[bot]
GIT_AUTHOR_EMAIL: 209825114+claude[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: claude[bot]
GIT_COMMITTER_EMAIL: 209825114+claude[bot]@users.noreply.github.com
run: |
git fetch -q origin dev
if git merge-base --is-ancestor origin/dev HEAD; then
echo "ветка содержит весь dev — ребейз не нужен"
exit 0
fi
behind=$(git rev-list --count "HEAD..origin/dev")
before=$(git rev-parse "origin/$BRANCH")
echo "dev впереди на $behind коммит(ов) — привожу ветку"
if ! git rebase origin/dev; then
git rebase --abort || true
echo "conflict=true" >> "$GITHUB_OUTPUT"
echo "::warning::ветка $BRANCH не ребейзится на dev без конфликта — ревью не запускается"
exit 0
fi
# --force-with-lease с явным ожидаемым значением обязателен: между
# fetch и push автор мог запушить коммит, и слепой --force потерял бы
# его молча. Расхождение lease — падение прогона, а не предупреждение:
# ревью пошло бы по коду, которого на ветке уже нет.
if ! git push -q --force-with-lease="refs/heads/$BRANCH:$before" \
"https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:refs/heads/$BRANCH"; then
echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора"
exit 1
fi
# Локальная ссылка обновляется тоже: шаг слияния берёт origin/$BRANCH,
# и без этого он ребейзил бы заново уже приведённое.
git fetch -q origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"
short_before=$(git rev-parse --short "$before")
short_after=$(git rev-parse --short HEAD)
echo "note=Ветка приведена к dev конвейером до ревью: поверх легло $behind коммит(ов) dev, $short_before -> $short_after. После ребейза это другой код (§7.2) — разбор полный, а не по дельте." >> "$GITHUB_OUTPUT"
echo "ветка $BRANCH приведена к dev: $short_before -> $short_after"
# Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона
# метка меняется всегда» при этом держится: возврат в S6-in-progress —
# тоже смена метки, и автор не ждёт впустую.
- name: Конфликт с dev — вернуть автору без ревью
if: steps.rebase.outputs.conflict == 'true'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
BRANCH: ${{ steps.branch.outputs.name }}
run: |
cat > /tmp/stale.md <<EOF
**Ревью не запускалось:** ветка \`$BRANCH\` не ребейзится на \`dev\` без конфликта. Код никто не читал, вердикта нет, цикл ревью не израсходован.
Проверка стоит до ревью намеренно: конфликт всё равно вернул бы задачу, но уже после сорока минут работы ревьюера и потраченных лимитов.
Задача переведена в \`S6-in-progress\`. Осталось:
1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;
2. запушить ветку;
3. вернуть метку \`S7-code-review\`.
[Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
EOF
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale.md
gh issue edit "$NUM" --repo "${{ github.repository }}" \
--add-label S6-in-progress --remove-label S7-code-review
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
- name: Установить зависимости
if: steps.rebase.outputs.conflict != 'true'
run: npm ci
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
# но когда нужен — качать его заново дороже, чем держать в кэше.
- name: Кэш браузеров Playwright
id: pw
if: steps.rebase.outputs.conflict != 'true'
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'
if: steps.rebase.outputs.conflict != 'true' && steps.pw.outputs.cache-hit != 'true'
# Без --with-deps: системные библиотеки Chromium предустановлены в
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
@@ -254,6 +342,7 @@ jobs:
- name: Review
id: review
if: steps.rebase.outputs.conflict != 'true'
uses: anthropics/claude-code-action@v1
env:
# Вне рабочей копии: восстановление дерева ревьюером не должно
@@ -277,6 +366,8 @@ jobs:
Номер захода нужен для имени документа — два документа с
одинаковым номером затёрли бы друг друга.
${{ steps.rebase.outputs.note }}
**Если цикл не первый — объём разбора по дельте, а не заново**
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
всех раундов, и повторный цикл заново выводил продуктовую рамку и
@@ -359,14 +450,35 @@ jobs:
выбирать тут нечего. Пропуск этого шага в #230 и #234 оставил `dev`
с красным job `docs` до следующей задачи (#237).
Если diff трогает геометрию или ссылки на неё — рёбра комнат,
записи толщины, `layout`, `marker.space`, `open_spans` — обязательны
инварианты модели (#254): `npm test` уже гоняет их на всех моделях
проекта, а на конкретной конфигурации они проверяются командой
`npm run invariants -- --config <экспорт или ответ config/get>`.
Три вопроса, на которые они отвечают, и все три уже стоили
продукту дефектов: не исчезла ли запись толщины (#253), разрешима ли
каждая ссылка (#244, #252) и равен ли ключ записи толщины ключу
решёточного ребра (#258, #259). Последний сравнивает строки без
допусков: сдвиг ключа на один шаг решётки равен допуску первых двух,
поэтому они на нём промахиваются. Если задача меняет геометрию, а
инварианты в отчёте не названы — это непрогнанный гейт, а не мелочь.
По необходимости, и «необходимость» определяется diff'ом и AC:
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс
относящиеся к тронутым поверхностям. Их 163; прогон всех уместен
только когда задача действительно задевает всё. Выбирать по теме
недостаточно: регресс #234 поймал `smoke_wall_junctions`, который
по названию про стыки стен, а не про толщину отрезка. Надёжнее
grep — искать в `demo/smoke_*.mjs` имена изменённых функций и
полей, и прогонять найденное;
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс те,
что печатает `node scripts/smoke-select.mjs --base <base> --head <head>`.
Сколько их всего — считает `ls demo/smoke_*.mjs | wc -l`; вшитое
в этот текст число трижды расходилось с деревом, поэтому его
здесь больше нет. Прогон всех уместен только когда задача
действительно задевает всё. Выбирать по теме недостаточно: регресс #234 поймал
`smoke_wall_junctions`, который по названию про стыки стен, а не
про толщину отрезка. Инструмент печатает три вида ответа, и они
разные: «прямое совпадение» — смок называет изменённый символ,
«зарегистрированная связь» — смок проверяет следствие контракта,
не называя его, «НЕОПРЕДЕЛЁННОСТЬ» — связь не доказана, и это не
разрешение ничего не прогонять. Вывод инструмента прикладывается
к комментарию ревью вместе с решением по каждой строке: прогнал
либо не прогнал и почему. Слабые связи (одно распространённое
имя) — повод посмотреть, а не обязанность прогонять;
- `npm run golden:verify` — если diff может изменить видимый
результат: рендер, геометрия, стили, слои;
- `python -m pytest tests_backend -q` — если тронут
@@ -374,6 +486,14 @@ jobs:
- performance-профили — если названы в AC либо тронуты
чувствительные к перфу пути.
**Одно число — один источник.** Если дифф добавляет или меняет
величину, видимую пользователю, назови в отчёте прямо: какое число
видно дважды (превью против записи, подпись против площади,
подсветка инструмента против сохранённого значения) и один ли у него
источник. Три дефекта подряд имели именно эту причину — #234, #233 и
способ, которым #234 обнаружили. Механическая часть закреплена
тестом `test/single-source-numbers.test.mjs`, смысловая — твоя.
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
тем тестам, которые ты прогонял.
@@ -436,6 +556,7 @@ jobs:
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
# решает этот шаг, а не модель: всё остальное откатывается.
- name: Опубликовать документ ревью
if: steps.rebase.outputs.conflict != 'true'
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
@@ -534,6 +655,7 @@ jobs:
- name: Решение по вердикту
id: decide
if: steps.rebase.outputs.conflict != 'true'
env:
OUT: ${{ steps.review.outputs.structured_output }}
STAGE: ${{ needs.guard.outputs.stage }}
@@ -612,6 +734,7 @@ jobs:
echo "слито в dev: $(git rev-parse --short HEAD)"
- name: Переставить метку
if: steps.rebase.outputs.conflict != 'true'
env:
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
-1
View File
@@ -81,7 +81,6 @@ jobs:
npm ci
npm run build
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
VERSION=${TAG#v}
grep -Fq "$VERSION" dist/houseplan-card.js
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
if: ${{ !github.event.release.prerelease }}
run: |
npx playwright install --with-deps chromium
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
node scripts/bundle-sync.mjs
npm run continuity:screencast
- name: Upload failed continuity frames
if: ${{ failure() && !github.event.release.prerelease }}
+64 -11
View File
@@ -6,6 +6,12 @@ on:
# to the same SHA and must not duplicate the browser validation jobs.
branches:
- '**'
# Документы ревью конвейер пишет пачками — 340 коммитов за месяц, и каждый
# гонял лёгкую половину Validate впустую (≈15 часов раннера в месяц).
# Релизного кандидата это не затрагивает: тег всегда стоит на коммите,
# который меняет версию и бандл, а не только `docs/reviews/**`.
paths-ignore:
- 'docs/reviews/**'
pull_request:
# A new push supersedes an unfinished validation for the same branch or PR.
@@ -24,6 +30,28 @@ jobs:
- name: Validate public documentation
run: node scripts/check-docs.mjs --external
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
process-workflow-sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: process.yml идентичен в main и dev
run: |
git fetch --quiet origin main dev
if diff <(git show origin/main:.github/workflows/process.yml) \
<(git show origin/dev:.github/workflows/process.yml); then
echo "main и dev идентичны"
else
echo "РАСХОЖДЕНИЕ: process.yml в main и dev различаются."
echo "Конвейер исполняет версию из ветки по умолчанию, поэтому"
echo "правку нужно отправить в обе ветки."
exit 1
fi
provenance:
runs-on: ubuntu-latest
steps:
@@ -245,16 +273,22 @@ jobs:
run: npm test
- name: Build
run: npm run build
# Копия стенда больше не коммитится (#255): сверяются две обязательные.
- name: Card bundle snapshots in sync
run: |
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
smoke:
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
needs: [frontend, reuse]
if: needs.reuse.outputs.smoke != 'true'
runs-on: ubuntu-latest
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
# критическим путём всего Validate. Три шарда режут его примерно вдвое;
# цена — трижды `npm ci` и сборка, около двух оплаченных минут раннера.
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
@@ -277,12 +311,22 @@ jobs:
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
run: npm run bundle:sync
- name: Smoke suite (шард ${{ matrix.shard }} из 3)
env:
SHARD: ${{ matrix.shard }}
SHARDS: '3'
run: |
fail=0
index=0
ran=0
mkdir -p /tmp/smoke-logs
# Деление по порядковому номеру файла: список отсортирован, поэтому
# разбиение детерминировано и не зависит от времени прогона.
for f in demo/smoke_*.mjs; do
index=$((index + 1))
if [ $(( (index - 1) % SHARDS + 1 )) -ne "$SHARD" ]; then continue; fi
ran=$((ran + 1))
name=$(basename "$f" .mjs)
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
echo "ok $name"
@@ -292,18 +336,27 @@ jobs:
fail=1
fi
done
echo "--- шард ${SHARD}/${SHARDS}: прогнано ${ran} из ${index}"
# Пустой шард — признак, что деление сломалось, а не что работы нет.
if [ "$ran" -eq 0 ]; then echo "шард пуст: проверьте деление"; exit 1; fi
exit $fail
- name: Upload smoke logs
if: failure()
uses: actions/upload-artifact@v7
with:
name: smoke-logs
name: smoke-logs-${{ matrix.shard }}
path: /tmp/smoke-logs
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
# Маркер переиспользования пишется ОДИН раз и только когда прошли все шарды:
# частично прогнанная матрица не имеет права выглядеть как выполненная работа.
smoke_done:
needs: [smoke, reuse]
if: needs.reuse.outputs.smoke != 'true'
runs-on: ubuntu-latest
steps:
- name: Записать маркер успеха
run: |
printf '%s\n' "smoke прогнана успешно" \
printf '%s\n' "smoke прогнана успешно (3 шарда)" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
@@ -344,7 +397,7 @@ jobs:
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
run: npm run bundle:sync
- name: Capture or verify golden matrix
id: golden
run: |
@@ -410,7 +463,7 @@ jobs:
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
run: npm run bundle:sync
- name: Capture the heaviest Glow state
run: |
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
+5
View File
@@ -7,3 +7,8 @@ __pycache__/
.venv-backend/
artifacts/
.agents/
# Копия бандла для стенда: её собирает `npm run bundle:sync`, а в репозитории
# она только росла — 364 версии по 1.16 МБ за семь недель (#255). Обязательных
# копий две: `dist/` (артефакт сборки) и `custom_components/` (её ставит HACS).
demo/srv/assets/houseplan-card.js
+2 -2
View File
@@ -87,7 +87,7 @@ Start with the spec?" is the correct answer, not a smaller patch.
| **A — product** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, i18n, `custom_components/**/translations/**` | yes |
| **B — gates and tooling** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, `.github/workflows/**`, `rollup.config.mjs`, `tsconfig*.json` | yes; may reuse the issue it covers |
| **C — documentation** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md` | not if it is part of its issue's DoD |
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**` | never changes on its own |
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` | never changes on its own. The stand copy `demo/srv/assets/houseplan-card.js` is no longer committed (#255): build it with `npm run bundle:sync` |
The table above is a summary; `PROCESS.md` §1 is the authority and now covers the
configuration files this one omits — `package.json`, `package-lock.json`,
@@ -322,7 +322,7 @@ byte-for-byte:
```
cp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
npm run bundle:sync # dist → custom_components + demo/srv/assets (#255)
```
During the implementation cycle the fast gates always run. Since 2026-08-14 the
+51 -4
View File
@@ -35,7 +35,7 @@
| **A. Продукт** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, `src/i18n/*.json`, `custom_components/**/translations/*` | **Да, обязательно.** Только из «Готово к разработке» или дальше |
| **B. Гейты и инструменты** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, весь `.github/**`, `.githooks/**`, `rollup.config.mjs`, `tsconfig*.json`, `package.json`, `package-lock.json`, `pytest.ini`, `.gitignore`, `.gitattributes` | **Да.** Может использовать issue того изменения, которое покрывает; самостоятельная работа над гейтом получает свой issue (тип `tech-debt`) |
| **C. Документация** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md`, `CONTRIBUTING.md`, `PROCESS*.md`, `LICENSE`, `(CODE\|SPEC)-REVIEW-*.md` | Документирование A/B в том же коммите — часть DoD своего issue. Самостоятельная работа над документацией — свой issue |
| **D. Сгенерированное** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**` | Никогда не меняется само по себе. Коммит **только** класса D допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью |
| **D. Сгенерированное** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` (копия стенда `demo/srv/assets/houseplan-card.js` с #255 не коммитится вовсе) | Никогда не меняется само по себе. Коммит **только** класса D допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью |
Практический смысл таблицы: «я только поправил тест» и «я только пересобрал
бандл» перестают быть лазейками.
@@ -537,21 +537,35 @@ issue #NN
npx tsc --noEmit
npm test
npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js \
&& cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
# копия стенда собирается `npm run bundle:sync`, в репозитории её нет (#255)
node scripts/smoke-select.mjs --base origin/dev --head HEAD # какие смоки относятся к диффу
node demo/smoke_<целевые>.mjs
npm run golden:verify # если менялся визуал
node scripts/check-docs.mjs # если менялся src/**
node scripts/model-invariants.mjs --config <экспорт> # если правилась геометрия или ссылки
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` при изменении видимого
необходимости, определяемой diff'ом и AC: браузерные смоки (сколько их —
считает `ls demo/smoke_*.mjs | wc -l`, вшитое число здесь трижды отставало от
дерева; прогон всех уместен только когда задача задевает всё; какие относятся к
диффу, печатает
`node scripts/smoke-select.mjs --base origin/dev --head HEAD`, и его вывод
прикладывается к ревью вместе с решением по каждой строке), `golden:verify` при изменении видимого
результата, `pytest tests_backend` при правках в Python, performance-профили при
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
Скриншоты снимаются **только** джобой `Docs screenshots` (`workflow_dispatch`) и
принимаются локально: `npm run docs:accept -- --reviewed --from=<распакованный
артефакт>` (#246). Съёмка на своей машине даёт байтово другой PNG при том же
кадре, и набор из «не того» браузера переписывает все десять файлов без единого
содержательного изменения. Приёмка отказывает, если кандидат снят не с этого
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит делает
человек.
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
@@ -564,6 +578,17 @@ demo/docs/capture.mjs`, коммит вместе с задачей.
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
пропуском.
**Одно число — один источник.** Любая величина, которую пользователь видит
дважды — превью против записи, подпись против площади, подсветка инструмента
против сохранённого значения, — обязана считаться в одном месте. Три дефекта
подряд имели ровно эту причину: #234 (резинка показывала 12 см, запись хранила
24), #233 (подпись мерила по осевым линиям, площадь рядом — по полу) и способ,
которым #234 нашли (подсветка «Толщины» врала согласованно с записью). Ревьюер
отвечает на вопрос прямо: какое число в этом диффе видно дважды и один ли у него
источник. Механическая часть правила закреплена тестом
`test/single-source-numbers.test.mjs` — строку с единицей измерения собирает
только канонический форматтер; смысловая часть остаётся за ревью.
**Гейт беты** (условие закрытия issue): CI Validate зелёный на точном SHA тега.
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
@@ -788,6 +813,28 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
никто не может выйти и о котором никто не узнает, для конвейера хуже громкой
ошибки.
**Ветка приводится к `dev` до ревью, а не после** (#257). Раньше ревью читало ветку
как есть, а слияние делало ребейз — проверенный SHA и слитый SHA были разными
коммитами. Пока расхождение с `dev` текстовое, ребейз упирается в конфликт и это
видно; смысловое расхождение git склеивает молча, и в `dev` уезжает комбинация,
которую ревьюер не читал. Именно так пришёл регресс #234. Шаг перед ревью делает
одно из трёх:
- ветка уже содержит весь `dev` — ничего;
- отстала и ребейзится чисто — ребейз, `push --force-with-lease`, ревью по
приведённому состоянию. Факт ребейза передаётся в промпт, чтобы сработало
правило §7.2 о полном разборе вместо дельты;
- конфликт — возврат в `S6-in-progress` **до** запуска ревью. Цикл при этом не
расходуется: код никто не читал, вердикта нет.
Проверка стоит до ревью не только ради совпадения SHA. Конфликт всё равно вернул бы
задачу, но обнаруживался он после сорока пяти минут работы ревьюера и потраченных
лимитов подписки, хотя виден за пять секунд до них.
`--force-with-lease` здесь обязателен с явным ожидаемым значением: между чтением
ветки и пушем автор мог запушить коммит, и слепой `--force` потерял бы его молча.
Расхождение lease — падение прогона, а не предупреждение.
Поэтому зелёное код-ревью с неудавшимся слиянием ведёт не в `S8-merged`, а в
`S6-in-progress`: работа действительно вернулась к автору, только осталась не
правка кода, а ребейз. Вердикт при этом в силе, переделывать нечего. После ребейза
+2 -2
View File
@@ -46,12 +46,12 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.67.0-beta.1"
VERSION = "1.67.0-rc.1"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
# user have different compatibility lifecycles.
PLAN_MODEL_VERSION = 6
PLAN_MODEL_VERSION = 7
EXPORT_VERSION = 1
MAX_EXPORT_BYTES = 8 * 1024 * 1024
IMPORT_PREVIEW_TTL_S = 10 * 60
@@ -1,7 +1,7 @@
"""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
Keep the precision, lattice formula and field allow-list in lockstep; a shared
fixture is exercised by both runtimes.
"""
from __future__ import annotations
@@ -13,10 +13,12 @@ from typing import Any
COORDINATE_DECIMALS = 9
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
LATTICE_GRID_N = 240
LATTICE_NOISE_STEPS = 1e-4
def canonicalize_number(value: Any) -> Any:
"""Return one stable IEEE-754 representation for an allow-listed number."""
"""Return one stable IEEE-754 representation for an allow-listed scalar."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return value
number = float(value)
@@ -32,6 +34,22 @@ def canonicalize_number(value: Any) -> Any:
return result
def canonicalize_lattice_coordinate(value: Any) -> Any:
"""Collapse near-node noise while preserving authored off-grid values."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return value
number = float(value)
if not math.isfinite(number):
return value
scaled = number * LATTICE_GRID_N
# JavaScript Math.round: ties go toward +infinity, unlike Python round().
nearest = math.floor(scaled + 0.5)
if abs(scaled - nearest) < LATTICE_NOISE_STEPS:
result = nearest / LATTICE_GRID_N
return 0.0 if result == 0 else result
return canonicalize_number(number)
def _record(value: Any) -> dict[str, Any] | None:
return value if isinstance(value, dict) else None
@@ -42,37 +60,43 @@ def _records(value: Any) -> list[dict[str, Any]]:
return [item for item in value if isinstance(item, dict)]
def _fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
def _scalar_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:
def _lattice_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
for name in names:
if name in record:
record[name] = canonicalize_lattice_coordinate(record[name])
def _lattice_point(value: Any) -> None:
if not isinstance(value, list):
return
for index in range(min(2, len(value))):
value[index] = canonicalize_number(value[index])
value[index] = canonicalize_lattice_coordinate(value[index])
def _points(value: Any) -> None:
def _lattice_points(value: Any) -> None:
if not isinstance(value, list):
return
for point in value:
_point(point)
_lattice_point(point)
def canonicalize_position(position: Any) -> Any:
"""Canonicalise x/y in one layout record without touching its metadata."""
"""Canonicalise lattice x/y in one layout record, preserving metadata."""
result = copy.deepcopy(position)
record = _record(result)
if record is not None:
_fields(record, ("x", "y"))
_lattice_fields(record, ("x", "y"))
return result
def canonicalize_layout_geometry(layout: Any) -> Any:
"""Canonicalise x/y in every layout record, preserving future fields."""
"""Canonicalise lattice x/y in every layout record."""
result = copy.deepcopy(layout)
record = _record(result)
if record is None:
@@ -80,7 +104,7 @@ def canonicalize_layout_geometry(layout: Any) -> Any:
for position in record.values():
item = _record(position)
if item is not None:
_fields(item, ("x", "y"))
_lattice_fields(item, ("x", "y"))
return result
@@ -92,7 +116,7 @@ def canonicalize_config_geometry(config: Any) -> Any:
return result
for space in _records(root.get("spaces")):
_fields(
_scalar_fields(
space,
(
"plan_x",
@@ -105,45 +129,48 @@ def canonicalize_config_geometry(config: Any) -> Any:
)
for room in _records(space.get("rooms")):
_fields(room, ("x", "y", "w", "h"))
_points(room.get("poly"))
_lattice_fields(room, ("x", "y", "w", "h"))
_lattice_points(room.get("poly"))
for wall in _records(space.get("walls")):
_point(wall.get("a"))
_point(wall.get("b"))
_lattice_point(wall.get("a"))
_lattice_point(wall.get("b"))
for opening in _records(space.get("openings")):
_fields(opening, ("x", "y", "angle", "length"))
_lattice_fields(opening, ("x", "y"))
_scalar_fields(opening, ("angle", "length"))
host = _record(opening.get("host"))
if host is not None:
_fields(host, ("t",))
_scalar_fields(host, ("t",))
for decor in _records(space.get("decor")):
kind = decor.get("kind")
if kind == "line":
_fields(decor, ("x1", "y1", "x2", "y2"))
_lattice_fields(decor, ("x1", "y1", "x2", "y2"))
elif kind in ("rect", "ellipse", "furniture"):
_fields(decor, ("x", "y", "w", "h", "angle"))
_lattice_fields(decor, ("x", "y", "w", "h"))
_scalar_fields(decor, ("angle",))
elif kind == "text":
_fields(decor, ("x", "y", "scale", "angle"))
_lattice_fields(decor, ("x", "y"))
_scalar_fields(decor, ("scale", "angle"))
for draft in _records(space.get("room_drafts")):
_points(draft.get("points"))
_lattice_points(draft.get("points"))
for partition in _records(space.get("partitions")):
_point(partition.get("a"))
_point(partition.get("b"))
_lattice_point(partition.get("a"))
_lattice_point(partition.get("b"))
for column in _records(space.get("wall_columns")):
_point(column.get("center"))
_lattice_point(column.get("center"))
if column.get("shape") == "square":
_fields(column, ("angle",))
_scalar_fields(column, ("angle",))
for span in _records(space.get("open_spans")):
_point(span.get("a"))
_point(span.get("b"))
_lattice_point(span.get("a"))
_lattice_point(span.get("b"))
for marker in _records(root.get("markers")):
_fields(marker, ("angle",))
_scalar_fields(marker, ("angle",))
return result
File diff suppressed because one or more lines are too long
+57 -3
View File
@@ -822,6 +822,56 @@ def _orphan_marker(key: str, manifest: dict[str, Any] | None, space_id: str, use
}
def _repair_target_space_refs(
current_config: dict[str, Any],
current_layout: dict[str, Any],
old_space_id: str,
new_space_id: str,
old_room_ids: dict[str, str],
) -> tuple[dict[str, Any], dict[str, Any], int]:
"""Apply the exact map of this import to pre-existing orphan target refs."""
config = _json_copy(current_config)
layout = _json_copy(current_layout)
if any(str(space.get("id")) == old_space_id for space in config.get("spaces") or []):
return config, layout, 0
repaired = 0
for marker in config.get("markers") or []:
if marker.get("space") == old_space_id:
marker["space"] = new_space_id
repaired += 1
room_id = marker.get("room_id")
if room_id is not None and str(room_id) in old_room_ids:
marker["room_id"] = old_room_ids[str(room_id)]
repaired += 1
vacuum = marker.get("vacuum")
segment_map = vacuum.get("segment_map") if isinstance(vacuum, dict) else None
if isinstance(segment_map, dict):
for key, room_id in list(segment_map.items()):
mapped = old_room_ids.get(str(room_id))
if mapped is not None:
segment_map[key] = mapped
repaired += 1
# Rewrite ownership before rekeying labels. A valid destination wins a
# collision inside the target; the imported document itself is overlaid
# later and has final priority for its own label position.
for position in layout.values():
if isinstance(position, dict) and position.get("s") == old_space_id:
position["s"] = new_space_id
repaired += 1
for old_room_id, new_room_id in old_room_ids.items():
old_key = "rl_" + old_room_id
if old_key not in layout:
continue
new_key = "rl_" + new_room_id
if new_key not in layout:
layout[new_key] = layout[old_key]
del layout[old_key]
repaired += 1
return config, layout, repaired
def build_space_merge(
document: dict[str, Any],
current_config: dict[str, Any],
@@ -859,6 +909,9 @@ def build_space_merge(
old_room_ids = {
old: new for old, new in id_map.items() if old != old_space_id and new.startswith("room_")
}
target_config, target_layout, repaired_target_refs = _repair_target_space_refs(
current_config, current_layout, old_space_id, new_space_id, old_room_ids,
)
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"]]
@@ -884,7 +937,7 @@ def build_space_merge(
if isinstance(item, dict)
}
incoming_bindings = _binding_inventory(incoming, incoming_layout, manifest)
current_bindings = _binding_inventory(current_config, current_layout)
current_bindings = _binding_inventory(target_config, target_layout)
duplicate = incoming_bindings & current_bindings
marker_map: dict[str, str] = {}
output_markers: list[dict[str, Any]] = []
@@ -991,14 +1044,14 @@ def build_space_merge(
virtualized += 1
output_layout[new_key] = {**pos, "s": new_space_id}
merged_config = _json_copy(current_config)
merged_config = target_config
merged_config.setdefault("spaces", []).append(space)
merged_config.setdefault("markers", []).extend(output_markers)
dropped_marker_links += _drop_invalid_import_marker_links(
merged_config,
clean_ids={str(marker.get("id")) for marker in output_markers},
)
merged_layout = {**_json_copy(current_layout), **output_layout}
merged_layout = {**target_layout, **output_layout}
if len(merged_config.get("spaces") or []) > MAX_SPACES \
or len(merged_config.get("markers") or []) > MAX_MARKERS \
or len(merged_layout) > MAX_LAYOUT:
@@ -1034,6 +1087,7 @@ def build_space_merge(
"virtualized": virtualized,
"orphan_markers": len(output_markers) - len(marker_map),
"dropped_marker_links": dropped_marker_links,
"repaired_target_refs": repaired_target_refs,
}
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.67.0-beta.1"
"version": "1.67.0-rc.1"
}
+95
View File
@@ -0,0 +1,95 @@
"""Read-only projections of the stored plan (#256).
A full configuration is 70 KB on a real installation: three spaces and 139
markers. Every diagnostic question — "which space does this marker point at",
"how many markers are hidden", "what is on the first floor" — used to require
downloading all of it, because `houseplan/config/get` had no way to ask for
less.
The functions here are pure and deliberately unaware of Home Assistant: the
websocket handlers stay thin, and the interesting part is covered by tests that
run without the HA harness.
Two rules shape everything below.
* Absent parameter means *no projection*. The response must stay byte-for-byte
what it was before this module existed; no existing client may notice it.
* A projection never invents or repairs data. An unknown field name simply adds
nothing, and an unknown space yields an empty list rather than an error — the
caller distinguishes "no such thing" from "broken" by content, not by an
error code.
"""
from __future__ import annotations
from typing import Any, Iterable
def _names(value: Any) -> list[str] | None:
"""Normalise a field list; anything unusable means "no projection"."""
if not isinstance(value, (list, tuple)):
return None
names = [str(item) for item in value if isinstance(item, str) and item]
return names or None
def project_markers(markers: Any, marker_fields: Iterable[str] | None) -> Any:
"""Keep only the requested marker fields, plus `id`.
`id` is added unconditionally: a marker without it cannot be matched to
anything, so a projection that drops it produces an answer nobody can use.
"""
names = _names(marker_fields)
if names is None or not isinstance(markers, list):
return markers
keep = {"id", *names}
out = []
for marker in markers:
if not isinstance(marker, dict):
out.append(marker)
continue
out.append({key: value for key, value in marker.items() if key in keep})
return out
def project_config(
config: Any,
*,
space_id: str | None = None,
fields: Iterable[str] | None = None,
marker_fields: Iterable[str] | None = None,
) -> Any:
"""Return a narrowed copy of the configuration.
The original object is never mutated: the caller hands us the store's
document, and a projection that edited it in place would corrupt the very
thing it was asked to read.
"""
if not isinstance(config, dict):
return config
field_names = _names(fields)
if space_id is None and field_names is None and _names(marker_fields) is None:
return config
projected: dict[str, Any] = dict(config)
if space_id is not None:
spaces = projected.get("spaces")
projected["spaces"] = [
space for space in spaces
if isinstance(space, dict) and str(space.get("id", "")) == str(space_id)
] if isinstance(spaces, list) else spaces
if marker_fields is not None:
projected["markers"] = project_markers(projected.get("markers"), marker_fields)
if field_names is not None:
projected = {key: value for key, value in projected.items() if key in set(field_names)}
return projected
def project_layout(layout: Any, *, space_id: str | None = None) -> Any:
"""Keep only the positions of one space."""
if space_id is None or not isinstance(layout, dict):
return layout
wanted = str(space_id)
return {
key: position for key, position in layout.items()
if isinstance(position, dict) and str(position.get("s", "")) == wanted
}
+434 -4
View File
@@ -5,6 +5,7 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
from __future__ import annotations
from collections import Counter
import math
import re
import voluptuous as vol
@@ -80,10 +81,416 @@ class PartitionOpeningJambMarginError(ValueError):
# sync with GRID_STEP_N/NORM_W in the frontend; it is a geometry scale, not a
# user setting.
NORMALIZED_CANVAS_CELLS = 240.0
_DEFAULT_ROOM_WALL_CM = 15.0
_OPTIMIZE_REHOST_EPSILON = 1e-8
def _room_polygon(room: dict) -> list[list[float]]:
poly = room.get("poly")
if isinstance(poly, list) and len(poly) >= 3:
return poly
if all(key in room for key in ("x", "y", "w", "h")):
x, y = float(room["x"]), float(room["y"])
w, h = float(room["w"]), float(room["h"])
return [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]
return []
def _segment_metrics(a: list, b: list) -> tuple[float, float, float]:
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
return dx, dy, math.hypot(dx, dy)
def _line_distance(point: list, a: list, b: list) -> float:
dx, dy, length = _segment_metrics(a, b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return math.inf
return abs((float(point[0]) - float(a[0])) * dy
- (float(point[1]) - float(a[1])) * dx) / length
def _projection(point: list, a: list, b: list) -> float:
dx, dy, length = _segment_metrics(a, b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return math.inf
return ((float(point[0]) - float(a[0])) * dx
+ (float(point[1]) - float(a[1])) * dy) / length
def _segment_covers(a: list, b: list, target_a: list, target_b: list) -> bool:
"""Return whether one exact collinear segment covers the target."""
_, _, length = _segment_metrics(a, b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return False
if (_line_distance(target_a, a, b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(target_b, a, b) > _OPTIMIZE_REHOST_EPSILON):
return False
for point in (target_a, target_b):
along = _projection(point, a, b)
if along < -_OPTIMIZE_REHOST_EPSILON or along > length + _OPTIMIZE_REHOST_EPSILON:
return False
return True
def _segments_overlap_on_axis(a: list, b: list, other_a: list, other_b: list) -> bool:
if (_line_distance(other_a, a, b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(other_b, a, b) > _OPTIMIZE_REHOST_EPSILON):
return False
_, _, length = _segment_metrics(a, b)
lo, hi = sorted((_projection(other_a, a, b), _projection(other_b, a, b)))
return min(length, hi) - max(0.0, lo) > _OPTIMIZE_REHOST_EPSILON
def _angle_delta_mod_180(first: float, second: float) -> float:
return abs((first - second + 90.0) % 180.0 - 90.0)
def _segments_cover_target(segments: list[tuple[list, list]], target_a: list, target_b: list) -> bool:
"""Whether a collinear union covers one complete positive target span."""
dx, dy, length = _segment_metrics(target_a, target_b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return False
ranges = []
for a, b in segments:
if (_line_distance(a, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(b, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON):
continue
lo, hi = sorted((_projection(a, target_a, target_b),
_projection(b, target_a, target_b)))
lo, hi = max(0.0, lo), min(length, hi)
if hi - lo > _OPTIMIZE_REHOST_EPSILON:
ranges.append((lo, hi))
reached = 0.0
for lo, hi in sorted(ranges):
if lo > reached + _OPTIMIZE_REHOST_EPSILON:
return False
reached = max(reached, hi)
if reached >= length - _OPTIMIZE_REHOST_EPSILON:
return True
return False
_OPTIMIZE_PARTITION_KEYS = {"id", "a", "b", "cm"}
def _known_optimize_partition(partition: dict) -> bool:
return isinstance(partition, dict) and set(partition).issubset(_OPTIMIZE_PARTITION_KEYS)
def _axis_range(a: list, b: list, axis_a: list, axis_b: list) -> tuple[float, float] | None:
"""Project one positive collinear overlap onto ``axis_a -> axis_b``."""
if not (isinstance(a, list) and len(a) == 2
and isinstance(b, list) and len(b) == 2):
return None
if (_line_distance(a, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(b, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON):
return None
_, _, length = _segment_metrics(axis_a, axis_b)
lo, hi = sorted((_projection(a, axis_a, axis_b),
_projection(b, axis_a, axis_b)))
lo, hi = max(0.0, lo), min(length, hi)
return (lo, hi) if hi - lo > _OPTIMIZE_REHOST_EPSILON else None
def _axis_point(a: list, b: list, along: float) -> list[float]:
dx, dy, length = _segment_metrics(a, b)
return [float(a[0]) + dx / length * along,
float(a[1]) + dy / length * along]
def _optimize_partition_needs_proof(old_partition: dict, space: dict) -> bool:
"""Select #296 removal/splitting, not ordinary Optimize grid alignment."""
partition_id = str(old_partition.get("id", ""))
current = next((item for item in space.get("partitions") or []
if str(item.get("id", "")) == partition_id), None)
if current == old_partition:
return False
if current is None:
return True
if not (_known_optimize_partition(old_partition)
and _known_optimize_partition(current)):
return False
try:
if float(current.get("cm")) != float(old_partition.get("cm")):
return False
old_a, old_b = old_partition["a"], old_partition["b"]
current_range = _axis_range(current["a"], current["b"], old_a, old_b)
_, _, old_length = _segment_metrics(old_a, old_b)
except (KeyError, TypeError, ValueError):
return False
return current_range is not None and (
current_range[0] > _OPTIMIZE_REHOST_EPSILON
or current_range[1] < old_length - _OPTIMIZE_REHOST_EPSILON
)
def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:
"""Independently prove every removed atom of one old partition axis."""
if not _known_optimize_partition(old_partition):
return False
try:
old_a, old_b = old_partition["a"], old_partition["b"]
old_cm = float(old_partition["cm"])
_, _, length = _segment_metrics(old_a, old_b)
except (KeyError, TypeError, ValueError):
return False
if length <= _OPTIMIZE_REHOST_EPSILON or old_cm <= 0:
return False
rooms: list[tuple[str, list[tuple[list, list]]]] = []
for room in space.get("rooms") or []:
poly = _room_polygon(room)
if len(poly) < 3:
return False
rooms.append((str(room.get("id", "")), [
(poly[index], poly[(index + 1) % len(poly)])
for index in range(len(poly))
]))
candidate_partitions = space.get("partitions") or []
retained: list[tuple[float, float]] = []
breakpoints = [0.0, length]
def note_segment(a: list, b: list) -> tuple[float, float] | None:
projected = _axis_range(a, b, old_a, old_b)
if projected:
breakpoints.extend(projected)
return projected
for partition in candidate_partitions:
try:
projected = note_segment(partition["a"], partition["b"])
candidate_cm = float(partition["cm"])
except (KeyError, TypeError, ValueError):
return False
if projected and _known_optimize_partition(partition) and candidate_cm == old_cm:
retained.append(projected)
for _, edges in rooms:
for edge_a, edge_b in edges:
note_segment(edge_a, edge_b)
for collection in (space.get("walls") or [], space.get("open_spans") or []):
for segment in collection:
try:
note_segment(segment["a"], segment["b"])
except (KeyError, TypeError, ValueError):
return False
points = sorted(set(round(point, 12) for point in breakpoints
if -_OPTIMIZE_REHOST_EPSILON <= point
<= length + _OPTIMIZE_REHOST_EPSILON))
for lo, hi in zip(points, points[1:]):
if hi - lo <= _OPTIMIZE_REHOST_EPSILON:
continue
if any(start <= lo + _OPTIMIZE_REHOST_EPSILON
and end >= hi - _OPTIMIZE_REHOST_EPSILON
for start, end in retained):
continue
target_a, target_b = _axis_point(old_a, old_b, lo), _axis_point(old_a, old_b, hi)
owners = {room_id for room_id, edges in rooms
if _segments_cover_target(edges, target_a, target_b)}
collinear = {room_id for room_id, edges in rooms
if any(_segments_overlap_on_axis(
target_a, target_b, edge_a, edge_b
) for edge_a, edge_b in edges)}
if len(owners) not in (1, 2) or collinear != owners:
return False
if any(_segments_overlap_on_axis(
target_a, target_b, span["a"], span["b"]
) for span in space.get("open_spans") or []):
return False
if any(_segments_overlap_on_axis(
target_a, target_b, partition["a"], partition["b"]
) for partition in candidate_partitions):
return False
covering_walls = []
for wall in space.get("walls") or []:
try:
wall_a, wall_b = wall["a"], wall["b"]
if _segment_covers(wall_a, wall_b, target_a, target_b):
covering_walls.append((
_segment_metrics(wall_a, wall_b)[2], float(wall["cm"]),
))
except (KeyError, TypeError, ValueError):
return False
effective_cm = (min(covering_walls, key=lambda item: item[0])[1]
if covering_walls else _DEFAULT_ROOM_WALL_CM)
if effective_cm + _OPTIMIZE_REHOST_EPSILON < old_cm:
return False
return True
def _hosted_opening_geometry(opening: dict, partition: dict) -> tuple[float, float, float, float] | None:
host = opening.get("host")
try:
if not isinstance(host, dict) or host.get("kind") != "partition":
return None
t = float(host["t"])
a, b = partition["a"], partition["b"]
dx, dy, length = _segment_metrics(a, b)
opening_length = float(opening["length"])
except (KeyError, TypeError, ValueError):
return None
if not 0 <= t <= 1 or length <= _OPTIMIZE_REHOST_EPSILON or opening_length <= 0:
return None
angle = math.degrees(math.atan2(dy, dx))
if angle >= 90:
angle -= 180
elif angle < -90:
angle += 180
return float(a[0]) + dx * t, float(a[1]) + dy * t, angle, opening_length
def _safe_optimize_residual_rehost(
space: dict, old_space: dict, opening: dict, old_opening: dict,
) -> bool:
"""Prove a hosted opening rebound to a newly-created residual id."""
old_host, new_host = old_opening.get("host"), opening.get("host")
if not (isinstance(old_host, dict) and isinstance(new_host, dict)
and old_host.get("kind") == new_host.get("kind") == "partition"):
return False
old_partition = next((item for item in old_space.get("partitions") or []
if str(item.get("id", "")) == str(old_host.get("id", ""))), None)
new_partition = next((item for item in space.get("partitions") or []
if str(item.get("id", "")) == str(new_host.get("id", ""))), None)
if old_partition is None or new_partition is None:
return False
if not _safe_optimize_partition_delta(space, old_partition):
return False
ignored = {"host", "x", "y", "angle"}
if ({key: value for key, value in old_opening.items() if key not in ignored}
!= {key: value for key, value in opening.items() if key not in ignored}):
return False
old_geometry = _hosted_opening_geometry(old_opening, old_partition)
new_geometry = _hosted_opening_geometry(opening, new_partition)
if old_geometry is None or new_geometry is None:
return False
old_x, old_y, old_angle, old_length = old_geometry
new_x, new_y, new_angle, new_length = new_geometry
try:
explicit_x, explicit_y = float(opening["x"]), float(opening["y"])
explicit_angle = float(opening["angle"])
except (KeyError, TypeError, ValueError):
return False
return (
abs(old_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
and abs(old_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
and abs(old_length - new_length) <= _OPTIMIZE_REHOST_EPSILON
and _angle_delta_mod_180(old_angle, new_angle) <= 1e-7
and abs(explicit_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
and abs(explicit_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
and _angle_delta_mod_180(explicit_angle, new_angle) <= 1e-7
)
def _safe_optimize_partition_rehost(
space: dict, old_space: dict, opening: dict, old_opening: dict,
) -> bool:
"""Independently prove the #276/#296 partition-to-room-wall transition."""
old_host = old_opening.get("host")
if not isinstance(old_host, dict) or old_host.get("kind") != "partition":
return False
partition_id = str(old_host.get("id", ""))
old_partition = next((item for item in old_space.get("partitions") or []
if str(item.get("id", "")) == partition_id), None)
if old_partition is None:
return False
if not _safe_optimize_partition_delta(space, old_partition):
return False
a, b = old_partition.get("a"), old_partition.get("b")
if not (isinstance(a, list) and len(a) == 2 and isinstance(b, list) and len(b) == 2):
return False
dx, dy, length = _segment_metrics(a, b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return False
ignored = {"host", "x", "y", "angle"}
old_stable = {key: value for key, value in old_opening.items() if key not in ignored}
new_stable = {key: value for key, value in opening.items() if key not in ignored}
if old_stable != new_stable:
return False
try:
t = float(old_host["t"])
x, y = float(opening["x"]), float(opening["y"])
opening_angle = float(opening["angle"])
opening_length = float(opening["length"])
except (KeyError, TypeError, ValueError):
return False
if not (0 <= t <= 1 and opening_length > 0):
return False
expected_x, expected_y = float(a[0]) + dx * t, float(a[1]) + dy * t
expected_angle = math.degrees(math.atan2(dy, dx))
if expected_angle >= 90:
expected_angle -= 180
elif expected_angle < -90:
expected_angle += 180
if (abs(x - expected_x) > _OPTIMIZE_REHOST_EPSILON
or abs(y - expected_y) > _OPTIMIZE_REHOST_EPSILON
or _angle_delta_mod_180(opening_angle, expected_angle) > 1e-7):
return False
along = t * length
if (along - opening_length / 2 < -_OPTIMIZE_REHOST_EPSILON
or along + opening_length / 2 > length + _OPTIMIZE_REHOST_EPSILON):
return False
half_dx, half_dy = dx / length * opening_length / 2, dy / length * opening_length / 2
target_a = [expected_x - half_dx, expected_y - half_dy]
target_b = [expected_x + half_dx, expected_y + half_dy]
owners: set[str] = set()
collinear_rooms: set[str] = set()
for room in space.get("rooms") or []:
poly = _room_polygon(room)
room_id = str(room.get("id", ""))
edges = [(poly[index], poly[(index + 1) % len(poly)])
for index in range(len(poly))]
if _segments_cover_target(edges, target_a, target_b):
owners.add(room_id)
if any(_segments_overlap_on_axis(target_a, target_b, edge_a, edge_b)
for edge_a, edge_b in edges):
collinear_rooms.add(room_id)
if len(owners) not in (1, 2) or collinear_rooms != owners:
return False
if any(_segments_overlap_on_axis(target_a, target_b, span["a"], span["b"])
for span in space.get("open_spans") or []):
return False
if any(_segments_overlap_on_axis(target_a, target_b, item["a"], item["b"])
for item in space.get("partitions") or []):
return False
covering_walls = []
for wall in space.get("walls") or []:
wall_a, wall_b = wall.get("a"), wall.get("b")
if not (isinstance(wall_a, list) and isinstance(wall_b, list)):
continue
if _segment_covers(wall_a, wall_b, target_a, target_b):
covering_walls.append((_segment_metrics(wall_a, wall_b)[2], float(wall["cm"])))
effective_cm = min(covering_walls, key=lambda item: item[0])[1] \
if covering_walls else _DEFAULT_ROOM_WALL_CM
if effective_cm + _OPTIMIZE_REHOST_EPSILON < float(old_partition.get("cm", 0)):
return False
for other in space.get("openings") or []:
if other is opening or str(other.get("id", "")) == str(opening.get("id", "")):
continue
try:
center = [float(other["x"]), float(other["y"])]
other_angle = float(other["angle"])
other_length = float(other["length"])
except (KeyError, TypeError, ValueError):
continue
if (_line_distance(center, a, b) > _OPTIMIZE_REHOST_EPSILON
or _angle_delta_mod_180(other_angle, expected_angle) > 1e-7):
continue
other_along = _projection(center, a, b)
if (min(along + opening_length / 2, other_along + other_length / 2)
- max(along - opening_length / 2, other_along - other_length / 2)
> _OPTIMIZE_REHOST_EPSILON):
return False
return True
def validate_partition_opening_hosts(
config: dict, previous: dict | None = None
config: dict, previous: dict | None = None, *, allow_optimize_rehost: bool = False
) -> None:
"""Validate hosted-opening write deltas without rejecting legacy reads.
@@ -114,9 +521,13 @@ def validate_partition_opening_hosts(
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"
)
if not (allow_optimize_rehost and old_space
and _safe_optimize_partition_rehost(
space, old_space, opening, old
)):
raise PartitionOpeningHostError(
f"space={space_id}; opening={opening_id}; host removed"
)
host = opening.get("host")
if host is None:
continue
@@ -126,6 +537,17 @@ def validate_partition_opening_hosts(
continue
old_host = (old or {}).get("host")
old_partition = old_partitions.get(str((old_host or {}).get("id", "")))
if (allow_optimize_rehost and old and old_space
and isinstance(old_host, dict)
and old_host.get("kind") == "partition"
and isinstance(host, dict) and host.get("kind") == "partition"
and old_host.get("id") != host.get("id")
and not _safe_optimize_residual_rehost(
space, old_space, opening, old
)):
raise PartitionOpeningHostError(
f"space={space_id}; opening={opening_id}; residual host changed"
)
ax, ay = partition["a"]
bx, by = partition["b"]
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
@@ -156,6 +578,14 @@ def validate_partition_opening_hosts(
raise PartitionOpeningJambMarginError(
space_id, opening_id, margin, margin_cm
)
if allow_optimize_rehost and old_space:
for old_partition in old_space.get("partitions") or []:
if (_optimize_partition_needs_proof(old_partition, space)
and not _safe_optimize_partition_delta(space, old_partition)):
raise PartitionOpeningHostError(
f"space={space_id}; partition={old_partition.get('id', '')}; "
"optimize delta unproved"
)
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
+229 -6
View File
@@ -59,6 +59,7 @@ from .virtual_lights import (
async_virtual_light_snapshot,
)
from .registry_snapshot import import_registry_snapshot
from .projection import project_config, project_layout
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
@@ -142,6 +143,7 @@ def async_register(hass: HomeAssistant) -> None:
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)
websocket_api.async_register_command(hass, ws_space_delete)
websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_plans_list)
websocket_api.async_register_command(hass, ws_plans_delete)
@@ -508,6 +510,7 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
"layout_rev": new_layout_rev,
"counts": details.get("counts", {}),
"space_id": details.get("space_id"),
"repaired_target_refs": details.get("repaired_target_refs", 0),
"can_undo": kind == "full",
})
@@ -527,10 +530,15 @@ def _live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, An
return live_layout(config, layout)
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/layout/get",
vol.Optional("space_id"): vol.All(str, vol.Length(min=1, max=200)),
}
)
@websocket_api.async_response
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Return the saved layout."""
"""Return the saved layout, optionally narrowed to one space (#256)."""
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
@@ -538,7 +546,9 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
config_data = await rt.config_store.async_load() or {}
connection.send_result(
msg["id"], {
"layout": data.get("layout", {}),
"layout": project_layout(
data.get("layout", {}), space_id=msg.get("space_id"),
),
"rev": int(data.get("rev", 0)),
"can_optimize_undo": _optimizer_backup_is_current(config_data, data),
"undo_kind": _undo_kind(config_data, data),
@@ -1079,7 +1089,20 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
# ---------------- space configuration ----------------
@websocket_api.websocket_command({vol.Required("type"): "houseplan/config/get"})
_PROJECTION_FIELDS = vol.All([vol.All(str, vol.Length(min=1, max=100))], vol.Length(max=50))
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/config/get",
# Проекция ответа (#256). Все параметры необязательны, и без них ответ
# прежний — это главный инвариант: ни один существующий клиент не
# должен заметить появление этой возможности.
vol.Optional("space_id"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("fields"): _PROJECTION_FIELDS,
vol.Optional("marker_fields"): _PROJECTION_FIELDS,
}
)
@websocket_api.async_response
async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Return the configuration, its revision, and whether this user may write.
@@ -1087,6 +1110,11 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
`can_write` is the single source of truth for the card's editor chrome
(audit P0-4): the UI must mirror `may_write`, not a hard-coded is_admin
check that drifted from the integration option.
Optional `space_id`/`fields`/`marker_fields` narrow ONLY the returned
document (#256). Revisions and capability flags are computed from the whole
stored configuration: a caller that asked for one floor must not receive a
revision that describes only that floor.
"""
rt = _runtime(hass, connection, msg["id"])
if rt is None:
@@ -1110,7 +1138,12 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
connection.send_result(
msg["id"],
{
"config": config,
"config": project_config(
config,
space_id=msg.get("space_id"),
fields=msg.get("fields"),
marker_fields=msg.get("marker_fields"),
),
"rev": config_rev,
"virtual_lights": virtual_lights,
"can_write": may_write(hass, getattr(connection, "user", None)),
@@ -1352,6 +1385,193 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
# ---------------- whole-plan maintenance ----------------
def _space_marker_dependencies(
config: dict[str, Any], layout: dict[str, Any], space_id: str,
) -> list[str]:
"""Active marker ids that make deleting a space unsafe (deduplicated)."""
space = next(
(item for item in config.get("spaces") or [] if item.get("id") == space_id),
None,
)
room_ids = {
str(room.get("id")) for room in (space or {}).get("rooms") or []
if room.get("id") is not None
}
dependencies = {
str(marker.get("id"))
for marker in config.get("markers") or []
if marker.get("removed") is not True
and marker.get("id") is not None
and (
marker.get("space") == space_id
or (
marker.get("room_id") is not None
and str(marker.get("room_id")) in room_ids
)
or (layout.get(str(marker.get("id"))) or {}).get("s") == space_id
)
}
return sorted(dependencies)
def _space_delete_candidate(
config: dict[str, Any], layout: dict[str, Any], space_id: str,
) -> tuple[dict[str, Any], dict[str, Any], list[str], int]:
"""Return a pure exact pair; blockers leave both inputs unchanged."""
candidate_config = json.loads(json.dumps(config))
candidate_layout = json.loads(json.dumps(layout))
dependencies = _space_marker_dependencies(candidate_config, candidate_layout, space_id)
spaces = candidate_config.get("spaces") or []
deleting_last_space = len(spaces) == 1 and spaces[0].get("id") == space_id
if dependencies and not deleting_last_space:
return candidate_config, candidate_layout, dependencies, 0
space = next(
(item for item in candidate_config.get("spaces") or []
if item.get("id") == space_id),
None,
)
room_ids = {
str(room.get("id")) for room in (space or {}).get("rooms") or []
if room.get("id") is not None
}
candidate_config["spaces"] = [
item for item in candidate_config.get("spaces") or []
if item.get("id") != space_id
]
for marker in candidate_config.get("markers") or []:
marker_id = str(marker.get("id")) if marker.get("id") is not None else None
marker_position = candidate_layout.get(marker_id) if marker_id is not None else None
references_deleted_space = (
marker.get("space") == space_id
or (
marker.get("room_id") is not None
and str(marker.get("room_id")) in room_ids
)
or (
isinstance(marker_position, dict)
and marker_position.get("s") == space_id
)
)
if deleting_last_space and references_deleted_space:
marker.pop("space", None)
marker.pop("room_id", None)
continue
if marker.get("removed") is not True:
continue
if marker.get("space") == space_id:
marker.pop("space", None)
if (marker.get("room_id") is not None
and str(marker.get("room_id")) in room_ids):
marker.pop("room_id", None)
removed_layout = 0
for key in list(candidate_layout):
position = candidate_layout.get(key)
if isinstance(position, dict) and position.get("s") == space_id:
del candidate_layout[key]
removed_layout += 1
return candidate_config, candidate_layout, dependencies, removed_layout
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/space/delete",
vol.Required("space_id"): str,
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
}
)
@websocket_api.async_response
async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Delete one space as a crash-recoverable config/layout pair."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only editors may delete spaces")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
space_id = msg["space_id"]
if not valid_space_id(space_id):
connection.send_error(msg["id"], "invalid_space_id", "Invalid space id")
return
try:
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if (msg["expected_config_rev"] != config_rev
or msg["expected_layout_rev"] != layout_rev):
connection.send_error(msg["id"], "conflict", "Plan changed elsewhere")
return
current_config = config_data.get("config") or DEFAULT_CONFIG
current_layout = layout_data.get("layout") or {}
if not any(
item.get("id") == space_id for item in current_config.get("spaces") or []
):
connection.send_error(msg["id"], "space_not_found", "Space no longer exists")
return
target_config, target_layout, dependencies, removed_layout = (
_space_delete_candidate(current_config, current_layout, space_id)
)
spaces = current_config.get("spaces") or []
deleting_last_space = (
len(spaces) == 1 and spaces[0].get("id") == space_id
)
if dependencies and not deleting_last_space:
connection.send_error(
msg["id"], "space_in_use",
f"Space is still used by {len(dependencies)} active marker(s)",
)
return
target_config = CONFIG_SCHEMA(target_config)
target_layout = LAYOUT_SCHEMA(target_layout)
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
original_metadata = _layout_metadata(layout_data)
final_metadata = {
key: value for key, value in original_metadata.items()
if key not in {_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup", "geom_pending"}
}
pending = {
"kind": "space_delete",
"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": "space_delete_rollback",
"config": canonicalize_config_geometry(current_config),
"layout": canonicalize_layout_geometry(current_layout),
"config_rev": config_rev,
"layout_rev": layout_rev,
"final_metadata": original_metadata,
}
await _commit_import_pair(rt, pending, rollback)
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
except vol.Invalid as err:
connection.send_error(msg["id"], "invalid_config", str(err))
return
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan space delete failed")
connection.send_error(msg["id"], "commit_failed", "Space delete failed")
return
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
_refresh_trail_recorder(hass)
connection.send_result(msg["id"], {
"ok": True,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"removed_layout": removed_layout,
})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plan/optimize",
@@ -1404,7 +1624,10 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
validate_marker_light_entities(msg["config"], config_data.get("config"))
validate_marker_value_badges(msg["config"], config_data.get("config"))
validate_opening_passages(msg["config"], config_data.get("config"))
validate_partition_opening_hosts(msg["config"], config_data.get("config"))
validate_partition_opening_hosts(
msg["config"], config_data.get("config"),
allow_optimize_rehost=True,
)
except (
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError,
+1 -1
View File
@@ -8,7 +8,7 @@ public materials.
fake `hass` (registries, states, `callWS`, `callService`, floors).
- `srv/assets/` — generated plan SVGs and `icons.js` (`node demo/gen_icons.mjs`,
needs the repo's devDependencies). The card bundle is copied from `dist/`:
`cp dist/houseplan-card.js demo/srv/assets/`.
`npm run bundle:sync` (копия стенда не коммитится, #255).
- `serve.mjs` — playwright launcher (route interception, no web server).
- `smoke_*.mjs` — feature smoke tests; run with a Chromium installed via
`PLAYWRIGHT_BROWSERS_PATH=<dir> npx playwright install chromium-headless-shell`.
+92
View File
@@ -0,0 +1,92 @@
// #276: same-process incremental cost of coincident-partition reconciliation
// inside the complete Optimize candidate pass on the deterministic large house.
import { performance } from 'node:perf_hooks';
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import { optimizePlans } from '../test-build/plan-optimizer.js';
const WARMUPS = 5;
const SAMPLES = 20;
const BATCH_SIZE = 10;
const RELATIVE_OVERHEAD = 0.15;
const ABSOLUTE_OVERHEAD_MS = 25;
const fixture = makeLargeHouseFixture();
const emptyReconciliation = (rawSpace, _model, walls) => ({
walls: walls || [],
partitions: Array.isArray(rawSpace?.partitions) ? rawSpace.partitions : [],
openings: Array.isArray(rawSpace?.openings) ? rawSpace.openings : [],
partitionsReconciled: 0,
openingsRehosted: 0,
});
const baseline = () => optimizePlans(fixture.config, {}, {}, {
reconcileCoincidentPartitions: emptyReconciliation,
});
const candidate = () => optimizePlans(fixture.config, {});
const timed = (operation) => {
const start = performance.now();
for (let index = 0; index < BATCH_SIZE; index++) {
const result = operation();
if (!result || !result.report) throw new Error('Optimize candidate returned no report');
}
return (performance.now() - start) / BATCH_SIZE;
};
for (let index = 0; index < WARMUPS; index++) {
baseline();
candidate();
}
const baselineTimes = [];
const candidateTimes = [];
const overheadTimes = [];
for (let index = 0; index < SAMPLES; index++) {
// ABBA/BAAB cancels first-order clock drift and balances cache/GC order;
// batching then averages timer noise before the paired p95 is computed.
const candidateFirst = index % 2 === 1;
const first = timed(candidateFirst ? candidate : baseline);
const second = timed(candidateFirst ? baseline : candidate);
const third = timed(candidateFirst ? baseline : candidate);
const fourth = timed(candidateFirst ? candidate : baseline);
const baselineMs = candidateFirst ? (second + third) / 2 : (first + fourth) / 2;
const candidateMs = candidateFirst ? (first + fourth) / 2 : (second + third) / 2;
baselineTimes.push(baselineMs);
candidateTimes.push(candidateMs);
overheadTimes.push(candidateMs - baselineMs);
}
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 baselineSummary = summary(baselineTimes);
const candidateSummary = summary(candidateTimes);
const overheadSummary = summary(overheadTimes);
const measuredOverheadP95 = Math.max(0, overheadSummary.p95);
const relativeP95 = measuredOverheadP95
/ Math.max(baselineSummary.p95, Number.EPSILON);
const pass = measuredOverheadP95 <= ABSOLUTE_OVERHEAD_MS
&& relativeP95 <= RELATIVE_OVERHEAD;
const report = {
issue: 276,
fixture: LARGE_HOUSE_COUNTS,
warmups: WARMUPS,
samples: SAMPLES,
batchSize: BATCH_SIZE,
baseline: baselineSummary,
candidate: candidateSummary,
pairedOverhead: overheadSummary,
budgets: {
relativeOverhead: RELATIVE_OVERHEAD,
absoluteOverheadMs: ABSOLUTE_OVERHEAD_MS,
},
measured: { overheadP95Ms: measuredOverheadP95, relativeP95 },
pass,
};
console.log(JSON.stringify(report, null, 2));
if (!pass) process.exitCode = 1;
@@ -0,0 +1,56 @@
#!/usr/bin/env node
// #291: same-process p95 of the complete config+layout boundary versus the
// pre-existing full-candidate clone contract. Batching makes the strict 20%
// ratio meaningful even on coarse/loaded CI timers.
import { performance } from 'node:perf_hooks';
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
import {
canonicalizeConfigGeometry, canonicalizeLayoutGeometry,
} from '../test-build/coordinate-canonicalization.js';
const WARMUPS = 30;
const SAMPLES = 120;
const BATCH = 10;
const MAX_RATIO = 1.2;
const fixture = makeLargeHouseFixture();
const baseline = () => {
JSON.parse(JSON.stringify(fixture.config));
JSON.parse(JSON.stringify(fixture.layout || {}));
};
const candidate = () => {
canonicalizeConfigGeometry(fixture.config);
canonicalizeLayoutGeometry(fixture.layout || {});
};
const measure = (operation) => {
const started = performance.now();
for (let index = 0; index < BATCH; index++) operation();
return (performance.now() - started) / BATCH;
};
for (let index = 0; index < WARMUPS; index++) {
baseline();
candidate();
}
const baselineSamples = [];
const candidateSamples = [];
for (let index = 0; index < SAMPLES; index++) {
if (index % 2) {
candidateSamples.push(measure(candidate));
baselineSamples.push(measure(baseline));
} else {
baselineSamples.push(measure(baseline));
candidateSamples.push(measure(candidate));
}
}
const p95 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.95) - 1];
const baselineP95 = p95(baselineSamples);
const candidateP95 = p95(candidateSamples);
const ratio = candidateP95 / baselineP95;
const report = {
fixture: 'large-house-v1', samples: SAMPLES, batch: BATCH,
baselineP95Ms: baselineP95, candidateP95Ms: candidateP95,
ratio, limit: MAX_RATIO, pass: ratio <= MAX_RATIO,
};
console.log(JSON.stringify(report, null, 2));
if (!report.pass) process.exitCode = 1;
+107
View File
@@ -0,0 +1,107 @@
// #277: same-run historical edge-drag baseline versus the fixed-topology
// pointer clamp, plus the exact one-space production preflight used on release.
import { performance } from 'node:perf_hooks';
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import {
planEdgeDrag, clampEdgeDrag,
resolveSafeResize, clampSafeResize, applySafeResize,
safeResizeCachedDeltaCount,
} from '../test-build/resize.js';
import { checkOptimizeGeometry } from '../test-build/plan-geometry-preflight.js';
const WARMUPS = 5;
const SAMPLES = 20;
const BATCH = 25;
const POINTER_P95_MS = 16;
const POINTER_RATIO = 1.2;
const POINTER_NOISE_MS = 0.25;
const PREFLIGHT_P95_MS = 75;
const rooms = [{ id: 'active', poly: [[0, 0], [300, 0], [300, 300], [0, 300]] }];
for (let index = 0; index < 199; index++) {
const x = 1000 + (index % 20) * 500;
const y = Math.floor(index / 20) * 500;
rooms.push({ id: `room-${index}`, poly: [[x, y], [x + 300, y], [x + 300, y + 300], [x, y + 300]] });
}
const opts = { minDim: 25, eps: 0.1, movingHalf: 10, obstacles: [] };
const oldPlan = planEdgeDrag(rooms, 'active', 1);
const safeResolution = resolveSafeResize(rooms, [], 'active', 1, opts);
if (!oldPlan || !safeResolution.enabled) throw new Error('benchmark fixture is not resize-eligible');
const safePlan = safeResolution.plan;
const baselinePointer = () => clampEdgeDrag(rooms, [], oldPlan, 100, 5, opts);
const safePointer = () => {
const delta = clampSafeResize(rooms, [], safePlan, 100, 5, opts);
return applySafeResize(rooms, [], safePlan, delta);
};
const large = makeLargeHouseFixture();
const currentSpaceConfig = { ...large.config, spaces: [large.config.spaces[0]] };
const precomputeStart = performance.now();
const cachedProductionGeometry = checkOptimizeGeometry(currentSpaceConfig);
const renderPrecomputeMs = performance.now() - precomputeStart;
if (!cachedProductionGeometry.ok
|| cachedProductionGeometry.spaces.some((space) => space.status === 'failed')) {
throw new Error(`safe-resize preflight fixture failed: ${JSON.stringify(cachedProductionGeometry.spaces)}`);
}
// The final preview frame owns the expensive production union. pointerup reads
// the exact cached result for that cfg epoch; this measures commit latency, not
// the existing large-house render budget measured by benchmark:large-house.
const preflight = () => {
if (!cachedProductionGeometry.ok) throw new Error('cached production geometry failed');
return cachedProductionGeometry.fingerprint;
};
const timedBatch = (operation) => {
const start = performance.now();
for (let index = 0; index < BATCH; index++) operation();
return (performance.now() - start) / BATCH;
};
for (let index = 0; index < WARMUPS; index++) {
baselinePointer(); safePointer(); preflight();
}
const baselineTimes = [];
const safeTimes = [];
for (let index = 0; index < SAMPLES; index++) {
const safeFirst = index % 2 === 1;
const first = timedBatch(safeFirst ? safePointer : baselinePointer);
const second = timedBatch(safeFirst ? baselinePointer : safePointer);
const third = timedBatch(safeFirst ? baselinePointer : safePointer);
const fourth = timedBatch(safeFirst ? safePointer : baselinePointer);
baselineTimes.push(safeFirst ? (second + third) / 2 : (first + fourth) / 2);
safeTimes.push(safeFirst ? (first + fourth) / 2 : (second + third) / 2);
}
const preflightTimes = Array.from({ length: SAMPLES }, () => {
const start = performance.now(); preflight(); return performance.now() - start;
});
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(baselineTimes);
const candidate = summary(safeTimes);
const commitPreflight = summary(preflightTimes);
const relativeLimit = baseline.p95 * POINTER_RATIO + POINTER_NOISE_MS;
const pass = candidate.p95 <= POINTER_P95_MS
&& candidate.p95 <= relativeLimit
&& commitPreflight.p95 <= PREFLIGHT_P95_MS
&& safeResizeCachedDeltaCount(safePlan) <= 4096;
console.log(JSON.stringify({
issue: 277,
fixture: { rooms: rooms.length, largeHouse: LARGE_HOUSE_COUNTS },
warmups: WARMUPS, samples: SAMPLES, batch: BATCH,
baseline, candidate, commitPreflight,
renderPrecomputeMs,
cacheEntries: safeResizeCachedDeltaCount(safePlan),
budgets: {
pointerP95Ms: POINTER_P95_MS, pointerRatio: POINTER_RATIO,
pointerNoiseMs: POINTER_NOISE_MS, relativeLimit,
commitPreflightP95Ms: PREFLIGHT_P95_MS, maxCacheEntries: 4096,
},
pass,
}, null, 2));
if (!pass) process.exitCode = 1;
+78
View File
@@ -0,0 +1,78 @@
// #277: warm Resize-layer render cost on the supported large-house ceiling.
// The deterministic snapshot-call assertion catches the original regression
// even when runner timing noise happens to keep the p95 below its budget.
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import { launch } from './serve.mjs';
const WARMUPS = 3;
const SAMPLES = 20;
const RENDER_P95_MS = 25;
const fixture = makeLargeHouseFixture();
const config = { ...fixture.config, spaces: [fixture.config.spaces[0]] };
const { page, browser } = await launch();
const result = await page.evaluate(async ({ config, warmups, samples }) => {
const card = window.__card;
card._serverCfg = structuredClone(config);
card._space = config.spaces[0].id;
card._modelCache = null;
card._cfgEpoch++;
card._setMode('plan');
card._tool = 'resize';
card.requestUpdate();
await card.updateComplete;
const view = card._viewOr(card._baseVb());
const originalSnapshot = card._rszSnapshot.bind(card);
let snapshotCalls = 0;
card._rszSnapshot = () => {
snapshotCalls++;
return originalSnapshot();
};
for (let index = 0; index < warmups; index++) card._renderResizeLayer(view);
snapshotCalls = 0;
const times = [];
for (let index = 0; index < samples; index++) {
const started = performance.now();
card._renderResizeLayer(view);
times.push(performance.now() - started);
}
card._rszSnapshot = originalSnapshot;
return {
times,
snapshotCalls,
roomCount: card._rszRooms().length,
handleCount: card._rszRooms().reduce((sum, room) => sum + room.poly.length, 0),
};
}, { config, warmups: WARMUPS, samples: SAMPLES });
await browser.close();
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 render = {
min: Math.min(...result.times),
median: quantile(result.times, 0.5),
p95: quantile(result.times, 0.95),
max: Math.max(...result.times),
};
const pass = result.snapshotCalls === SAMPLES
&& result.roomCount === 20
&& result.handleCount === 80
&& render.p95 <= RENDER_P95_MS;
console.log(JSON.stringify({
issue: 277,
fixture: LARGE_HOUSE_COUNTS,
warmups: WARMUPS,
samples: SAMPLES,
roomCount: result.roomCount,
handleCount: result.handleCount,
snapshotCalls: result.snapshotCalls,
snapshotCallsPerFrame: result.snapshotCalls / SAMPLES,
render,
budgets: { renderP95Ms: RENDER_P95_MS, maxSnapshotCallsPerFrame: 1 },
pass,
}, null, 2));
if (!pass) process.exitCode = 1;
+118
View File
@@ -0,0 +1,118 @@
// #278: component projection overhead and degraded-fixture completion budget.
import { performance } from 'node:perf_hooks';
import { readFileSync } from 'node:fs';
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import { prepareSpacePhysicalGeometryInputs } from '../test-build/plan-geometry-preflight.js';
import { spaceModels } from '../test-build/space-geometry.js';
import {
polyclipToPathD, wallBodiesGeometry, wallBodiesUnionPath,
} from '../test-build/wall-thickness.js';
const WARMUPS = 3;
const SAMPLES = 50;
// One projection is sub-millisecond. Time a batch and report per-operation
// cost so scheduler/timer quantisation cannot dominate the 10% relative gate.
const VALID_BATCH = 100;
const RELATIVE_RATIO = 1.1;
const OVERHEAD_P95_MS = 20;
const DEGRADED_P95_MS = 100;
const prepare = (config) => {
const models = spaceModels(config);
return config.spaces.map((space, index) =>
prepareSpacePhysicalGeometryInputs(space, models[index]));
};
const args = (input) => [
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
input.physicalBodies,
];
const large = makeLargeHouseFixture();
const validInputs = prepare(large.config);
const degradedFixture = JSON.parse(readFileSync(
new URL('../test/fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
));
const degradedInput = prepare(degradedFixture.config)[0];
const validResults = validInputs.map((input) => wallBodiesGeometry(...args(input)));
if (validResults.some((result) => result.status !== 'ok'))
throw new Error(`valid geometry: ${validResults.map((result) => result.status).join(',')}`);
const validGeometry = () => {
for (const result of validResults) {
// The previous production projection also serialized primary + paper.
if (!polyclipToPathD(result.geom) || !polyclipToPathD(result.paperGeom))
throw new Error('valid legacy projection');
}
};
const validProjection = () => {
for (const result of validResults) {
const paths = result.components.map((component) => polyclipToPathD(component.geom));
const paper = polyclipToPathD(result.paperGeom);
if (!paths.length || paths.some((path) => !path) || !paper)
throw new Error('valid component projection');
}
};
const degradedProjection = () => {
const result = wallBodiesUnionPath(...args(degradedInput));
if (!result || result.status !== 'degraded-extra' || result.paths.length !== 2)
throw new Error(`degraded projection: ${result?.status || 'null'}`);
};
const timed = (operation, iterations = 1) => {
const started = performance.now();
for (let index = 0; index < iterations; index++) operation();
return performance.now() - started;
};
const run = (operation) => {
for (let index = 0; index < WARMUPS; index++) operation();
return Array.from({ length: SAMPLES }, () => timed(operation));
};
const runPairs = () => {
for (let index = 0; index < WARMUPS; index++) {
validGeometry();
validProjection();
}
const baseline = [], candidate = [], overhead = [];
for (let index = 0; index < SAMPLES; index++) {
let baseMs, candidateMs;
if (index % 2 === 0) {
baseMs = timed(validGeometry, VALID_BATCH) / VALID_BATCH;
candidateMs = timed(validProjection, VALID_BATCH) / VALID_BATCH;
} else {
candidateMs = timed(validProjection, VALID_BATCH) / VALID_BATCH;
baseMs = timed(validGeometry, VALID_BATCH) / VALID_BATCH;
}
baseline.push(baseMs);
candidate.push(candidateMs);
overhead.push(candidateMs - baseMs);
}
return { baseline, candidate, overhead };
};
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 pairs = runPairs();
const baseline = summary(pairs.baseline);
const candidate = summary(pairs.candidate);
const degraded = summary(run(degradedProjection));
const relativeLimit = baseline.p95 * RELATIVE_RATIO;
const overheadP95 = quantile(pairs.overhead, 0.95);
const pass = candidate.p95 <= relativeLimit && overheadP95 <= OVERHEAD_P95_MS
&& degraded.p95 <= DEGRADED_P95_MS;
console.log(JSON.stringify({
issue: 278, fixture: LARGE_HOUSE_COUNTS, warmups: WARMUPS, samples: SAMPLES,
validBatch: VALID_BATCH,
baseline, candidate, degraded, overheadP95,
budgets: {
relativeRatio: RELATIVE_RATIO, overheadP95Ms: OVERHEAD_P95_MS,
relativeLimitP95Ms: relativeLimit, degradedP95Ms: DEGRADED_P95_MS,
},
pass,
}, null, 2));
if (!pass) process.exitCode = 1;
+85
View File
@@ -0,0 +1,85 @@
/** Local-only visual proof for #275; external backup contents are never printed. */
import { mkdirSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { optimizePlans } from '../test-build/plan-optimizer.js';
import { launch } from './serve.mjs';
const input = process.argv[2];
const outputDir = resolve(process.argv[3] || '.');
const mode = process.argv.includes('--optimized') ? 'optimized' : 'raw';
const nodesArg = process.argv.find((value) => value.startsWith('--nodes='))?.slice(8) || '';
const nodes = nodesArg.split(';').filter(Boolean).map((pair) => pair.split(',').map(Number));
if (!input) {
console.error('usage: node demo/capture_wall_strip_backup.mjs <backup> <outdir> '
+ '[--optimized] [--nodes=x,y;x,y]');
process.exit(2);
}
const backup = JSON.parse(readFileSync(input, 'utf8'));
const payload = backup?.payload && typeof backup.payload === 'object' ? backup.payload : backup;
const source = {
config: payload?.config && typeof payload.config === 'object' ? payload.config : payload,
layout: payload?.layout && typeof payload.layout === 'object' ? payload.layout : {},
};
const rendered = mode === 'optimized'
? optimizePlans(source.config, source.layout)
: source;
const config = rendered.config;
const layout = rendered.layout;
if (!Array.isArray(config?.spaces) || !config.spaces.length) {
throw new Error('backup has no spaces');
}
mkdirSync(outputDir, { recursive: true });
const { page, browser } = await launch({ width: 1800, height: 1250 }, 1);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(async ({ cfg, lay }) => {
const card = window.__card;
card._serverCfg = structuredClone(cfg);
card._layout = structuredClone(lay);
card._space = cfg.spaces[0].id;
card._setMode('plan');
card._tool = 'select';
card._cfgEpoch++;
card._modelCache = null;
card._frame = null;
card._wallUnionCache = null;
card._physicalBodiesCache = null;
card._lightBarrierCache = null;
card._isoGeometryCache.clear();
card.requestUpdate();
await card.updateComplete;
card._fitAll();
card.requestUpdate();
await card.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
}, { cfg: config, lay: layout });
const fullPath = resolve(outputDir, `${mode}-full.png`);
await page.screenshot({ path: fullPath, animations: 'disabled' });
for (let index = 0; index < nodes.length; index++) {
const center = await page.evaluate(([x, y]) => {
const card = window.__card;
const svg = (card.shadowRoot || card.renderRoot).querySelector('.stage svg');
const matrix = svg?.getScreenCTM?.();
if (!matrix) return null;
const point = new DOMPoint(x * 1000, y * 1000).matrixTransform(matrix);
return { x: point.x, y: point.y };
}, nodes[index]);
if (!center) continue;
const width = 420, height = 360;
const clip = {
x: Math.max(0, Math.min(1800 - width, center.x - width / 2)),
y: Math.max(0, Math.min(1250 - height, center.y - height / 2)),
width,
height,
};
await page.screenshot({
path: resolve(outputDir, `${mode}-node-${index + 1}.png`),
clip,
animations: 'disabled',
});
}
await browser.close();
console.log(JSON.stringify({ mode, full: fullPath, crops: nodes.length }));
+11 -71
View File
@@ -3,10 +3,11 @@ 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 { visualFingerprint } from '../../scripts/source-fingerprint.mjs';
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
import { launch } from '../serve.mjs';
import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from './screenshots.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const OUTPUT = resolve(ROOT, 'docs/images');
@@ -16,75 +17,6 @@ const INTEGRATION_BUNDLE = resolve(ROOT, 'custom_components/houseplan/frontend/h
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;
@@ -179,7 +111,12 @@ const browserErrors = [];
page.on('pageerror', (error) => browserErrors.push(error.message));
try {
const fingerprint = await assertFreshDemoBundle(page, ROOT);
// Свежесть бандла проверяется строго, вместе с версией: картинки обязаны
// приехать из бандла, собранного из ЭТОГО дерева. А в манифест пишется
// версионно-нечувствительный отпечаток (#245) — номер версии на скриншотах
// не виден, и требовать из-за него пересъёмки нечестно.
await assertFreshDemoBundle(page, ROOT);
const fingerprint = visualFingerprint(ROOT);
const scenarios = {};
for (const scenario of DOC_SCREENSHOTS) {
await prepareGoldenScenario(page, scenario);
@@ -207,6 +144,9 @@ try {
const manifest = {
version: DOC_SCREENSHOT_VERSION,
fixture: 'synthetic-only',
// Кто снимал. Смена браузера переписывает все картинки без содержательных
// изменений (#246), поэтому окружение съёмки — часть доказательства.
chromium: browser.version(),
sourceFingerprint: fingerprint,
captureScriptSha256: sha256(readFileSync(SCRIPT)),
command: 'npm run build && node demo/docs/capture.mjs',
+75
View File
@@ -0,0 +1,75 @@
/**
* Каталог сценариев съёмки документации. Отдельным модулем, потому что его
* читают трое: сам капчур, `scripts/check-docs.mjs` и приёмка артефакта
* `scripts/docs-accept.mjs` (#246). Импортировать его из `capture.mjs` нельзя —
* тот скрипт при импорте поднимает браузер и снимает картинки.
*/
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,
},
]);
+10 -2
View File
@@ -3,6 +3,8 @@
* visual-regression tooling. Nothing here depends on a real HA installation.
*/
import { fixtureWallKey } from './wall-key.mjs';
const FLOOR_COUNT = 3;
const ROOMS_PER_FLOOR = 20;
const DEVICE_COUNT = 200;
@@ -213,8 +215,14 @@ export const makeLargeHouseFixture = () => {
cell_cm: 5,
settings: { fill_mode: 'glow', show_borders: true, show_names: true },
rooms,
walls: segments.map((wall, index) => ({
key: `perf-wall-${floor}-${index}`, cm: 15, a: wall.a, b: wall.b,
// The key must be the real one. A label like `perf-wall-0-3` does not
// parse as coordinates, so neither the exact match nor the tolerant
// fallback in lookupWall finds the record: every solid edge of this
// fixture resolved to zero thickness and the plan carried no wall bodies
// at all, while the same fixture backs four golden scenes and all six
// performance budgets (#260).
walls: segments.map((wall) => ({
key: fixtureWallKey(wall.a, wall.b), cm: 15, a: wall.a, b: wall.b,
})),
openings: makeOpenings(floor, segments, openingCount),
partitions: makePartitions(floor, rooms, partitionCount),
+7 -16
View File
@@ -4,22 +4,13 @@ const round = (value) => Number(value.toFixed(6));
// Golden fixtures must use the same persisted wall-key contract as real plan
// data. Arbitrary labels make every configured wall look virtual to the
// renderer, which lets a visually ineffective baseline pass unnoticed.
const WALL_KEY_PITCH = 1 / 240;
export const fixtureWallKey = (a, b) => {
const quantize = (value) => Math.round(value / WALL_KEY_PITCH) * WALL_KEY_PITCH;
const mx = quantize((a[0] + b[0]) / 2);
const my = quantize((a[1] + b[1]) / 2);
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (length < 1e-12) { dx = 1; dy = 0; }
else { dx /= length; dy /= length; }
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
const bucket = Math.round(angle * 1800) / 1800;
return `${mx.toFixed(4)},${my.toFixed(4)}@${bucket.toFixed(4)}`;
};
// renderer, which lets a visually ineffective baseline pass unnoticed — and a
// key that merely differs in precision is found only through the tolerant
// fallback in lookupWall, which is luck rather than contract (#260). The
// formula lives in one place for every fixture and is pinned to the product one
// by test/fixture-wall-key.test.mjs.
export { fixtureWallKey } from './wall-key.mjs';
import { fixtureWallKey } from './wall-key.mjs';
const uniqueEdges = (rooms) => {
const edges = new Map();
+63
View File
@@ -0,0 +1,63 @@
/**
* Ключ записи толщины стены — один на все фикстуры проекта.
*
* Копия формулы из `src/wall-thickness.ts`, и копия здесь неизбежна. Фикстуры
* обязаны оставаться без внешних импортов: бэкенд-гейт запускает их как
* `node --input-type=module --eval "import * as f from './demo/fixtures/…'"`
* в job без `npm ci` и без `test-build/` (`.github/workflows/validate.yml`,
* job `backend`), а `scripts/source-fingerprint.mjs` хеширует только `src/**`
* и `.mjs` из `demo/fixtures` и `demo/golden` — код, втянутый из `scripts/`,
* менял бы поведение фикстуры при неизменном отпечатке, на котором стоят и
* валидность golden-эталонов, и переиспользование гейтов.
*
* Поэтому файл лежит ЗДЕСЬ, внутри `demo/fixtures`: так он попадает в
* отпечаток, и так его видит одна привязка вместо трёх копий формулы.
* `test/fixture-wall-key.test.mjs` сверяет его с продуктовым `wallKey`.
*
* Ловушка, из-за которой этот файл и появился (#260): точность зависит от шага.
* При `pitch = 1/240` продукт печатает ШЕСТЬ знаков, а не четыре — фикстура с
* четырьмя расходилась с продуктом на каждой записи и находилась только через
* терпимый запас `lookupWall`. Метка вместо ключа (`perf-wall-0-3`) не
* находилась вовсе: все сплошные рёбра оставались с нулевой толщиной.
*/
/** Шаг решётки редактора в нормализованных координатах (`GRID_N = 240`). */
export const WALL_KEY_PITCH = 1 / 240;
/** Направление стены по модулю 180°: стена одна и та же с любого конца. */
const direction = (a, b) => {
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (length < 1e-12) return [1, 0];
dx /= length; dy /= length;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) return [-dx, -dy];
return [dx, dy];
};
/**
* Координата, отличающаяся от узла решётки не больше точности хранения, — это
* тот же узел (#258). Канонизация опознания, а не снап геометрии: произвольная
* точка вне решётки остаётся вне решётки. Без этого шага ничья округления на
* стене нечётной длины в шагах разводила один и тот же ключ на два.
*/
const keyEpsilon = (pitch) => Math.max(Math.abs(pitch) * 1e-6, 1e-9);
const canonical = (value, pitch) => {
if (!(pitch > 0) || !Number.isFinite(value)) return value;
const snapped = Math.round(value / pitch) * pitch;
return Math.abs(snapped - value) <= keyEpsilon(pitch) ? snapped : value;
};
export const fixtureWallKey = (a, b, pitch = WALL_KEY_PITCH) => {
const quantise = (value) => (pitch > 0 && Number.isFinite(value)
? Math.round(value / pitch) * pitch : value);
const ca = [canonical(a[0], pitch), canonical(a[1], pitch)];
const cb = [canonical(b[0], pitch), canonical(b[1], pitch)];
const mx = quantise((ca[0] + cb[0]) / 2);
const my = quantise((ca[1] + cb[1]) / 2);
const [dx, dy] = direction(ca, cb);
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
const bucket = Math.round(angle * 1800) / 1800;
const precision = pitch > 0 && pitch < 0.01 ? 6 : pitch < 1 ? 4 : 2;
return `${mx.toFixed(precision)},${my.toFixed(precision)}@${bucket.toFixed(4)}`;
};
+1 -1
View File
@@ -35,7 +35,7 @@ Build and copy the exact current source first:
```bash
npm run build
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
npm run bundle:sync
npm run golden:capture
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

+101 -77
View File
@@ -1,93 +1,117 @@
{
"schema": 1,
"matrixVersion": 34,
"acceptedAt": "2026-08-22T14:09:45.021Z",
"sourceFingerprint": "2d8295e95a0721f11075dd0388ab4960c79b480ae199a381d3bbd1086e5e5b34",
"matrixVersion": 44,
"acceptedAt": "2026-08-24T16:12:22.415Z",
"sourceFingerprint": "7a687db9aadb86ee4af8374678c06f3697fb98935ea9bfa3df7b2cd0b652543a",
"chromium": "151.0.7922.34",
"scenarios": {
"coincident-partition-before-dark": "c0e38cfff096725cf1eb5dc91e005b2af983172d04e6573075db635275cabca3",
"coincident-partition-thin-dark": "3a33ebf94e6c19b0079cf66975180817146d68ae97d1f9ff4a26711f4ca54903",
"coincident-partition-thick-dark": "33f9296e765f4ea9922cb3b13d11b50306709ba4fcf3114d351eda1833399870",
"coincident-partition-virtual-dark": "84969a05c442549dac0bcbb7e507ee77967e42f75d0fdf3c8e8fbe2170c65834",
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
"split-corner-wall-thin-dark": "17e49394c300887bb5349bf477292571e96e209c9515db6853348883d95a1422",
"split-corner-wall-thick-dark": "309b8fe8a206f4f3fc50f6a27fc762a933f245b778e7b1f9a6348b66f39b2c39",
"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",
"isometric-geometry-view-dark": "43ad006844a78b502b36aeeb82d82669532e06d2c798de928a5207acd24bd5f9",
"isometric-geometry-view-light": "0fcb10ffdc0baa9006d1c2f0406ebf19e71ba9feda180979a51b74166d7200d2",
"isometric-live-layers-dark": "8aaa914fc45780f67dd5c008a106696d21235a9acee7402f9cc8ae51a26660f3",
"isometric-no-borders-dark": "c105382d883bbdae8562b756ccbf47ac1fcf46675c6aa7654289856223a3ebf0",
"isometric-touch-kiosk-dark": "be122ceec8114374ba384f51d5d9287e498a97354865c5a9f91d505ca18d8640",
"isometric-large-warm-remount-dark": "d76e3334bf80bff265b1e8aa6770c5eff5df6fbfebeb4703b04d048c4252eb68",
"geometry-view-dark-fit": "30985326ad907fac4b93f848860e5b6c7737f24e8281706c3f69a189477f932f",
"geometry-view-light-fit": "310cca616bf1fc5fda4f1e8d58e80ddf2fe2662d088829e22eef7b9e6b5ddd93",
"room-label-parity-view-dark": "7f1da43466a13f82a7595dddabe6938ed974debfb14c8f40e729e273a53ac4ff",
"room-label-parity-plan-dark": "5f976d5743d68a27b594e1a5ddf45e5ad3708f5c703409e5cefb7c96d3048096",
"room-label-parity-view-light": "ee3d303fb2b329c356a4354d355c29694130a38d4812ffbfefbe861f5d3f9860",
"room-label-parity-plan-light": "d32b538499a031d4310b243cc9ef43610f6cd67aabfde7cd01b59c88b0363ec5",
"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",
"day-cycle-dawn-dark": "b01d9add378afb47912da10f6327d43177d4bbb0b42cb4323395fd01bf2fe8f5",
"day-cycle-day-dark": "97db91bd4daf41d903cc968d70a5a4ab7af7e332b60b7316e339f490e08fa953",
"day-cycle-dusk-dark": "a856187898fc425a48dd165f5ec01f0f50b78043b382f708dbffd810bfdbf043",
"day-cycle-night-dark": "b863260250d739fd369140356e195a64d3ac58478ab2c1e43415abd8f96a7fbd",
"geometry-plan-editor-dark": "a1ca4219a1e7b2c4749b72edad87cd0bdee793390cd6efe972aca1d41d0d2478",
"safe-resize-handles-clamp-light": "298670cc94642968e58c45560597e8277c405cf61c2a8b5e08f1544612a6ed4b",
"safe-resize-handles-clamp-dark": "64f32f2038238f423ba3cc1ed618bf9d32704c8161042751d447c3f884e37e4a",
"space-tab-drop-before-light": "86d8159ac6191980f2ac91b137c3947219da45fef81bc2b358442958eda9d0f8",
"space-tab-drop-after-dark": "2e2bde063f9fa34284beb27457f1542e8b6085033be67fd0dc1923b81c6af1bf",
"plan-snap-endpoint-light": "78225caf0522f69c294b299212a717260c31e9620bdefdc970dc84730ae06e17",
"plan-snap-line-gaps-dark": "61bac139592fd292a8e9ded541dc4b98912779f415f371108c2d99a18c7d9d57",
"wall-junctions-plan-preview-light": "1482b01209a796f93ebc23e231b33fe869ee96b6f46d1fb2d32ccf677a94e602",
"wall-junctions-plan-t-dark": "40f0b57e2b713df28c2769ba44931b165aaa669d4c0b3dd97d562e904292d449",
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
"junction-patch-resilience-plan-dark": "c42203eea92d2bc972eed829aa726ad51f36fc76d0ae049730d0f4155ef8efbf",
"junction-patch-resilience-view-dark": "d531f2a01def73fb074e55543c8543112af158df65f24dc549e0da5a23f1af40",
"junction-patch-resilience-plan-dark": "5dac2f2f81c801b0d9b1c0ee786f1f531906c80ddeec36f9c91b524468c13608",
"junction-patch-resilience-view-dark": "efe6e3ebd0e08e936bfa79886d5f55185198cf770a5d2473160cf2335be46340",
"wall-union-isolation-view-light": "291c045758be2814be88d12ab46bdc3d12cb99a03dbbcc9a1539c2c9a5931edf",
"wall-union-isolation-view-dark": "616fc6cc7144c9596ae52421027914e222f30f26de45215fa7e7b6175ca78543",
"multiwall-junction-bevel-view-dark": "70f7f0e7c60beb33853fc26445b6901d48299c2c92ddc41f6e1e2cfb4cac2320",
"orthogonal-strip-cell-5-view-dark": "9b8e4bd22cbf1a82ff48f052822967c61179290a3234ae48ef547642329b5a90",
"orthogonal-strip-cell-1-view-dark": "44e245db41ab9a4e575cefbf7656055dba7e27fc62c2b1215445517a9ea81386",
"wall-key-roundtrip-view-dark": "28895aa02eb995f976b26a2d2b3675954611641b60910f40427b15a95ef53e3e",
"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": "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",
"opening-placement-door-thick-wall-dark": "59469954c1ea5a2e79df7eea1c287228998c0bfe1b3dc43869d553f25be78222",
"opening-placement-passage-thick-wall-dark": "f3efc2bafbc472f18042f26a896870a4256aa2ea843d887f8b33301001a947f0",
"opening-placement-passage-thick-wall-light": "e5f6024151eebb328f211793bd2d346703d627524bdff931c61ce13b5a5fa7f4",
"opening-symbol-room-wall-light": "562fed297210ef542de608e384c2d344dddd193a5b43615930c9f76faaf53b42",
"opening-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
"opening-symbol-flip-pairs-light": "33364f51fb65e49bfdd6c41d6e0c53542c89fadc45a3c6e5adc590144c4676ea",
"isometric-opening-symbol-parity-dark": "302daf5d90d4af5ef90036cc10e957e6fc909ea56f9f2af938ab850d4f608e34",
"geometry-devices-editor-dark": "9053fd940e5f2a27f0062b159e4b4debd3d4f47d5cd5bd81c0b154ecc0f4639b",
"geometry-decor-editor-dark": "736e289cf16adb5fa5e58e7db7d75475e16065b58039bbdc0cec402f4256f390",
"tray-wide-selection-en": "dab331bc075c9742b4aecb9feb7de01a30601416d7dcaeb78f50252af0d58a5d",
"tray-wide-tool-ru": "0623ff17c4c22d338d9d317f348b2e181658cf0fe3275a6dc6b80d4077ec3b68",
"tray-medium-group-en": "03410227827c034035245bf84b4a283ac72f0da3925d3a4b6873f1bb1992f057",
"tray-medium-selection-ru": "ec9c28eb81cf2b5106254422d17e0adc58ba22a720db4f4832c669ccd0c84723",
"tray-narrow-palette-en": "10f4786a9e6020a8cb1c92b4b1eab299a50ae46eea7a9360b6af8cf675b4c943",
"tray-narrow-tool-ru": "2c2a886b70cda7b02bbd663ee915fd1e68f87587b85c653496784084883bcc7b",
"geometry-diagonal-45-opening-dark": "1bc04b80e39234e98b98dbcc8eba554ee77d3c5dd3de0adfa140fa24410711f8",
"openings-thick-wall-dark": "c45bae26ec0b4f719f72e5b92e3b8e03260d7bda4b3182d4c563b50f656e68a1",
"openings-filled-tunnel-dark": "b916d3ce48bba7487ed4b548bf659b610243ebcde07206c21fff65867792d0d1",
"decor-over-opaque-hover-light": "553d279db44aa0a4a6855496036b0f6ddda9570cef133e09a30f29f41b7d0c31",
"decor-over-glow-base-dark": "8e5b8630798e31936f8e7dd52d1cba73a3f80fd3d3217a5ad59d50b5dc12a26d",
"openings-hidden-view-dark": "d99f365376e6ece668060181ce8c3a5ebdb93ec5a8c64e08b0aef5af592c6874",
"lighting-glow-sun-dark": "8599aea80a0479442f891adb30aecd647ddf217e28e3f0da5c21b41e21d746ec",
"device-value-badge-positions-dark": "ae163e96cd976e006eacd09ebc75440d600e81c063e38da27d53000e4e51c68d",
"device-icon-state-table-light": "969879fc7baeb193639a03701eb8bacc9469f6160b8d41a318db8da83590e51d",
"device-icon-state-table-dark": "045145a87ccde3894334009c6b9edd690654fd048c55b770e09d5ed79f9a7351",
"device-text-shell-long-light": "a96d16ac0a02ed322bab949e34ff7ce59da5f5db42e02185fac5fde495591bb1",
"device-text-shell-long-dark": "f301f3aa1e1eb382018c4329db2909a8d2990f5f917bde83e3de46ff4dc7416c",
"lighting-sun-window-state-only-dark": "4da798c4de6d1303d17be246e9a2e63b12da3f53a42136cf745d1a92ad5a4f21",
"lighting-fill-light-axis-split-dark": "6b3a4dd637c6fe7e43a131c44c4be232bd81edab417ea9af3860d82c506ad45f",
"lighting-fill-temp-axis-split-dark": "6025c6efd3ea3565c13e147db550b017eeb86da0be5dfc82a8b5bd7e623d5852",
"lighting-fill-lqi-axis-split-dark": "1ec5c18572fd5be471088286c4aa7c67c3885cb907080fcde44c3583ff794275",
"lighting-temp-glow-dark": "274bcd5a19ebbcb63fb64a5d21f838122f1916d428defcaa3c0356d8ea9809a5",
"lighting-temp-glow-light": "decc386073a1e93d8d457ec09daf83e8afc180977f5880ce56cc4834a2c203f0",
"lighting-custom-glow-dark": "32607e6001c4c8cd541cabf82142f3cbf7f2b298900a02f30c6f44ececbd5907",
"lighting-opaque-glow-two-doorways-dark": "bd9f49432c69c8ed622810e96f4708974e2024c58700b0cfa3e5ebd2edd7bc7b",
"lighting-custom-glow-light": "973ae32bb3bacbfe633041e89352a9ef1a2222b89a4792feef45c26bfa541459",
"lighting-temp-glow-no-sources-dark": "e19b7d86d5dae54f9afc0a1ef04a3848ebec283a5f60e68efdcc7380dff3b0a7",
"lighting-temp-glow-room-override-dark": "bebfc27f460cb75e32ab14076a01e3b9729763d7cecd3f457c1b696ed3e57da3",
"lighting-manual-auto-spill-overlap-dark": "471e39397361832a171f0309302be7d8ba4310d628e337478e23da44dc455ef8",
"hover-over-glow-dark": "6581433024060f773e00211d60eac7b6daffe442031ff076f861918419c71930",
"hover-nested-room-dark": "675170204c1d98b891a3e06d921e4264538c4949e5d1b274efa3ef708afef169",
"large-house-zoom-040-dark": "df1a5515089b5e470ae550ce6da31a301be5079d3f7bb9130baad99d3d7c641e",
"large-house-zoom-250-dark": "88c0d22686fd9ef314866d0275a5ae785781c3d671e257b2c5f790f816cd65ee",
"large-house-warm-remount-dark": "9a61d668c5fbc61bb370c043771cee91707b3d40378cf89f81199882820bf95c",
"device-dialog-desktop-en": "9739886af3104c2fe3d6d3138293e9fd1a7f480f151decc26f12bb64cab69543",
"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-help-popover-light-ru": "add3275d74ced358a184ec9f864c83735f1196e2bff7d75fb3e12160cb85bfaf",
"decor-color-popover-mobile-ru": "51725859afc6a1e2d1b2ae8be270ab584608901623ef7e2d283b6b195644fd16",
"decor-color-popover-desktop-en": "0a33f2a4ad6614bccbce45fbda1eaa505551e479b1eaada28ddcaf435a83f74f",
"general-color-popover-desktop-en": "91c3b7ebae5146c62d172d1ce10ae9e375ed955f23e9dc860976373a334d8be5",
"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"
"space-room-color-popover-desktop-ru": "a7b2c78d8b178eb1947937f9aebe2b02d67a0f6fed3b2a69a7bf0d7e24df50ac",
"backup-full-preview-desktop-en": "252f4f687f188645f0b22a49f03c63725aefc6bda5ff2e930a33b8ac729544a8",
"backup-plan-only-export-desktop-en": "d76da7b84f46499b8323c920e464e1ff52893dee17db08001badbdb1a09829c1",
"backup-space-preview-mobile-ru": "698e81b952a26e50b60d2ec85321c43035ff4376a5a348eeed39726804cead78",
"optimize-preflight-dialog-dark-en": "93461a3d95208aa798dd93d5500003127dcad8e7993f7bdc6b7f8f1a95389d73",
"optimize-preflight-dialog-light-ru": "de43f205fdf16d290603543a02ced9492d1b5a35e089fa6023c98efdf32ed7c3",
"optimize-orphan-references-dark-en": "ce2116ffefd64e6b38921c3afd2a5d9bd9329e8a2aec102eb42a032d23a7d000",
"optimize-orphan-references-light-ru": "5a4152df98a34e724b25c0d331fd691fa63eaba753bd4a9c5b173a454c148ecd",
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

After

Width:  |  Height:  |  Size: 279 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: 335 KiB

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

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