Compare commits

...
Author SHA1 Message Date
Sergey Matyunin aa97e7e0bc test: accept device icon golden baselines
Issue: #179
User-Visible: no
Release: v1.65.0-beta.6
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32298649253
2026-08-19 23:36:17 +03:00
Matysh 72eae1059c perf: skip a heavy gate whose inputs are byte-identical to a green run
Every push to dev paid for the full browser trio and the backend suite,
including commits that touch only documentation, workflows or process
scripts — the bundle and the harness were byte-identical, so the runs
proved nothing new. On 2026-08-19 alone that was roughly six pushes at
about seven minutes each.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Issue: #191
User-Visible: no
2026-08-19 09:16:54 +03:00
Sergey Matyunin e2bb90b59b test: accept v1.65.0-beta.3 Linux golden baselines
Issue: #57
User-Visible: no
Release: v1.65.0-beta.3
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32207124102
2026-08-19 05:09:14 +03:00
Sergey Matyunin a80fa1fa5e Release v1.65.0-beta.3 candidate
Issue: #57
Issue: #103
Issue: #113
Issue: #117
Issue: #132
Issue: #184
Issue: #187
Issue: #189
User-Visible: yes
2026-08-19 05:02:30 +03:00
claude[bot] 0e53b1b0d6 docs: review document for #184
Issue: #184
User-Visible: no
2026-08-19 01:50:18 +00:00
Sergey Matyunin fa015907d4 fix: reject stale space position writes
Issue: #184
User-Visible: no
2026-08-19 04:42:49 +03:00
claude[bot] 5d04e9b7c2 docs: review document for #189
Validate / docs (push) Failing after 38s
Validate / provenance (push) Successful in 54s
Validate / changes (push) Successful in 45s
Validate / process-gate (push) Successful in 54s
Validate / hacs (push) Skipped
Validate / frontend (push) Skipped
Validate / smoke (push) Skipped
Validate / hassfest (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Issue: #189
User-Visible: no
2026-08-19 01:35:50 +00:00
Sergey Matyunin 89789d8fa6 fix: cut partition snap axes at hosted openings
Issue: #189
User-Visible: yes
2026-08-19 04:23:09 +03:00
claude[bot] 6846ffb828 docs: code review document for #187
Validate / golden (push) Failing after 9m37s
Validate / smoke (push) Failing after 19m32s
Validate / performance_smoke (push) Failing after 11m49s
Validate / docs (push) Failing after 21s
Validate / hacs (push) Failing after 15s
Validate / provenance (push) Successful in 31s
Validate / changes (push) Successful in 53s
Validate / hassfest (push) Failing after 12s
Validate / process-gate (push) Failing after 1m3s
Validate / frontend (push) Successful in 5m54s
Validate / backend (push) Failing after 7m15s
Issue: #187
User-Visible: no
2026-08-19 01:16:54 +00:00
Sergey Matyunin c8755b7c51 Fix Glow fail-dark source guard
Issue: #187
User-Visible: yes
2026-08-19 04:09:13 +03:00
claude[bot] fe15d863ce docs: code review document for #57
Validate / docs (push) Successful in 22s
Validate / provenance (push) Successful in 41s
Validate / process-gate (push) Failing after 49s
Validate / changes (push) Successful in 38s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / smoke (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Issue: #57
User-Visible: no
2026-08-19 00:58:46 +00:00
Sergey Matyunin 299da593d5 Unify color and opacity picking
Issue: #57
User-Visible: yes
2026-08-19 03:46:38 +03:00
claude[bot]andSergey Matyunin 32e3a79dcd docs: review document for #57
Issue: #57
User-Visible: no
2026-08-19 03:36:42 +03:00
Sergey Matyunin ffb10843c9 docs: specify unified color opacity picker
Issue: #57
User-Visible: no
2026-08-19 03:36:22 +03:00
claude[bot] 5fb510290a docs: review document for #103
Validate / docs (push) Successful in 34s
Validate / provenance (push) Successful in 59s
Validate / changes (push) Successful in 52s
Validate / process-gate (push) Failing after 57s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / smoke (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Validate / golden (push) Skipped
Issue: #103
User-Visible: no
2026-08-19 00:34:45 +00:00
Sergey Matyunin 932773773f Show state in toggle confirmations
Issue: #103
User-Visible: yes
2026-08-19 03:23:58 +03:00
claude[bot]andSergey Matyunin 6c7958c6d0 docs: add spec review r1 for toggle confirmation state
Issue: #103
User-Visible: no
2026-08-19 03:12:56 +03:00
Sergey Matyunin a449edc545 docs: specify toggle confirmation states
Issue: #103
User-Visible: no
2026-08-19 03:12:55 +03:00
claude[bot] e88c23b8ee docs: review document for #113
Validate / provenance (push) Successful in 45s
Validate / changes (push) Successful in 56s
Validate / process-gate (push) Failing after 58s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / smoke (push) Skipped
Validate / backend (push) Skipped
Validate / frontend (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / docs (push) Failing after 34s
Issue: #113
User-Visible: no
2026-08-19 00:11:27 +00:00
Sergey Matyunin acad3b32c1 Refresh documentation source fingerprint
Issue: #113
User-Visible: no
2026-08-19 03:01:22 +03:00
claude[bot] b443a333e0 docs: review document for #113
Issue: #113
User-Visible: no
2026-08-18 23:59:36 +00:00
Sergey Matyunin 66fa8f476c Keep mutation anchor aligned after optional model change
Issue: #113
User-Visible: no
2026-08-19 02:51:50 +03:00
claude[bot]andSergey Matyunin 047363c2d3 docs: code review document for #113
Issue: #113
User-Visible: no
2026-08-19 02:49:39 +03:00
Sergey Matyunin 1e8503bd46 Make empty space model explicit
Issue: #113
User-Visible: no
2026-08-19 02:49:39 +03:00
claude[bot]andSergey Matyunin 135497b272 docs: review document for #113
Issue: #113
User-Visible: no
2026-08-19 02:47:50 +03:00
Sergey Matyunin 0c2a5dedea docs: specify optional space model contract
Issue: #113
User-Visible: no
2026-08-19 02:47:50 +03:00
claude[bot] c1676cf26a docs: review document for #117
Validate / docs (push) Failing after 35s
Validate / provenance (push) Successful in 53s
Validate / process-gate (push) Failing after 55s
Validate / changes (push) Successful in 46s
Validate / smoke (push) Skipped
Validate / backend (push) Skipped
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Issue: #117
User-Visible: no
2026-08-18 23:20:52 +00:00
claude[bot]andSergey Matyunin c65cbcc96c docs: review document for #117
Issue: #117
User-Visible: no
2026-08-19 02:12:37 +03:00
Sergey Matyunin 01fe48de00 fix: support registryless opening entities
Issue: #117
User-Visible: yes
2026-08-19 02:12:37 +03:00
claude[bot]andSergey Matyunin 9baf533c90 docs: review document for #117
Issue: #117
User-Visible: no
2026-08-19 02:11:36 +03:00
Sergey Matyunin 563a850aac docs: specify registryless opening entities
Issue: #117
User-Visible: no
2026-08-19 02:11:35 +03:00
claude[bot] 9e5ff0b8a0 docs: review document for #132
Issue: #132
User-Visible: no
2026-08-18 22:51:35 +00:00
Sergey Matyunin 3fe0f8c443 fix: address partition opening review regressions
Issue: #132
User-Visible: yes
2026-08-19 01:34:51 +03:00
claude[bot] 742b3279a2 docs: code review document for #132
Issue: #132
User-Visible: no
2026-08-18 22:28:09 +00:00
Sergey Matyunin 9f77e3e932 feat: support openings in independent walls
Issue: #132
User-Visible: yes
2026-08-19 01:11:55 +03:00
Sergey Matyunin 083621342a docs: address partition openings spec review
Issue: #132
User-Visible: no
2026-08-19 00:35:59 +03:00
claude[bot] 4f20befd77 docs: review document for #132
Issue: #132
User-Visible: no
2026-08-18 21:33:56 +00:00
Sergey Matyunin 2aaabc48d6 docs: update partition openings spec after unified walls
Issue: #132
User-Visible: no
2026-08-19 00:21:38 +03:00
Sergey Matyunin b9bf210804 docs: specify partition openings
Issue: #132
User-Visible: no
2026-08-19 00:19:01 +03:00
Sergey Matyunin d6007dc444 ci: retry prerelease gate after runner mirror timeout
Issue: #150
Issue: #157
Issue: #170
Issue: #172
Issue: #173
Issue: #174
Issue: #178
User-Visible: no
2026-08-18 22:44:29 +03:00
Sergey Matyunin 54c5ca3840 test: accept v1.65.0-beta.2 golden baselines
Issue: #150
Issue: #172
Issue: #173
Issue: #178
User-Visible: no
Release: v1.65.0-beta.2
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32175473418
2026-08-18 22:26:47 +03:00
Sergey Matyunin 57fc434d4f Release v1.65.0-beta.2 candidate
Issue: #150
Issue: #157
Issue: #170
Issue: #172
Issue: #173
Issue: #174
Issue: #178
User-Visible: yes
2026-08-18 22:11:05 +03:00
Sergey Matyunin 9530ee2e5a fix: preserve short wall closure gestures
Issue: #173
User-Visible: yes
2026-08-18 22:05:03 +03:00
287 changed files with 31051 additions and 3353 deletions
+9 -1
View File
@@ -132,9 +132,17 @@ jobs:
- name: Install candidate and baseline dependencies
run: npm ci --prefix candidate && npm ci --prefix baseline
# То же, что в validate.yml: кэш браузеров, apt не трогаем (#206).
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('candidate/package-lock.json') }}
- name: Install pinned Chromium
if: steps.pw.outputs.cache-hit != 'true'
working-directory: candidate
run: npx playwright install --with-deps chromium
run: npx playwright install chromium
- name: Build both exact source trees
run: |
+15 -10
View File
@@ -299,16 +299,20 @@ jobs:
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
Серьёзность: High блокирует; Medium обязан стать отдельным issue;
Low либо правится, либо снимается с записью. Жёлтый вердикт
допустим при полностью выполненных AC, если изменение не решает
заявленный сценарий или ухудшает смежный. Продуктовое рассуждение
расширяет вопросы, но не отменяет AC и не даёт права менять скоуп.
Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней
же — без High это жёлтый вердикт и возврат автору, отдельный issue
НЕ заводится (решение владельца 2026-08-19, #202: заведение и
обслуживание issue дороже правки на месте); Low либо правится,
либо снимается с записью. Жёлтый вердикт допустим и при полностью
выполненных AC, если изменение не решает заявленный сценарий или
ухудшает смежный. Продуктовое рассуждение расширяет вопросы, но не
отменяет AC и не даёт права менять скоуп.
Каждую Medium-находку заведи отдельным issue со ссылкой на
#${{ github.event.issue.number }} и метками: тип, приоритет,
S1-new. «Оставили в тексте ревью» закрытием не считается и прямо
запрещено §12.
Только Medium-находку ВНЕ скоупа задачи (попутный дефект соседнего
поведения, который в этой ветке чинить нельзя) заведи отдельным
issue со ссылкой на #${{ github.event.issue.number }} и метками:
тип, приоритет, S1-new. «Оставили в тексте ревью» закрытием не
считается и прямо запрещено §12.
Напиши полный документ ревью в файл
docs/reviews/<SPEC|CODE>-REVIEW-${{ github.event.issue.number }}-r${{ needs.guard.outputs.cycle }}.md
@@ -319,7 +323,8 @@ jobs:
Затем оставь в issue краткий комментарий: вердикт, ключевые находки
и ссылка на документ. Первой строкой — вердикт в формате §7.2:
`Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → #…`
`Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…`
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору жёлтым)
Затем верни JSON по схеме. Это последнее действие и оно обязательно:
без него метка не переставится и конвейер встанет.
+208 -10
View File
@@ -116,6 +116,98 @@ jobs:
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
} >> "$GITHUB_OUTPUT"
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
# сборки) ПЛЮС оснастка именно этой job. Маркер в кэше пишет только успешный
# прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми
# же входами уже завершилась успешно.
#
# Это НЕ фильтр путей из job `changes` (на dev они отключены намеренно): там
# объём прогона угадывается по путям и «зелёный» начинает значить разное,
# здесь эквивалентность входов доказана хешем.
#
# Свойство, снимающее главный риск: релизный кандидат бампает версию, а
# CARD_VERSION и package.json входят в фингерпринт, поэтому ключи кандидата
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
reuse:
runs-on: ubuntu-latest
outputs:
smoke: ${{ steps.probe.outputs.smoke }}
golden: ${{ steps.probe.outputs.golden }}
performance_smoke: ${{ steps.probe.outputs.performance_smoke }}
backend: ${{ steps.probe.outputs.backend }}
smoke_key: ${{ steps.keys.outputs.smoke }}
golden_key: ${{ steps.keys.outputs.golden }}
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
backend_key: ${{ steps.keys.outputs.backend }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Ключи переиспользования
id: keys
run: |
for job in smoke golden performance_smoke backend; do
key=$(node scripts/gate-reuse.mjs --job="$job")
echo "$job=$key" >> "$GITHUB_OUTPUT"
echo "$job: $key"
done
# lookup-only: маркер только проверяется, но не восстанавливается —
# сохранять его в этой job нечего, она ничего не прогоняла.
- name: Маркер smoke
id: m_smoke
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-smoke-${{ steps.keys.outputs.smoke }}
lookup-only: true
- name: Маркер golden
id: m_golden
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-golden-${{ steps.keys.outputs.golden }}
lookup-only: true
- name: Маркер performance_smoke
id: m_perf
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
lookup-only: true
- name: Маркер backend
id: m_backend
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-backend-${{ steps.keys.outputs.backend }}
lookup-only: true
- name: Что переиспользуем
id: probe
env:
SMOKE: ${{ steps.m_smoke.outputs.cache-hit }}
GOLDEN: ${{ steps.m_golden.outputs.cache-hit }}
PERF: ${{ steps.m_perf.outputs.cache-hit }}
BACKEND: ${{ steps.m_backend.outputs.cache-hit }}
run: |
# Пропуск обязан быть громким: молчаливый skip — тот самый тихий
# успех, который уже дважды стоил нам дня (#171, #207).
waive() {
if [ "$2" = "true" ]; then
echo "$1=true" >> "$GITHUB_OUTPUT"
echo "::notice::$1 не прогоняется: входы побайтово те же, что в предыдущем успешном прогоне (#208)"
echo "- **$1** переиспользована: входы не менялись" >> "$GITHUB_STEP_SUMMARY"
else
echo "$1=false" >> "$GITHUB_OUTPUT"
echo "- $1: прогоняется" >> "$GITHUB_STEP_SUMMARY"
fi
}
echo "### Переиспользование гейтов (#208)" >> "$GITHUB_STEP_SUMMARY"
waive smoke "$SMOKE"
waive golden "$GOLDEN"
waive performance_smoke "$PERF"
waive backend "$BACKEND"
hacs:
needs: changes
if: needs.changes.outputs.integration == 'true'
@@ -160,7 +252,8 @@ jobs:
smoke:
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
needs: frontend
needs: [frontend, reuse]
if: needs.reuse.outputs.smoke != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -169,8 +262,20 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
- name: Install Chromium for Playwright
run: npx playwright install --with-deps chromium
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
# внятной ошибкой — тогда флаг вернуть.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install pinned Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
- name: Build a fresh bundle for the smokes
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Smoke suite
@@ -194,11 +299,28 @@ jobs:
with:
name: smoke-logs
path: /tmp/smoke-logs
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
run: |
printf '%s\n' "smoke прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
- uses: actions/cache/save@v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-smoke-${{ needs.reuse.outputs.smoke_key }}
golden:
# Deterministic visual correctness stays in every prerelease gate: it is
# inexpensive and catches a different class of regressions than timings.
needs: frontend
needs: [frontend, reuse]
if: needs.reuse.outputs.golden != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -207,8 +329,20 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
# внятной ошибкой — тогда флаг вернуть.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install pinned Chromium
run: npx playwright install --with-deps chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
- name: Build the exact source under review
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Capture or verify golden matrix
@@ -227,13 +361,33 @@ jobs:
with:
name: golden-images
path: artifacts/golden
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
run: |
printf '%s\n' "golden прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
- uses: actions/cache/save@v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-golden-${{ needs.reuse.outputs.golden_key }}
performance_smoke:
# Candidate-only catastrophic-regression guard for ordinary pushes and
# prereleases. The expensive same-runner comparison lives in performance.yml.
needs: frontend
needs: [frontend, reuse]
if: needs.reuse.outputs.performance_smoke != 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
# 15 минут не хватало, когда установка браузера шла через apt: замер
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
# job укладывается в те же минуты, что и раньше.
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
@@ -241,8 +395,20 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
# внятной ошибкой — тогда флаг вернуть.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install pinned Chromium
run: npx playwright install --with-deps chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
- name: Build the exact candidate source
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Capture the heaviest Glow state
@@ -257,10 +423,26 @@ jobs:
with:
name: performance-smoke
path: artifacts/performance-smoke
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
run: |
printf '%s\n' "performance_smoke прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
- uses: actions/cache/save@v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
backend:
needs: changes
if: needs.changes.outputs.backend == 'true'
needs: [changes, reuse]
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -273,3 +455,19 @@ jobs:
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
- name: Backend unit tests (pure + HA harness)
run: python -m pytest tests_backend/ -q
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
run: |
printf '%s\n' "backend прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
- uses: actions/cache/save@v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-backend-${{ needs.reuse.outputs.backend_key }}
+13 -4
View File
@@ -255,10 +255,15 @@ The exchange happens in **issue comments** — there is no local message bus. Ve
format:
```text
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → #… · Document: …
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → in-task | #… · Document: …
```
High blocks. Medium must become its own issue. Low is fixed or waived with a note
High blocks. A Medium finding INSIDE the task's scope is fixed within the task:
with no High findings the verdict is yellow, the author fixes it and the fix
passes another review cycle — no separate issue (owner's decision 2026-08-19,
#202: filing and servicing an issue costs far more than fixing in place). Only
a Medium finding OUTSIDE the scope becomes its own issue — foreign scope is
never patched from this branch. Low is fixed or waived with a note
in the review document. A yellow verdict is legitimate even when every acceptance
criterion passes, if the change does not solve the stated scenario or degrades a
neighbouring one.
@@ -359,8 +364,12 @@ fingerprint is a hard failure, not a warning.
**CI is pinned to an exact SHA.** The release gate accepts only a `completed
success` run for the candidate's SHA, not "the last green one"; a new push cancels
an unfinished Validate for the same branch. Jobs: `provenance`, `hacs`, `hassfest`,
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`.
an unfinished Validate for the same branch. Gate jobs, matching the actual
`validate.yml` (#191): `docs`, `provenance`, `process-gate`, `hacs`, `hassfest`,
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
went red after the #113 merge.
**"Verified" without a named command and its result is not evidence.**
+19 -9
View File
@@ -131,8 +131,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
Его задача — не согласиться, а найти, где ТЗ не выполнимо или не проверяемо.
- **Артефакт:** `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, вердикт
зелёный / жёлтый / красный. Лёгкий трек — комментарий в issue.
- **High-находки блокируют.** Medium/Low — либо правятся, либо становятся
отдельными issue со ссылкой; «оставили в тексте ревью» не считается закрытием.
- **High-находки блокируют.** Medium **в скоупе задачи** чинится в текущем
issue: без High это жёлтый вердикт, автор правит ТЗ, фикс проходит повторный
цикл. Medium **вне скоупа** — отдельный issue: чужой скоуп в этой задаче не
правится. «Оставили в тексте ревью» не считается закрытием ни для одной
(решение владельца 2026-08-19, #202: отдельный issue дороже правки на месте).
Low либо правится, либо снимается решением ревьюера с записью.
- **Выход:** «Готово к разработке» либо возврат в «ТЗ в работе» — не более
4 циклов (§4).
@@ -187,7 +191,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
коду с явной записью «проверено чтением, не исполнением».
- **High блокируют.** Medium **обязаны** превратиться в issue.
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
Medium **вне скоупа** — отдельный issue (#202).
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
4 циклов (§4).
@@ -231,8 +237,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
ревью-гейт.
7. **Ревью возвращает не более 4 раз.** Пятый заход — решение владельца: разделить,
отклонить или арбитраж (§4).
8. **High блокирует. Medium становится issue.** Low либо правится, либо снимается
решением ревьюера с записью в документе.
8. **High блокирует. Medium в скоупе чинится в текущем issue** (без High —
жёлтый вердикт и повторный цикл); Medium вне скоупа становится отдельным
issue (#202). Low либо правится, либо снимается решением ревьюера с записью
в документе.
9. **Скоуп не расширяется.** Всё найденное вне ТЗ — новый issue, а не попутная
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
@@ -445,7 +453,8 @@ issue #NN
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · цикл r<N>/<лимит> ·
High: N · Medium: N → #… · Документ: docs/reviews/…`
High: N · Medium: N → в задаче | #… · Документ: docs/reviews/…`
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору)
- **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>`
**Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору;
@@ -686,8 +695,8 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
```
Ревьюер — `anthropics/claude-code-action`. Он читает `docs/SCOPE.md`, `AGENTS.md`,
этот документ и тело issue, публикует разбор комментарием, заводит issue на каждую
Medium-находку, кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
этот документ и тело issue, публикует разбор комментарием, заводит issue на Medium-находки
вне скоупа задачи (#202), кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
структурированным JSON. **Метку переставляет отдельный детерминированный шаг по
вердикту, а не модель.**
@@ -811,7 +820,8 @@ Golden, браузерные смоки, performance и полный HA-харн
- принятие golden-эталонов ради зелёного CI или по частичному артефакту;
- закрытие issue до выпуска беты с зелёным CI;
- переоткрытие закрытого issue вместо нового бага;
- Medium-находки, оставленные как TODO в документе ревью;
- Medium-находки, оставленные как TODO в документе ревью: в скоупе — чинятся
в текущем issue, вне скоупа — становятся отдельным (#202);
- **параллельные бэклоги** в файлах (`BACKLOG-*.md`, «планы» в docs);
- ревью-документы вне репозитория;
- попутные правки «раз уж я здесь»;
+1 -1
View File
@@ -46,7 +46,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.65.0-beta.1"
VERSION = "1.65.0-beta.6"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
File diff suppressed because one or more lines are too long
+20 -3
View File
@@ -48,9 +48,11 @@ from .validation import (
validate_marker_controls,
validate_marker_light_entities,
validate_marker_value_badges,
validate_opening_passages,
validate_opening_passages, validate_partition_opening_hosts,
MarkerControlError,
OpeningPassageError,
PartitionOpeningHostError,
PartitionOpeningJambMarginError,
)
FORMAT = "houseplan-export"
@@ -264,7 +266,8 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
_pick_fields(
opening,
("id", "type", "x", "y", "angle", "length")
+ (() if opening.get("type") == "passage" else ("flip_h", "flip_v")),
+ (() if opening.get("type") == "passage" else ("flip_h", "flip_v"))
+ (("host",) if opening.get("host") else ()),
)
for opening in space.get("openings") or []
]
@@ -840,6 +843,16 @@ def build_space_merge(
for room in space.get("rooms") or []:
if room.get("open_to"):
room["open_to"] = [old_room_ids.get(str(value), str(value)) for value in room["open_to"]]
# Opening ownership is part of the same space-local id graph. Remap the
# nested reference together with the partition itself; otherwise the
# invariant validator correctly rejects a copied space whose host.id still
# names the source partition.
for opening in space.get("openings") or []:
host = opening.get("host") if isinstance(opening, dict) else None
if isinstance(host, dict) and host.get("kind") == "partition":
old_host_id = str(host.get("id"))
if old_host_id in id_map:
host["id"] = id_map[old_host_id]
space["id"] = new_space_id
space["title"] = _unique_title(
str(space.get("title") or old_space_id), current_config.get("spaces") or []
@@ -980,7 +993,11 @@ def build_space_merge(
validate_marker_light_entities(merged_config, current_config)
validate_marker_value_badges(merged_config, current_config)
validate_opening_passages(merged_config, current_config)
except (MarkerControlError, OpeningPassageError) as err:
validate_partition_opening_hosts(merged_config, current_config)
except (
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError,
) as err:
raise ImportFailure(err.code, str(err)) from err
try:
merged_layout = LAYOUT_SCHEMA(merged_layout)
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.65.0-beta.1"
"version": "1.65.0-beta.6"
}
+31 -3
View File
@@ -10,6 +10,7 @@ want to see where the cleanup has already been).
from __future__ import annotations
import asyncio
import math
import time
from typing import Any
@@ -24,11 +25,33 @@ import logging
_LOGGER = logging.getLogger(__name__)
TRAIL_CAP = 2000 # raw points per run before decimation
TRAIL_RESUME_GRACE_S = 30 * 60 # same-map stop/pause belongs to one cleanup
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
MOVING_STATES = {"cleaning", "returning", "on"}
def can_resume_trail_run(run: Any, map_id: str, now: float) -> bool:
"""Whether an ended current run may be reopened for this point.
Store timestamps are untrusted persisted data. Only finite JSON-number
timestamps and a non-negative inclusive grace interval are accepted;
malformed values and wall-clock rollback fail closed into a new run.
"""
if not isinstance(run, dict) or run.get("map_id") != map_id:
return False
ended = run.get("ended")
if (
isinstance(ended, bool)
or not isinstance(ended, (int, float))
or isinstance(now, bool)
or not isinstance(now, (int, float))
):
return False
elapsed = now - ended
return math.isfinite(elapsed) and 0 <= elapsed <= TRAIL_RESUME_GRACE_S
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
"""Map-id normalisation contract, shared with the frontend.
@@ -64,7 +87,10 @@ class TrailBook:
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
rec = self.data.setdefault(marker, {})
cur = rec.get("current")
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
resumed = bool(cur and can_resume_trail_run(cur, map_id, now))
if resumed:
cur["ended"] = None
if not cur or cur.get("ended") is not None or cur.get("map_id") != map_id:
# a new run begins: the old one becomes "previous" (and the one
# before it is forgotten — we keep exactly two, per the owner)
if cur:
@@ -73,7 +99,9 @@ class TrailBook:
rec["current"] = cur
pts: list[list[float]] = cur["points"]
if pts and pts[-1][0] == x and pts[-1][1] == y:
return False
# Clearing ended is observable state even if the source repeats
# the dock point: it must still reach Store and live cards.
return resumed
pts.append([x, y])
if len(pts) > TRAIL_CAP:
# decimate by two but never lose the freshest point
@@ -85,7 +113,7 @@ class TrailBook:
def end_run(self, marker: str, now: float) -> bool:
cur = (self.data.get(marker) or {}).get("current")
if cur and not cur.get("ended"):
if cur and cur.get("ended") is None:
cur["ended"] = now
return True
return False
+139
View File
@@ -46,6 +46,112 @@ class OpeningPassageError(ValueError):
)
class PartitionOpeningHostError(ValueError):
"""A write tried to strip explicit host identity from a surviving opening."""
code = "invalid_partition_opening_host"
class PartitionOpeningJambMarginError(ValueError):
"""A direct geometry write leaves no physical jamb at a wall endpoint."""
code = "invalid_partition_opening_jamb_margin"
def __init__(
self, space_id: str, opening_id: str, margin: float, margin_cm: float
) -> None:
self.space_id = space_id
self.opening_id = opening_id
self.margin = margin
self.margin_cm = margin_cm
super().__init__(
f"space={space_id}; opening={opening_id}; "
f"margin={margin:.12g}; margin_cm={margin_cm:.12g}"
)
# One normalized canvas width contains this many physical grid cells. Keep in
# sync with GRID_STEP_N/NORM_W in the frontend; it is a geometry scale, not a
# user setting.
NORMALIZED_CANVAS_CELLS = 240.0
def validate_partition_opening_hosts(
config: dict, previous: dict | None = None
) -> None:
"""Validate hosted-opening write deltas without rejecting legacy reads.
Deleting the opening together with its partition remains valid. Surviving
records keep their host, while new/direct geometry changes reserve half the
actual wall depth at both endpoints. Rigid host translation and unrelated
edits round-trip existing near-end records unchanged.
"""
old_spaces = {
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
}
for space in config.get("spaces") or []:
space_id = str(space.get("id", ""))
old_space = old_spaces.get(space_id)
old_openings = {
str(opening.get("id")): opening
for opening in (old_space or {}).get("openings") or []
}
partitions = {
str(partition.get("id")): partition
for partition in space.get("partitions") or []
}
old_partitions = {
str(partition.get("id")): partition
for partition in (old_space or {}).get("partitions") or []
}
for opening in space.get("openings") or []:
opening_id = str(opening.get("id", ""))
old = old_openings.get(opening_id)
if old and old.get("host") is not None and opening.get("host") is None:
raise PartitionOpeningHostError(
f"space={space_id}; opening={opening_id}; host removed"
)
host = opening.get("host")
if host is None:
continue
partition = partitions.get(str(host.get("id", "")))
if partition is None:
# SPACE_SCHEMA owns missing-host diagnostics.
continue
old_host = (old or {}).get("host")
old_partition = old_partitions.get(str((old_host or {}).get("id", "")))
ax, ay = partition["a"]
bx, by = partition["b"]
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
old_span = None
if old_partition is not None:
old_ax, old_ay = old_partition["a"]
old_bx, old_by = old_partition["b"]
old_span = ((old_bx - old_ax) ** 2 + (old_by - old_ay) ** 2) ** 0.5
strict = (
old is None
or old_host is None
or old_host.get("id") != host.get("id")
or old_host.get("t") != host.get("t")
or old.get("length") != opening.get("length")
or old_partition is None
or old_partition.get("cm") != partition.get("cm")
or abs(old_span - span) > 1e-9
)
if not strict:
continue
cell_cm = float(space.get("cell_cm", 5))
margin_cm = float(partition["cm"]) / 2
margin = margin_cm / cell_cm / NORMALIZED_CANVAS_CELLS
along = float(host["t"]) * span
half = float(opening["length"]) / 2
if (along - half < margin - 1e-9
or along + half > span - margin + 1e-9):
raise PartitionOpeningJambMarginError(
space_id, opening_id, margin, margin_cm
)
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
@@ -767,6 +873,15 @@ WALL_COLUMN_SCHEMA = vol.All(
_strict_wall_column,
)
PARTITION_OPENING_HOST_SCHEMA = vol.Schema(
{
vol.Required("kind"): vol.Equal("partition"),
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("t"): vol.All(_finite, vol.Range(min=0, max=1)),
},
extra=vol.PREVENT_EXTRA,
)
def _space_geometry_invariants(value: dict) -> dict:
"""All stored geometry shares ids; draft segments also have a space cap."""
@@ -784,6 +899,29 @@ def _space_geometry_invariants(value: dict) -> dict:
)
if draft_segments > MAX_DRAFT_SEGMENTS:
raise vol.Invalid("too many saved room-draft segments")
partitions = {
item.get("id"): item for item in value.get("partitions", []) if item.get("id")
}
hosted_intervals: dict[str, list[tuple[float, float]]] = {}
for opening in value.get("openings", []):
host = opening.get("host")
if host is None:
continue
partition = partitions.get(host["id"])
if partition is None:
raise vol.Invalid("partition opening host must exist in the same space")
ax, ay = partition["a"]
bx, by = partition["b"]
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
length = float(opening["length"])
along = float(host["t"]) * span
if length > span or along - length / 2 < -1e-9 or along + length / 2 > span + 1e-9:
raise vol.Invalid("partition opening must fit inside its host")
lo, hi = along - length / 2, along + length / 2
occupied = hosted_intervals.setdefault(host["id"], [])
if any(max(lo, old_lo) < min(hi, old_hi) - 1e-9 for old_lo, old_hi in occupied):
raise vol.Invalid("partition openings must not overlap")
occupied.append((lo, hi))
return value
@@ -849,6 +987,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
vol.Optional("invert"): bool,
vol.Optional("flip_h"): bool,
vol.Optional("flip_v"): bool,
vol.Optional("host"): PARTITION_OPENING_HOST_SCHEMA,
},
extra=vol.ALLOW_EXTRA,
)
+13 -4
View File
@@ -57,8 +57,9 @@ from .virtual_lights import (
from .registry_snapshot import import_registry_snapshot
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError, sanitize_filename,
validate_opening_passages,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
PartitionOpeningHostError, PartitionOpeningJambMarginError, sanitize_filename,
validate_opening_passages, validate_partition_opening_hosts,
validate_marker_controls, validate_marker_light_entities,
validate_marker_value_badges, valid_space_id,
)
@@ -1256,7 +1257,11 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
validate_marker_light_entities(msg["config"], data.get("config"))
validate_marker_value_badges(msg["config"], data.get("config"))
validate_opening_passages(msg["config"], data.get("config"))
except (MarkerControlError, OpeningPassageError) as err:
validate_partition_opening_hosts(msg["config"], data.get("config"))
except (
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError,
) as err:
connection.send_error(msg["id"], err.code, str(err))
return
# An internal plan url must name a file that exists. The card can pick a
@@ -1367,7 +1372,11 @@ 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"))
except (MarkerControlError, OpeningPassageError) as err:
validate_partition_opening_hosts(msg["config"], config_data.get("config"))
except (
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError,
) as err:
connection.send_error(msg["id"], err.code, str(err))
return
Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

+78 -61
View File
@@ -1,72 +1,89 @@
{
"schema": 1,
"matrixVersion": 24,
"acceptedAt": "2026-08-17T10:51:28.998Z",
"sourceFingerprint": "b73e5020215967d2d13e2a08ce303916e2bdbcbd45c94c40c6e56336a8097656",
"matrixVersion": 32,
"acceptedAt": "2026-08-19T20:33:45.393Z",
"sourceFingerprint": "c80008b7bfa2031d1b7f7931429102e39c8267c144c34d948a617b96dba5f40e",
"chromium": "151.0.7922.34",
"scenarios": {
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
"isometric-geometry-view-dark": "4816ec4b22211c73765f9fac81741df685202d21df6b0fbc1649b23357086da3",
"isometric-geometry-view-light": "8ec81fc4f254ae6ebab55bc5f0ef5325a4e5d327cd1fb53bf5f293f06fbec671",
"isometric-live-layers-dark": "c841269f672c7ad8b208a549cc87592bd26f2a9e226f793a2b79592b65ee54c6",
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
"isometric-touch-kiosk-dark": "fd2e74c5966b4adcf4e66021e2cef781873f9a2b58f16ad19d57fb451ba45dc9",
"isometric-large-warm-remount-dark": "0afc1069d334f10be2d0ed135ee0eb52e9272a1a1ad1224e00f05753b8789d0c",
"geometry-view-dark-fit": "3df272f6c3c3d20e9e375ea037f3dbb885657b94b0b29d0067505f4a73741237",
"geometry-view-light-fit": "a7f2c9667d9872dd84a37d5318fd238c9eabcc0f413017eb19e02204587b4e1a",
"washer-active-cycle-dark": "7597ffad91165f2f3cc9647e5081c03d4ffd722313bf74f8dcf5fe6dc52d1dcf",
"washer-idle-cycle-dark": "6b1de9786f4852659d2d453fa39340626a7944417f88c5a37355b62c458b6ac1",
"day-cycle-dawn-dark": "a573083c4ee21993cf2e482b60418a30e388a14117d056a2b0ea559a0c928039",
"day-cycle-day-dark": "6f24cd1d8669c9f3451c06ca5a5fd499d8f1ae2698a34f4a63ffee2e9e6b4330",
"day-cycle-dusk-dark": "80029577c25f8759090ee2550fe530a35c189806dd6fa26e887da4f5d1cf54a3",
"day-cycle-night-dark": "d855785914d3e11198d3e4671c1fbcad15104c95954bc1ed9b7366aa51aac65e",
"geometry-plan-editor-dark": "16364738754515e81e6d0ae13c0358db8c7f9d26c2f34dfde15f5855c6af13fa",
"plan-snap-endpoint-light": "c5f63ca2ca2706a062a2fc97e25670768662810bd7e0b251f0a9cfdbaf60c758",
"plan-snap-line-gaps-dark": "44808a816e62416b9c2c39a6e06cd4a8631860e178f246772ce8c7a46f98edd0",
"wall-junctions-plan-preview-light": "9e3a07da3e3ae1b2a95299f92b9500f347f0bfbd20d87429505b30769ee627c2",
"wall-junctions-plan-t-dark": "a4a958f20ed5f4b8d4e6bca9ebd1b289186c0cb48b48897a1624b49014f3ae5d",
"wall-junctions-view-dark": "73a64c65c8e77aa767bb7f401d68c61df5749e65e75273c85b0d6c72cb430766",
"isometric-wall-junctions-dark": "cb1e28f484b304ecda3e35f66e0c0429016d10a5a3022dcc9315a92eb537300f",
"opening-placement-door-thick-wall-dark": "395c03bbf5d968e83664fd6621f0ac25902e718022f2e92ffbcddb8ce629cf9c",
"geometry-devices-editor-dark": "a9e4846ce5453400b87e6ad3d575882bc23a07b59dc3eecb612ed872b0c871ec",
"geometry-decor-editor-dark": "435b36096bbb2996d56ff0af262ddebff4a727edd841b0fad9b8d4f507b987ac",
"tray-wide-selection-en": "06b0df980fd79e8bfc2957878966a11ae9d1a61e80ee870094dc60eae4de26bb",
"tray-wide-tool-ru": "e5a6b2057acd9af2c5417bf413778395112eb1ebecd784251c68c74a16b9dc5f",
"tray-medium-group-en": "5115910bc0f359ce91f361794a51ae1ae6a493203941d09166b412b696eda775",
"tray-medium-selection-ru": "4e5f235be8ed6296e136641d172e727a6a6b7a9d061f0928a96ccc1103c19f1f",
"tray-narrow-palette-en": "88b9846e4b451ed95b7ae7d2c3183a2ea191d7668768992a1364c6a7a53eb0c6",
"tray-narrow-tool-ru": "c4130715b3cb31c68619dfc706a3aa308e86edf272bfae6833b20666764ece2b",
"geometry-diagonal-45-opening-dark": "01206d25631c8fd09fa077932fbb5c1ee115b65ba76b38e6f7b09315dbbf6002",
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
"lighting-glow-sun-dark": "a98eee332f25a43c8d9d126c118c8cfea4ee73752b1060227548efedaf0efcdd",
"device-value-badge-positions-dark": "1ad43f2bd866733aa75c34de38fb97d469661799b540a8ae22150067d788cec8",
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
"isometric-geometry-view-dark": "9bc8eb0da8746bddc0b5245b337eef7477d4ce6fb39ac576cab1e49057fd837c",
"isometric-geometry-view-light": "144f1cc3107562bc252cdcb54165f2c92569f4cf3484554148a7fdb78de372ec",
"isometric-live-layers-dark": "5173bbd51f31cb7aff42dbe68dd63596a7f5436beabdf626b9159661fae5a8b7",
"isometric-no-borders-dark": "7bf00cd6c5832a72b85f891a3b3e029910d2ef48c33b286a907fddfd90db6e07",
"isometric-touch-kiosk-dark": "745482a797e236058e3ddd56cb5d85d700c9333a3aeb764333addeaba40cf6b0",
"isometric-large-warm-remount-dark": "5a0b0a27aae68c0da18a43237f366fbe41282f376aa3b75c097df928a0c64f05",
"geometry-view-dark-fit": "438817c56cd8f91ef63778a3d3d22a4ee1fb675064bc24bf613fb752d6c503d9",
"geometry-view-light-fit": "667d38fd55a946ea6930b8674c4036e75c261ba4f111f893367ae72ed19500fc",
"room-label-parity-view-dark": "8351de36e484a5932997a4ca45aa681cf28313a2e669eb9c967058871a2e4b67",
"room-label-parity-plan-dark": "e782f0386a6b1e75c7bb9f135e9ec01a9ff4b540f0384d518c7ea9f2198683ea",
"room-label-parity-view-light": "ac6d507c2868d4ab0eb772d42c45df89888b956c73759f2e8514b7976ec9fce6",
"room-label-parity-plan-light": "84afb43102cebb53bede8d2af2dac43506c0a7c69ca329df2cf5dc57ab3e1dbd",
"washer-active-cycle-dark": "9db6bea75edf51c851f557119519e44e5121c98d77d6e60b4c515c313117331b",
"washer-idle-cycle-dark": "5ecf8f0f298ee13b056d69cf49276215f231a518f8aae1c429bde1548c9f1d05",
"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": "a29d42ae49a0a513dc36e933bdd2b5f3cb97aa40b0e7ec897c602fe65a5a5fe8",
"wall-junctions-plan-preview-light": "cdb96edabd5b04a47e5026eed4255e06b802bd8ba7f0c7ba2cab66c4ae2f371a",
"wall-junctions-plan-t-dark": "ec2b29ca87bba9b41e4c22ce94d2c4fe06c6d9c0833152d3a6224371763ae6bc",
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
"junction-patch-resilience-plan-dark": "78f36b340606e239d36dcb4b28433694d4ed4cc9c632f28c9bb3ebdc78311790",
"junction-patch-resilience-view-dark": "d531f2a01def73fb074e55543c8543112af158df65f24dc549e0da5a23f1af40",
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
"opening-placement-door-thick-wall-dark": "c398f53391bc697e859ba389bac0ce586526d6f14c85f32d9a4b314bebe902ab",
"opening-placement-passage-thick-wall-dark": "98bb63f72886986d71d598fffcaa4fb8e7dce3e7f3a27b5afb6012f9eac4537c",
"opening-placement-passage-thick-wall-light": "2f19063a0e792c7bec92f3e4069739954dcdd79eb4933355a575aec5463b9ade",
"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": "eb5d093c4b1a96282afb7dd6079b0ce4724c93f14015e2e32b4e4522b4001366",
"openings-filled-tunnel-dark": "76d047978bf8a33f49b3d1eb385f4d16d2a0cb57e6cd6afcb31d17cb1c39fe40",
"openings-hidden-view-dark": "155ee4838ca1b603eb4ea8865e32f1265d2b83dc6c280190aaac93a526d3fe06",
"lighting-glow-sun-dark": "8d2365a82c1d33bcf15a14185dcd608ad6e43f600b4ca3f9014ceacb09996fbf",
"device-value-badge-positions-dark": "b8b7415e1e40256d328003badb21a8f1346c6b174a1b2680479243378a4b5ec2",
"device-icon-state-table-light": "5c0839b476052b50e48ccd4381941d1c94bf43f11d9d8368d3912395db156e4c",
"device-icon-state-table-dark": "98d56b5e810a96291529e3f64379fa9337dab20bad0fd0e4909a8822d2fdf8b8",
"lighting-sun-window-state-only-dark": "3bd581a23a2e0ebba58530db5182adea5cba6ec10bc032bee024415c19108a17",
"lighting-fill-light-axis-split-dark": "4f867528aeb9124229f81659876b03ff297a3a7a92bc57ffb32d5c24913c4938",
"lighting-fill-temp-axis-split-dark": "e0535b70701c9fe6753f74c943b9f288a0fb1a23a9cfc8590d4945e0a1724eb5",
"lighting-fill-lqi-axis-split-dark": "485ab183144913569ddc11154553ab4ac7db522ab188de74d652b717dc786d9c",
"lighting-temp-glow-dark": "ecaed039fb6aab4e1fdc9f1856c89e5f563219b8ff813877027737cecbeca41a",
"lighting-temp-glow-light": "5bc8a35aaa94c427d465d198f1cd5eecfdc704f5abeded9e407f32ce93aa7d32",
"lighting-custom-glow-dark": "899e334dfc0d3490ca291b7239bf7b88ca9d1ef3be199ddf347314bab1513f27",
"lighting-opaque-glow-two-doorways-dark": "413f5a8e39193ba941f72955a391ccac954c09f24322b7bf67494c7691275980",
"lighting-custom-glow-light": "266bba4ae1744a884b2cd224b37fdc36447d57405a1c5298ca30027e2957cc8a",
"lighting-temp-glow-no-sources-dark": "5100c81543fc30a7934a6db6f9e67e2c4fa185df0a974be5911879e43c0d3fc9",
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
"hover-nested-room-dark": "6c09526ad885c4555063def5b43287b41124a81d90972c425084dba6e622d055",
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
"device-dialog-desktop-en": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
"decor-color-popover-mobile-ru": "46d4c2e4dd20c3a38e90efe3db59b3e878bdbcf273fbc1aa23de4b230723fa6e",
"backup-full-preview-desktop-en": "cc42a621f55f043b272014b9c32127823ca3573520e502966c71642027f7fdaf",
"backup-plan-only-export-desktop-en": "2833ee45acac2936e76546e9ff5d0031932c02fbf66fe904c1be94dc3e33dc05",
"lighting-fill-light-axis-split-dark": "48d9de651a7974e34e21a7eaa66416f7fff8f2f2b56afd35918a9e0f8eda1152",
"lighting-fill-temp-axis-split-dark": "890e0f168ca71e505b82b62620d0d25fe2848d7cc15d58b0b6b42e34f59950f9",
"lighting-fill-lqi-axis-split-dark": "d808545d529e2179282662c8ef1a258d90a28c71bd90daa6e5b4fdf1e99cd5d2",
"lighting-temp-glow-dark": "07bdcc9028f502e46d3c7d899d0ebc7ee55a8215f76592ebb584db7be7a486b2",
"lighting-temp-glow-light": "0f15bf8a6222130916b95fa2cc97d62ee39020266b0010026212451f5ca5eded",
"lighting-custom-glow-dark": "8ae8fcafd8913fc70c71d3634feae914963e3ceb918becc92c1e5b7875b24fe9",
"lighting-opaque-glow-two-doorways-dark": "288880fab3b7bac3c8664e0d4eb3743ff416be23630ecf5d2c8365093abc208b",
"lighting-custom-glow-light": "2f4c027bae8da9ac7f87a8ef99af26f9ad1dbde062a3c041582aaffdeb3ed3e8",
"lighting-temp-glow-no-sources-dark": "e188aaab4e019b9f18c72b1e2e4dc2fd2ed3d48f956fb1ba97d080ec2053eedb",
"lighting-temp-glow-room-override-dark": "f5b1abf84ca35da41302f72ccb333c168de0c602451f700e5769dd382f035731",
"lighting-manual-auto-spill-overlap-dark": "18494c809465c26d03286d41326ff10274cad490a3babe415cfe3a462aebd80d",
"hover-over-glow-dark": "8758d1525a8a2d1eef42e7f01ad121a233dab1e422b60af1a46a13955312571d",
"hover-nested-room-dark": "2db78e53a76fa9b7cdc4597f23fc2c8ae439e5a56d82895109ca968d73075640",
"large-house-zoom-040-dark": "e94562031dab35d9aa9cb6b50c8cc7e04855db4e9aeee735222284d450ed254d",
"large-house-zoom-250-dark": "493f5d361504b7653168ab0bb41c592b72db22013eb9ed0e1fb5c4688f8e9830",
"large-house-warm-remount-dark": "01ada4b9d8ea5daef221b5b95e1c98296a155cf1b51c3c1915c667cf4d485830",
"device-dialog-desktop-en": "01fde8cd44f0b229e8416f3e89e9fc8985a5ab83451a8ba172f5ed5724e0c1c6",
"device-dialog-mobile-ru": "c90be98e65c37963fd4443e413848ca68a412c9ee568d648aba76bf9232ee59d",
"toggle-entity-dialog-desktop-en": "80ac0d11106990707ab5624193d1180161b5436bde0e55a6eb61c71e382d88c2",
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
"device-help-popover-light-ru": "9c3e3a9b498c4169c8aee8b2595d017a4b5c78822ad983198ca55dd856672566",
"decor-color-popover-mobile-ru": "af002e4c3106c008bbdb58ed1f00d8f8415dc86a8dfab2552defb4d16d8bbe71",
"decor-color-popover-desktop-en": "50f8816484d1767d8c0eabf117151a7ff2be75272b3509d9a13221855ad02b72",
"general-color-popover-desktop-en": "da85d0159802afce90fd87ecf940ef1b97ffd57c27fbdc79469cc11082b9a482",
"device-ripple-color-popover-mobile-ru": "3ab12c339106004c76fe3c7741d242269e157c40ddfa29afd8f13b445b0a7023",
"space-room-color-popover-desktop-ru": "aac2b1c6ba7126d5e0942c8f3e3c62d378b3b39bcf8eb2f61dd8e28efeb983c4",
"backup-full-preview-desktop-en": "6f0cfecf587b73f38088d414f489b67cc68ed71c37c96d4e307e66f6d97bda9f",
"backup-plan-only-export-desktop-en": "1e1c8a9cc5383394b91e24af8342d92103a5e85b3e5c9b53c45ab0e9b0d57da4",
"backup-space-preview-mobile-ru": "a4719cbe29b378ef7baa63bb7ff23e201b2943025008d939f2c08cee63bb9038"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

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

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 163 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: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 KiB

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

After

Width:  |  Height:  |  Size: 357 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: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

+140 -4
View File
@@ -1,5 +1,10 @@
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
import { readFileSync } from 'node:fs';
const junctionPatchFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/197-junction-patch.json', import.meta.url), 'utf8',
));
const fixtureFor = (scenario) => scenario.fixture === 'large'
? makeLargeHouseFixture()
@@ -139,11 +144,43 @@ export function prepareGoldenFixture(scenario) {
wall_columns: [],
});
}
if (scenario.junctionPatchResilience) {
fixture.config.spaces.push({
...structuredClone(junctionPatchFixture),
id: scenario.space,
title: 'Junction patch resilience',
view_box: [0, 0, 1, 1],
settings: {
...(junctionPatchFixture.settings || {}),
fill_mode: 'none',
show_borders: true,
show_names: false,
},
});
}
const requireSpace = () => {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
return space;
};
if (scenario.roomLabelParity) {
const space = requireSpace();
if (space.id !== 'golden-lighting' || space.rooms.length !== 2)
throw new Error(`golden roomLabelParity requires the two-room lighting fixture: ${space.id}`);
space.settings = {
...(space.settings || {}),
show_names: true,
label_temp: true,
label_hum: true,
label_lqi: true,
label_light: true,
};
fixture.layout = {
...(fixture.layout || {}),
'rl_light-left': { s: space.id, x: 0.28, y: 0.24 },
'rl_light-right': { s: space.id, x: 0.72, y: 0.24 },
};
}
if (scenario.deviceName) {
if (!scenario.deviceId || !fixture.devices?.[scenario.deviceId])
throw new Error(`golden deviceName references missing device: ${scenario.deviceId || '<empty>'}`);
@@ -276,9 +313,13 @@ export function prepareGoldenFixture(scenario) {
export async function prepareGoldenScenario(page, scenario) {
await stableEnvironment(page, scenario);
// Scenarios share one Playwright page, including its pointer position. A
// scenario that deliberately hovers a marker must not leave the next one
// capturing an unrelated room hover at the same viewport coordinates.
await page.mouse.move(0, 0);
const fixture = prepareGoldenFixture(scenario);
return page.evaluate(async ({ fixture, scenario }) => {
const result = await page.evaluate(async ({ fixture, scenario }) => {
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
@@ -380,6 +421,14 @@ export async function prepareGoldenScenario(page, scenario) {
await card.updateComplete;
await settleMode(card);
}
if (scenario.roomLabelParity) {
const labels = [...card.renderRoot.querySelectorAll('.roomlabel')];
if (labels.length !== 2
|| labels.some((label) => !label.querySelector('.rlgo') || !label.querySelector('.rlmetrics'))
|| labels.some((label) => !label.querySelector('.rlmetrics')?.textContent?.trim())) {
throw new Error(`golden room-label parity core is incomplete: ${scenario.id}`);
}
}
if (scenario.projection === 'iso' && typeof card._setProjection === 'function') {
card._setProjection('iso');
await card.updateComplete;
@@ -416,7 +465,7 @@ export async function prepareGoldenScenario(page, scenario) {
const { tool, anchor, pointer, expectedKind } = scenario.planSnap;
const validPoint = (point) => Array.isArray(point) && point.length === 2
&& point.every(Number.isFinite);
if (!['draw', 'partition'].includes(tool) || !validPoint(pointer)
if (tool !== 'draw' || !validPoint(pointer)
|| (anchor != null && !validPoint(anchor))
|| !['endpoint', 'line'].includes(expectedKind)) {
throw new Error(`invalid golden planSnap contract: ${scenario.id}`);
@@ -447,7 +496,7 @@ export async function prepareGoldenScenario(page, scenario) {
}
if (scenario.openingPreview) {
const { type, pointer } = scenario.openingPreview;
if (!['window', 'door', 'gate'].includes(type)
if (!['window', 'door', 'passage', 'gate'].includes(type)
|| !Array.isArray(pointer) || pointer.length !== 2
|| !pointer.every(Number.isFinite)) {
throw new Error(`invalid golden openingPreview: ${scenario.id}`);
@@ -471,7 +520,11 @@ export async function prepareGoldenScenario(page, scenario) {
await card.updateComplete;
await frame();
const preview = card.renderRoot.querySelector(`.opening-preview[data-kind="${type}"]`);
if (!preview || !preview.querySelector('.op-leaf')) {
const expectedGeometry = type === 'passage'
? !!preview?.querySelector('.passage-preview-cut')
&& preview.querySelectorAll('.passage-preview-boundary').length === 2
: !!preview?.querySelector('.op-leaf');
if (!preview || !expectedGeometry) {
const intervals = card._openingPlacementIntervalsCache?.value || [];
const nearest = intervals.map((interval) => {
const [px, py] = card._cursorPt || [0, 0];
@@ -663,6 +716,68 @@ export async function prepareGoldenScenario(page, scenario) {
if (!trigger) throw new Error('golden decor color trigger missing');
trigger.click();
await picker.updateComplete;
} else if (scenario.dialog === 'general-color') {
card._openSettingsDialog();
await card.updateComplete;
const dialog = card.renderRoot.querySelector('hp-dialog');
const picker = [...(dialog?.querySelectorAll('hp-color-opacity') || [])]
.find((item) => item.label === card._t('gs.light_on'));
await picker?.updateComplete;
const trigger = picker?.renderRoot?.querySelector('.trigger');
if (!trigger) throw new Error('golden general-settings color trigger missing');
trigger.scrollIntoView({ block: 'center' });
await frame();
trigger.click();
await picker.updateComplete;
} else if (scenario.dialog === 'device-ripple-color') {
card._setMode('devices');
await card.updateComplete;
await settleMode(card);
const device = card._devices.find((item) => item.id === scenario.deviceId);
if (!device) throw new Error(`golden ripple device missing: ${scenario.deviceId}`);
card._openMarkerDialog(device);
card._markerDialog = { ...card._markerDialog, display: 'icon_ripple' };
card.requestUpdate();
await card.updateComplete;
const dialog = card.renderRoot.querySelector('hp-dialog');
const picker = [...(dialog?.querySelectorAll('hp-color-opacity') || [])]
.find((item) => item.label === card._t('marker.activity_color'));
await picker?.updateComplete;
const trigger = picker?.renderRoot?.querySelector('.trigger');
if (!trigger) throw new Error('golden ripple color trigger missing');
trigger.scrollIntoView({ block: 'center' });
await frame();
trigger.click();
await picker.updateComplete;
} else if (scenario.dialog === 'space-room-color') {
card._openSpaceDialog('edit', scenario.space);
await card.updateComplete;
const dialog = card.renderRoot.querySelector('hp-dialog');
const picker = [...(dialog?.querySelectorAll('hp-color-opacity') || [])]
.find((item) => item.label === card._t('space.room_color'));
await picker?.updateComplete;
const trigger = picker?.renderRoot?.querySelector('.trigger');
if (!trigger) throw new Error('golden space room-color trigger missing');
trigger.scrollIntoView({ block: 'center' });
await frame();
trigger.click();
await picker.updateComplete;
}
if (scenario.deviceClassOverrides) {
for (const [id, classes] of Object.entries(scenario.deviceClassOverrides)) {
const marker = card.renderRoot.querySelector(`[data-hp="device"][data-id="${CSS.escape(id)}"]`);
if (!marker || !Array.isArray(classes) || !classes.length) {
throw new Error(`invalid golden device class override: ${scenario.id}/${id}`);
}
marker.classList.add(...classes);
}
}
if (scenario.focusDevice) {
const marker = card.renderRoot.querySelector(
`[data-hp="device"][data-id="${CSS.escape(scenario.focusDevice)}"]`,
);
if (!marker) throw new Error(`golden focus device missing: ${scenario.focusDevice}`);
marker.focus({ focusVisible: true });
}
if (scenario.dayCycle) {
const environment = card.renderRoot.querySelector('.hp-day-cycle-env');
@@ -697,6 +812,27 @@ export async function prepareGoldenScenario(page, scenario) {
} } : {}),
};
}, { fixture, scenario });
if (scenario.hoverDevice) {
const point = await page.evaluate((id) => {
const marker = window.__goldenCard?.renderRoot?.querySelector(
`[data-hp="device"][data-id="${CSS.escape(id)}"]`,
);
if (!marker) return null;
const rect = marker.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}, scenario.hoverDevice);
if (!point) throw new Error(`golden hover device missing: ${scenario.hoverDevice}`);
await page.mouse.move(point.x, point.y);
if (scenario.hideHoverTooltip) {
await page.evaluate(async () => {
const card = window.__goldenCard;
card._tip = null;
card.requestUpdate();
await card.updateComplete;
});
}
}
return result;
}
export async function goldenClip(page, capture) {
+86 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 26;
export const GOLDEN_MATRIX_VERSION = 32;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -44,6 +44,14 @@ export const GOLDEN_SCENARIOS = Object.freeze([
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-view-light-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'room-label-parity-view-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
roomLabelParity: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...page },
{ id: 'room-label-parity-plan-dark', fixture: 'visual', space: 'golden-lighting', mode: 'plan',
roomLabelParity: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...page },
{ id: 'room-label-parity-view-light', fixture: 'visual', space: 'golden-lighting', mode: 'view',
roomLabelParity: true, theme: 'light', viewport: { width: 1000, height: 900 }, ...page },
{ id: 'room-label-parity-plan-light', fixture: 'visual', space: 'golden-lighting', mode: 'plan',
roomLabelParity: true, theme: 'light', viewport: { width: 1000, height: 900 }, ...page },
{ id: 'washer-active-cycle-dark', fixture: 'visual', applianceLifecycle: true,
space: 'golden-appliance', mode: 'view',
stateOverrides: { 'sensor.golden_washer_status': { state: 'start' } },
@@ -93,6 +101,12 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'wall-junctions-view-dark', fixture: 'visual',
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'junction-patch-resilience-plan-dark', fixture: 'visual',
space: 'golden-junction-patch-resilience', junctionPatchResilience: true, mode: 'plan',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'junction-patch-resilience-view-dark', fixture: 'visual',
space: 'golden-junction-patch-resilience', junctionPatchResilience: true, mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-wall-junctions-dark', fixture: 'visual',
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
@@ -104,6 +118,14 @@ export const GOLDEN_SCENARIOS = Object.freeze([
mode: 'plan', openingPreview: { type: 'door', pointer: [0.48, 0.65] },
openingPreviewPixels: { minPixels: 150, minInsideWallPixels: 8, minChannelDelta: 4 },
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'opening-placement-passage-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
mode: 'plan', openingPreview: { type: 'passage', pointer: [0.48, 0.65] },
openingPreviewPixels: { minPixels: 150, minInsideWallPixels: 8, minChannelDelta: 4 },
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'opening-placement-passage-thick-wall-light', fixture: 'visual', space: 'golden-geometry',
mode: 'plan', openingPreview: { type: 'passage', pointer: [0.48, 0.65] },
openingPreviewPixels: { minPixels: 150, minInsideWallPixels: 8, minChannelDelta: 4 },
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'geometry-decor-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
@@ -175,6 +197,57 @@ export const GOLDEN_SCENARIOS = Object.freeze([
'golden-climate': { s: 'golden-lighting', x: 0.80, y: 0.28 },
},
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
...['light', 'dark'].map((theme) => ({
id: `device-icon-state-table-${theme}`, fixture: 'visual', space: 'golden-lighting', mode: 'view',
glowEnabled: false, sunRays: false,
markerOverrides: [
{ id: 'golden-light-one', binding: 'device:golden-light-one' },
{ id: 'golden-light-two', binding: 'device:golden-light-two' },
{ id: 'golden-light-three', binding: 'device:golden-light-three' },
{ id: 'golden-presence', binding: 'device:golden-presence' },
{ id: 'golden-climate', binding: 'device:golden-climate' },
{ id: 'golden-left-temperature', binding: 'device:golden-left-temperature', display: 'value' },
{ id: 'golden-right-temperature', binding: 'device:golden-right-temperature', display: 'static_icon' },
{ id: 'golden-left-linkquality', binding: 'device:golden-left-linkquality', value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: 'sensor.golden_left_linkquality' },
position: 'right',
} },
{ id: 'golden-right-linkquality', binding: 'device:golden-right-linkquality', value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: 'sensor.golden_right_linkquality' },
position: 'bottom',
} },
],
stateOverrides: {
'light.golden_light_one': { attributes: { lqi: 40 } },
'light.golden_light_two': { attributes: { lqi: 41 } },
'light.golden_light_three': { attributes: { lqi: 180 } },
'sensor.golden_left_temperature': { state: 'Complete long localized state' },
'sensor.golden_right_temperature': { state: 'unavailable' },
},
layoutOverrides: {
'golden-light-one': { s: 'golden-lighting', x: 0.16, y: 0.25 },
'golden-light-two': { s: 'golden-lighting', x: 0.34, y: 0.25 },
'golden-light-three': { s: 'golden-lighting', x: 0.66, y: 0.25 },
'golden-presence': { s: 'golden-lighting', x: 0.84, y: 0.25 },
'golden-climate': { s: 'golden-lighting', x: 0.16, y: 0.72 },
'golden-left-temperature': { s: 'golden-lighting', x: 0.34, y: 0.72 },
'golden-right-temperature': { s: 'golden-lighting', x: 0.66, y: 0.72 },
'golden-left-linkquality': { s: 'golden-lighting', x: 0.77, y: 0.55 },
'golden-right-linkquality': { s: 'golden-lighting', x: 0.84, y: 0.72 },
},
deviceClassOverrides: {
'golden-light-two': ['lock-locked'],
'golden-light-three': ['lock-unlocked'],
'golden-presence': ['virtual'],
'golden-climate': ['alarm', 'sel'],
},
focusDevice: 'golden-climate',
hoverDevice: 'golden-presence',
hideHoverTooltip: true,
theme, viewport: { width: 1000, height: 900 }, ...stage,
})),
{ id: 'lighting-sun-window-state-only-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
// The golden screenshot is backed by a second, sun-layer-hidden capture.
// A real painted ray must account for enough changed pixels; DOM-only
@@ -269,6 +342,18 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'decor-color-popover-mobile-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'decor-color', language: 'ru', theme: 'dark',
viewport: { width: 390, height: 760 }, ...page },
{ id: 'decor-color-popover-desktop-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'decor-color', language: 'en', theme: 'light',
viewport: { width: 760, height: 820 }, ...page },
{ id: 'general-color-popover-desktop-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'general-color', language: 'en', theme: 'light',
viewport: { width: 900, height: 900 }, ...page },
{ id: 'device-ripple-color-popover-mobile-ru', fixture: 'visual', space: 'golden-lighting',
dialog: 'device-ripple-color', deviceId: 'golden-light-two',
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
{ id: 'space-room-color-popover-desktop-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'space-room-color', language: 'ru', theme: 'dark',
viewport: { width: 900, height: 1000 }, ...page },
{ id: 'backup-full-preview-desktop-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'backup-full', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 900 }, ...page },
+5 -1
View File
@@ -63,7 +63,11 @@ const res = await page.evaluate(async () => {
};
c._openSettingsDialog();
await c.updateComplete;
out.dialogHasBgRow = !!sr().querySelector('hp-dialog .colorrow input[type=color]');
const settingsDialog = sr().querySelector('hp-dialog');
const bgPicker = [...settingsDialog.querySelectorAll('hp-color-opacity')]
.find((picker) => picker.label === c._t('gs.bg_color'));
out.dialogHasBgRow = !!bgPicker && bgPicker.showOpacity === false
&& !settingsDialog.querySelector('input[type=color]');
c._settingsDialog = { ...c._settingsDialog, bgColor: '#0a2a4a' };
out.dialogPreviews = (await stageBg()) === rgb('#0a2a4a');
await c._saveSettingsDialog();
+152
View File
@@ -0,0 +1,152 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(
{ width: 390, height: 760 }, 1, [], { hasTouch: true, isMobile: true },
);
const result = await page.evaluate(async () => {
const out = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const frame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (card._modeTransitionBusy && performance.now() - started < 1500);
await card.updateComplete;
};
[...root().querySelectorAll('.modetab')][2]?.click();
await settleMode();
card._decorTool = 'line';
card.requestUpdate();
await card.updateComplete;
const picker = root().querySelector('.editor-secondary hp-color-opacity');
const trigger = picker?.shadowRoot?.querySelector('.trigger');
const events = [];
picker?.addEventListener('hp-color-opacity-change', (event) => events.push(event.detail));
trigger?.click();
await picker?.updateComplete;
await frame();
const surface = () => picker?.shadowRoot?.querySelector('.picker');
const ranges = () => [...(surface()?.querySelectorAll('input[type="range"]') || [])];
out.oneSurfaceNoNativePicker = !!surface()
&& surface().getAttribute('role') === 'dialog'
&& !surface().querySelector('input[type="color"]');
out.allDimensionsVisible = !!surface()?.querySelector('.sv-field')
&& ranges().length === 4
&& !!surface()?.querySelector('input[type="text"]')
&& !!surface()?.querySelector('input[type="number"]');
out.englishLabels = surface()?.getAttribute('aria-label') === 'Color picker'
&& ranges().map((input) => input.getAttribute('aria-label')).join('|')
=== 'Hue|Saturation|Brightness|Opacity';
const rect = surface()?.getBoundingClientRect();
out.narrowViewportFits = !!rect && rect.left >= 0 && rect.right <= innerWidth
&& rect.top >= 0 && rect.bottom <= innerHeight
&& surface().scrollWidth <= surface().clientWidth;
const hue = ranges()[0];
const hueStyle = getComputedStyle(hue);
out.hueTrackContract = hue?.classList.contains('hue-range')
&& hue.min === '0' && hue.max === '359' && hue.step === '1'
&& hueStyle.getPropertyValue('--hp-picker-hue-track').includes('linear-gradient')
&& hue.getBoundingClientRect().height >= 40;
const thumbShadow = hueStyle.getPropertyValue('--hp-picker-hue-thumb-shadow');
out.hueThumbContrastContract = thumbShadow.includes('0 0 0 2px')
&& thumbShadow.includes('0 0 0 3px');
ranges()[1].value = '100';
ranges()[1].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
ranges()[2].value = '100';
ranges()[2].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
hue.value = '120';
hue.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await picker.updateComplete;
out.hueUpdatesSharedDraft = picker.color === '#00ff00'
&& card._decorStyle.color === '#00ff00';
const beforeShift = Number(ranges()[0].value);
ranges()[0].dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowRight', shiftKey: true, bubbles: true, composed: true, cancelable: true,
}));
await picker.updateComplete;
out.shiftArrowUsesTenStep = Math.round(picker._hue) === beforeShift + 10;
const opacity = ranges().at(-1);
opacity.value = '37';
opacity.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await picker.updateComplete;
out.opacitySharesTheSameSurface = picker.opacity === 0.37
&& card._decorStyle.opacity === 0.37;
let hex = surface().querySelector('input[type="text"]');
hex.value = '#0af';
hex.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await picker.updateComplete;
out.shortHexNormalizesAndEmits = picker.color === '#00aaff'
&& events.at(-1)?.color === '#00aaff';
const eventCount = events.length;
hex = surface().querySelector('input[type="text"]');
hex.value = '#12';
hex.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
hex.dispatchEvent(new FocusEvent('blur'));
await picker.updateComplete;
hex = surface().querySelector('input[type="text"]');
out.invalidHexNeverEmits = events.length === eventCount
&& hex.value === '#00aaff' && hex.getAttribute('aria-invalid') === 'true';
const field = surface().querySelector('.sv-field');
const fieldRect = field.getBoundingClientRect();
const eventsBeforePointer = events.length;
field.dispatchEvent(new PointerEvent('pointerdown', {
pointerId: 41, pointerType: 'touch', clientX: fieldRect.left + fieldRect.width * .25,
clientY: fieldRect.top + fieldRect.height * .25, bubbles: true, composed: true, cancelable: true,
}));
field.dispatchEvent(new PointerEvent('pointermove', {
pointerId: 41, pointerType: 'touch', clientX: fieldRect.right + 20,
clientY: fieldRect.bottom + 20, bubbles: true, composed: true, cancelable: true,
}));
field.dispatchEvent(new PointerEvent('pointercancel', {
pointerId: 41, pointerType: 'touch', bubbles: true, composed: true, cancelable: true,
}));
await frame();
out.pointerCancelAddsNoEvent = events.length === eventsBeforePointer + 1
&& events.at(-1)?.color === picker.color
&& picker._activePointerId === null;
picker.showOpacity = false;
await picker.updateComplete;
out.colorOnlyKeepsColorControls = ranges().length === 3
&& !surface().querySelector('input[type="number"]');
picker.showOpacity = true;
await picker.updateComplete;
card._config = { ...card._config, language: 'ru' };
card.requestUpdate();
await card.updateComplete;
await picker.updateComplete;
out.cardLanguageOwnsCopy = surface()?.getAttribute('aria-label') === 'Выбор цвета'
&& surface()?.querySelector('input[type="text"]')?.getAttribute('aria-label') === 'Цвет HEX';
const escapeControl = surface()?.querySelector('input[type="range"]');
escapeControl?.focus();
escapeControl?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
await picker.updateComplete;
await frame();
out.escapeClosesFirstAndRefocuses = trigger?.getAttribute('aria-expanded') === 'false'
&& picker.shadowRoot.activeElement === trigger;
picker.disabled = true;
await picker.updateComplete;
trigger?.click();
await picker.updateComplete;
out.disabledDoesNotOpen = trigger?.getAttribute('aria-expanded') === 'false';
return out;
});
checkAll(result);
await finish(browser, result);
+123
View File
@@ -0,0 +1,123 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 390, height: 1000 });
const result = await page.evaluate(async () => {
const out = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const frame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const change = (picker, detail) => picker.dispatchEvent(new CustomEvent(
'hp-color-opacity-change', { detail, bubbles: true, composed: true },
));
const pickerByLabel = (dialog, label) => [...dialog.querySelectorAll('hp-color-opacity')]
.find((picker) => picker.label === label);
const nativeColors = () => root().querySelectorAll('input[type="color"]').length;
card._openSettingsDialog();
card._settingsDialog = { ...card._settingsDialog, bgMode: 'static', bgColor: null };
card.requestUpdate();
await card.updateComplete;
let dialog = root().querySelector('hp-dialog');
const generalPickers = [...dialog.querySelectorAll('hp-color-opacity')];
const lightOn = pickerByLabel(dialog, card._t('gs.light_on'));
const tempCold = pickerByLabel(dialog, card._t('gs.temp_cold'));
const globalBg = pickerByLabel(dialog, card._t('gs.bg_color'));
out.generalSettingsUsesTwelveUnifiedPickers = generalPickers.length === 12
&& generalPickers.filter((picker) => picker.showOpacity).length === 11
&& globalBg?.showOpacity === false
&& nativeColors() === 0;
const previousTemp = { ...card._settingsDialog.colors.temp_cold };
change(lightOn, { color: '#123456', opacity: 0.37 });
await card.updateComplete;
out.generalColorAndOpacityStayAtomic = card._settingsDialog.colors.light_on.c === '#123456'
&& card._settingsDialog.colors.light_on.a === 0.37
&& JSON.stringify(card._settingsDialog.colors.temp_cold) === JSON.stringify(previousTemp);
lightOn.renderRoot.querySelector('.trigger').click();
await lightOn.updateComplete;
await frame();
tempCold.renderRoot.querySelector('.trigger').click();
await tempCold.updateComplete;
await frame();
out.generalPickersAreExclusive = lightOn.renderRoot.querySelector('.trigger')
?.getAttribute('aria-expanded') === 'false'
&& tempCold.renderRoot.querySelector('.trigger')?.getAttribute('aria-expanded') === 'true';
tempCold.renderRoot.querySelector('.trigger').click();
await tempCold.updateComplete;
const originalGlobalBg = card._settingsDialog.bgColor;
globalBg.renderRoot.querySelector('.trigger').click();
await globalBg.updateComplete;
globalBg.renderRoot.querySelector('.trigger').click();
await globalBg.updateComplete;
out.globalBackgroundOpenCloseDoesNotMaterialize = originalGlobalBg === null
&& card._settingsDialog.bgColor === null;
change(globalBg, { color: '#0a2a4a', opacity: 0.12 });
await card.updateComplete;
dialog = root().querySelector('hp-dialog');
const defaultButton = [...dialog.querySelectorAll('button')]
.find((button) => button.textContent.trim() === card._t('gs.bg_default'));
const explicitGlobalBg = card._settingsDialog.bgColor;
defaultButton?.click();
await card.updateComplete;
out.globalBackgroundIgnoresAlphaAndResets = explicitGlobalBg === '#0a2a4a'
&& card._settingsDialog.bgColor === null;
card._settingsDialog = null;
card._setMode('devices');
card._openMarkerDialog();
card._markerDialog = { ...card._markerDialog, display: 'icon_ripple', rippleColor: '', rippleSize: 4.5 };
card.requestUpdate();
await card.updateComplete;
dialog = root().querySelector('hp-dialog');
const ripple = pickerByLabel(dialog, card._t('marker.activity_color'));
out.rippleUsesColorOnlyPicker = !!ripple && ripple.showOpacity === false
&& ripple.color === '#3ea6ff' && nativeColors() === 0;
const rippleLabelBox = ripple?.renderRoot.querySelector('.label')?.getBoundingClientRect();
const rippleSizeBox = dialog.querySelector('.ripple-sizerow .opl')?.getBoundingClientRect();
out.rippleLabelsDoNotOverlapOnMobile = !!rippleLabelBox && !!rippleSizeBox
&& (rippleLabelBox.bottom <= rippleSizeBox.top || rippleLabelBox.right <= rippleSizeBox.left);
const rippleSize = card._markerDialog.rippleSize;
change(ripple, { color: '#abcdef', opacity: 0.05 });
await card.updateComplete;
out.rippleChangeLeavesSizeAndAlphaModelAlone = card._markerDialog.rippleColor === '#abcdef'
&& card._markerDialog.rippleSize === rippleSize
&& !Object.hasOwn(card._markerDialog, 'rippleOpacity');
card._markerDialog = null;
card._setMode('view');
card._openSpaceDialog('edit', card._space);
card._spaceDialog = { ...card._spaceDialog, bgMode: 'static', bgColor: null };
card.requestUpdate();
await card.updateComplete;
dialog = root().querySelector('hp-dialog');
const roomColor = pickerByLabel(dialog, card._t('space.room_color'));
let spaceBg = pickerByLabel(dialog, card._t('space.bg_color'));
out.spaceUsesOpacityAndColorOnlyModes = roomColor?.showOpacity === true
&& spaceBg?.showOpacity === false && nativeColors() === 0;
change(roomColor, { color: '#fedcba', opacity: 0.63 });
await card.updateComplete;
out.roomColorAndOpacityStayAtomic = card._spaceDialog.roomColor === '#fedcba'
&& card._spaceDialog.roomOpacity === 0.63;
const serverSpaceBefore = JSON.stringify(card._serverCfg.spaces.find((space) => space.id === card._space));
spaceBg = pickerByLabel(root().querySelector('hp-dialog'), card._t('space.bg_color'));
change(spaceBg, { color: '#102030', opacity: 0.2 });
await card.updateComplete;
dialog = root().querySelector('hp-dialog');
const inheritButton = [...dialog.querySelectorAll('button')]
.find((button) => button.textContent.trim() === card._t('space.bg_inherit'));
const explicitSpaceBg = card._spaceDialog.bgColor;
inheritButton?.click();
await card.updateComplete;
card._spaceDialog = null;
await card.updateComplete;
out.spaceBackgroundIgnoresAlphaAndCancelPersistsNothing = explicitSpaceBg === '#102030'
&& JSON.stringify(card._serverCfg.spaces.find((space) => space.id === card._space)) === serverSpaceBefore;
out.noNativeColorInputInAnyMigratedDialog = nativeColors() === 0;
return out;
});
checkAll(result);
await finish(browser, result);
+19 -10
View File
@@ -7,9 +7,10 @@
// --hp-open #ff9f43, not just a border), and the travelling states carried it
// underneath the breathing ring. Now a cover returns no plate class at all:
//
// closed / open / ajar -> neutral plate, the ICON tells the state
// closed / open / ajar -> neutral core, the ICON tells the state
// opening / closing -> neutral plate + activity-transition when the
// marker opts into «Icon + activity»
// unknown / unavailable -> package unavailable core, without a morph
//
// The frame itself is NOT gone: door/window binary sensors and locks still
// wear it, and a valve keeps it too (nothing morphs its icon). Those are
@@ -33,7 +34,8 @@ const out = await page.evaluate(async () => {
};
const YELLOW = probe('var(--hp-on)'); // «включено»
const ORANGE = probe('var(--hp-open)'); // «открыто» frame
const NEUTRAL = probe('var(--hp-bg)'); // the plain badge
const NEUTRAL = probe('light-dark(#fff, #252525)'); // package theme fallback
const AMBER = probe('#F0A00C'); // package working/unlocked core
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
o.yellowIsTheYellow = YELLOW === 'rgb(255, 212, 92)';
o.orangeIsTheOpenFrame = ORANGE === 'rgb(255, 159, 67)';
@@ -71,7 +73,7 @@ const out = await page.evaluate(async () => {
const devEl = () => sr().querySelector('.devlayer .dev');
const cls = () => [...(devEl()?.classList || [])];
const plate = () => getComputedStyle(devEl()).backgroundColor;
const plate = () => getComputedStyle(devEl()?.querySelector('.device-core')).backgroundColor;
const icon = () => devEl()?.querySelector('ha-icon')?.getAttribute('icon') || '';
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.devlayer .dev').length === 1;
@@ -105,7 +107,7 @@ const out = await page.evaluate(async () => {
o.openingRingIsTheBreathingOne = (() => {
const ring = devEl()?.querySelector('.device-pulse.continuous.transition i:first-child');
const cs = ring ? getComputedStyle(ring) : null;
return !!cs && cs.animationName === 'hp-pulse-continuous' && cs.animationDuration === '2.4s';
return !!cs && cs.animationName === 'hp-pulse-continuous' && cs.animationDuration === '3.6s';
})();
await setCover('closing', { device_class: 'curtain' });
@@ -134,9 +136,12 @@ const out = await page.evaluate(async () => {
// one the name rule «ворота|garage|gate» hands out, and it morphs too
o.gateAutoIcon = c._devices.find((x) => x.bindingRef === 'd_gate')?.icon === 'mdi:garage-variant';
o.morphNoClass = await morph('', 'mdi:garage-variant', 'mdi:garage-open-variant');
// …and an unreadable state morphs nothing, plate still neutral
// …and an unreadable state morphs nothing and uses the package unavailable core.
await setCover('unknown', { device_class: 'curtain' });
plateIsNeutral('unknown');
o.unknownPlateUnavailable = plate() === probe('#B5BAC1');
o.unknownNotYellow = plate() !== YELLOW;
o.unknownNotOrange = plate() !== ORANGE;
o.unknownNoPlateClass = !cls().includes('on') && !cls().includes('open');
o.unknownNoMorph = icon() === 'mdi:garage-variant';
o.unknownNoRing = !cls().includes('activity-transition');
@@ -164,17 +169,21 @@ const out = await page.evaluate(async () => {
const winEl = elOf(dev('d_window'));
o.lockMarkerFound = !!lockEl;
o.windowMarkerFound = !!winEl;
o.unlockedLockKeepsTheFrame = !!lockEl && lockEl.classList.contains('open')
&& getComputedStyle(lockEl).backgroundColor === ORANGE;
o.unlockedLockKeepsTheFrame = !!lockEl && lockEl.classList.contains('lock-unlocked')
&& getComputedStyle(lockEl.querySelector('.device-core')).backgroundColor === AMBER;
o.openWindowKeepsTheFrame = !!winEl && winEl.classList.contains('open')
&& getComputedStyle(winEl).backgroundColor === ORANGE;
&& getComputedStyle(winEl.querySelector('.device-core')).backgroundColor === ORANGE;
// …and a locked lock is neutral again, so the frame still MEANS something
c.hass = { ...c.hass, states: { ...c.hass.states,
'lock.front_door': { entity_id: 'lock.front_door', state: 'locked', attributes: { friendly_name: 'Front door' } } } };
c.requestUpdate();
await c.updateComplete;
await new Promise((r) => setTimeout(r, 300));
o.lockedLockIsNeutral = getComputedStyle(elOf(dev('d_lock'))).backgroundColor === NEUTRAL;
const lockedEl = elOf(dev('d_lock'));
const lockedCore = lockedEl?.querySelector('.device-core');
o.lockedLockIsNeutral = !!lockedEl?.classList.contains('lock-locked')
&& getComputedStyle(lockedCore).backgroundColor === NEUTRAL
&& getComputedStyle(lockedCore).color === 'rgb(0, 0, 0)';
// a VALVE keeps the frame too: nothing morphs its icon, so the frame is the
// only thing it has to say «открыт» with (deliberately left alone)
+5 -3
View File
@@ -37,7 +37,7 @@ const out = await page.evaluate(async () => {
};
const YELLOW = probe('var(--hp-on)');
const ORANGE = probe('var(--hp-open)');
const NEUTRAL = probe('var(--hp-bg)');
const NEUTRAL = probe('light-dark(#fff, #252525)');
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
// =======================================================================
@@ -197,7 +197,7 @@ const out = await page.evaluate(async () => {
const el = d && elOf(d);
o[tag + 'MarkerFound'] = !!el;
if (!el) return;
const bg = getComputedStyle(el).backgroundColor;
const bg = getComputedStyle(el.querySelector('.device-core')).backgroundColor;
o[tag + 'PlateNeutral'] = bg === NEUTRAL;
o[tag + 'NotYellow'] = bg !== YELLOW;
o[tag + 'NotOrange'] = bg !== ORANGE;
@@ -212,7 +212,9 @@ const out = await page.evaluate(async () => {
// …and at rest (closed) they are plain, with no ring
await push('closed');
const closedBg = (ref) => getComputedStyle(elOf(dev(ref))).backgroundColor;
const closedBg = (ref) => getComputedStyle(
elOf(dev(ref)).querySelector('.device-core'),
).backgroundColor;
o.litLampClosedNeutral = closedBg('d_mixed') === NEUTRAL;
o.controlledClosedNeutral = closedBg('d_ctrl') === NEUTRAL;
o.litLampClosedNoRing = !elOf(dev('d_mixed')).classList.contains('activity-transition');
+1 -1
View File
@@ -174,7 +174,7 @@ const out = await page.evaluate(async () => {
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.dev').length === 1;
o.pulseWhileOpening = sr().querySelectorAll('.dev.activity-transition').length === 1;
const a1 = afterAnim();
o.pulseIsBreathingRing = a1?.name === 'hp-pulse-continuous' && a1?.dur === '2.4s';
o.pulseUsesPackageContinuousRing = a1?.name === 'hp-pulse-continuous' && a1?.dur === '3.6s';
// the plate stays NEUTRAL while travelling: yellow is reserved for «включено»
o.movingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
+179
View File
@@ -0,0 +1,179 @@
// Issue #179: package shell/state/motion/a11y contract in the real shared renderer.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(
{ width: 1120, height: 900 }, 1, [], { colorScheme: 'dark' },
);
await page.evaluate(async () => {
const c = window.__card;
const marker = (id, patch) => ({
...(c._serverCfg.markers || []).find((item) => item.id === id),
id, binding: `device:${id}`, ...patch,
});
const replacements = new Map([
['d_light1', marker('d_light1', { display: 'badge' })],
['d_motion', marker('d_motion', { display: 'icon_ripple' })],
['d_tv', marker('d_tv', { display: 'value' })],
['d_temp', marker('d_temp', {
display: 'badge',
value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: 'sensor.living_temp' },
position: 'right',
},
})],
['d_kettle', marker('d_kettle', { display: 'icon_ripple', tap_action: 'more-info' })],
]);
c._serverCfg.markers = [
...(c._serverCfg.markers || []).filter((item) => !replacements.has(item.id)),
...replacements.values(),
];
c.hass = {
...c.hass,
themes: { ...(c.hass.themes || {}), darkMode: true },
states: {
...c.hass.states,
'binary_sensor.hall_motion': {
...c.hass.states['binary_sensor.hall_motion'],
state: 'on',
attributes: {
...c.hass.states['binary_sensor.hall_motion'].attributes,
device_class: 'presence', linkquality: 64,
},
},
'media_player.tv': {
...c.hass.states['media_player.tv'],
state: 'A very long localized device value without clipping',
},
'sensor.living_temp': {
...c.hass.states['sensor.living_temp'],
state: '12345678901234567890',
attributes: {
...c.hass.states['sensor.living_temp'].attributes,
linkquality: 30,
},
},
'switch.kettle': {
...c.hass.states['switch.kettle'],
state: 'unavailable',
},
},
};
c._regSignature = '';
c._cfgEpoch++;
c._maybeRebuildDevices();
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
await page.waitForTimeout(220);
const selector = (id) => `.dev[data-id="${id}"]`;
const beforeUnavailable = await page.$eval(selector('d_kettle'), (node) => ({
core: getComputedStyle(node.querySelector('.device-core')).backgroundColor,
shell: getComputedStyle(node.querySelector('.device-shell')).borderColor,
}));
await page.hover(selector('d_kettle'));
// The production core/shell hover transition is 150 ms. Sampling in the same
// frame only proves the starting color and lets a restored unavailable hover
// escape the mutation guard.
await page.waitForTimeout(220);
const afterUnavailable = await page.$eval(selector('d_kettle'), (node) => ({
core: getComputedStyle(node.querySelector('.device-core')).backgroundColor,
shell: getComputedStyle(node.querySelector('.device-shell')).borderColor,
}));
const out = await page.evaluate(async ({ beforeUnavailable, afterUnavailable }) => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const node = (id) => sr().querySelector(`.dev[data-id="${id}"]`);
const rect = (element) => element?.getBoundingClientRect();
const light = node('d_light1');
const lightShell = light?.querySelector('.device-shell');
const lightCore = light?.querySelector('.device-core');
const shellRect = rect(lightShell);
const coreRect = rect(lightCore);
const presence = node('d_motion');
const unavailable = node('d_kettle');
const text = node('d_tv');
const double = node('d_temp');
const doubleShellRect = rect(double?.querySelector('.device-shell'));
const doubleCoreRect = rect(double?.querySelector('.device-core'));
const doubleValueRect = rect(double?.querySelector('.value-badge'));
const pseudo = getComputedStyle(light, '::before');
const lockNode = node('d_lock');
lockNode.querySelector('.device-core').style.transition = 'none';
const darkThemeClassProjected = lockNode.classList.contains('theme-dark');
const darkThemeCore = getComputedStyle(lockNode.querySelector('.device-core')).backgroundColor;
lockNode.classList.remove('theme-dark');
lockNode.classList.add('theme-light');
const lightThemeCore = getComputedStyle(lockNode.querySelector('.device-core')).backgroundColor;
lockNode.classList.remove('theme-light');
lockNode.classList.add('theme-dark');
let moreInfo = 0;
c._openMoreInfo = () => { moreInfo++; };
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
unavailable.dispatchEvent(enter);
const space = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true });
unavailable.dispatchEvent(space);
const aria = unavailable.getAttribute('aria-label') || '';
const viewKeyboard = unavailable.getAttribute('role') === 'button'
&& unavailable.getAttribute('tabindex') === '0'
&& enter.defaultPrevented && space.defaultPrevented && moreInfo === 2;
await c._setMode('devices');
await c.updateComplete;
const editorNode = node('d_light1');
editorNode.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter', bubbles: true, cancelable: true,
}));
await c.updateComplete;
const editorKeyboardOpensSettings = c._markerDialog?.devId === 'd_light1';
c._markerDialog = null;
await c._setMode('plan');
await c.updateComplete;
const planNode = node('d_light1');
const planNotInTabOrder = !planNode?.hasAttribute('role') && !planNode?.hasAttribute('tabindex');
return {
sharedShellGeometry: !!shellRect && !!coreRect
&& Math.abs(shellRect.width / coreRect.width - 1.26875) < 0.03,
packageShadowColor: getComputedStyle(lightShell).boxShadow.includes('37, 40, 45'),
noBackdropBlur: getComputedStyle(lightShell).backdropFilter === 'none',
activeUsesPackageAmber: getComputedStyle(lightCore).backgroundColor === 'rgb(240, 160, 12)',
lightThemeCoreIsWhite: lightThemeCore === 'rgb(255, 255, 255)',
darkThemeCoreIs252525: darkThemeClassProjected && darkThemeCore === 'rgb(37, 37, 37)',
hitAreaAtLeast44: parseFloat(pseudo.width) >= 44 && parseFloat(pseudo.height) >= 44,
presenceContinuousGreen: presence?.querySelector('.device-pulse.continuous.reason-presence')
&& getComputedStyle(presence.querySelector('.device-pulse i')).borderColor === 'rgb(29, 194, 29)'
&& getComputedStyle(presence.querySelector('.device-pulse i')).animationDuration === '3.6s',
lockedStateProjected: node('d_lock')?.classList.contains('lock-locked')
&& node('d_lock')?.getAttribute('data-state') === 'locked',
lqiBandsProjected: node('d_temp')?.getAttribute('data-lqi-band') === 'low'
&& node('d_window')?.getAttribute('data-lqi-band') === 'mid'
&& node('d_lamp')?.getAttribute('data-lqi-band') === 'high',
unavailableHasNoHoverOrMotion: JSON.stringify(beforeUnavailable) === JSON.stringify(afterUnavailable)
&& !unavailable.querySelector('.device-pulse'),
unavailableAriaIsReadable: /unavailable/i.test(aria) && /LQI|signal/i.test(aria),
textIsComplete: text?.querySelector('.valtext')?.textContent
=== 'A very long localized device value without clipping'
&& getComputedStyle(text.querySelector('.valtext')).textOverflow !== 'ellipsis'
&& getComputedStyle(text.querySelector('.valtext')).overflow === 'visible'
&& text.getAttribute('aria-label')?.includes('A very long localized device value without clipping'),
doubleUsesOneShell: !!doubleShellRect && !!doubleCoreRect && !!doubleValueRect
&& doubleShellRect.left <= Math.min(doubleCoreRect.left, doubleValueRect.left)
&& doubleShellRect.right >= Math.max(doubleCoreRect.right, doubleValueRect.right),
viewKeyboard,
editorKeyboardOpensSettings,
planNotInTabOrder,
};
}, { beforeUnavailable, afterUnavailable });
checkAll(out);
await finish(browser, out);
+1 -1
View File
@@ -12,7 +12,7 @@ const res = await page.evaluate(async () => {
const semantic = ['on', 'open', 'alarm', 'unavail', 'valonly', 'activity-running', 'activity-event', 'activity-presence', 'activity-transition'];
return {
classes: semantic.filter((name) => node.classList.contains(name)),
icon: node.querySelector(':scope > ha-icon')?.getAttribute('icon') || '',
icon: node.querySelector('.device-core > ha-icon')?.getAttribute('icon') || '',
value: node.querySelector('.valtext')?.textContent?.trim() || '',
badge: node.querySelector('.value-badge')?.textContent?.trim() || '',
badgePosition: [...(node.querySelector('.value-badge')?.classList || [])]
+74 -4
View File
@@ -50,7 +50,12 @@ const res = await page.evaluate(async () => {
// 3) повторный клик по активной вкладке — ничего
tabs()[0].click(); await c.updateComplete;
out.reclickNoop = c._mode === 'plan';
const enteringTabCross = tabs()[0].querySelector('.closex');
out.tabCrossCloseStartsDuringEnter = c._modeTransitionBusy;
enteringTabCross.click();
await settleMode();
out.tabCrossCloseDuringEnterWorks = c._mode === 'view';
tabs()[0].click(); await settleMode();
// HP-UX-11: transient tool controls live in a stage overlay. Opening the
// thickness controls must not resize/refit the stage or move pinned Close.
const stageBeforeContext = sr().querySelector('.stage').getBoundingClientRect();
@@ -171,10 +176,75 @@ const res = await page.evaluate(async () => {
out.barCloseWorks = c._mode === 'view'
&& getComputedStyle(chrome).visibility === 'hidden'
&& chrome.getBoundingClientRect().height < 1;
// 7) крестик в самой вкладке → Просмотр (и не переключает режим)
tabs()[1].click(); await settleMode();
tabs()[1].querySelector('.closex').click(); await settleMode();
out.tabCrossWorks = c._mode === 'view';
// 7) X in every active tab keeps its 13 px glyph/layout footprint, but its
// real hit target is at least 24 px. Exercise the newly covered edge rather
// than calling the element's centre programmatically.
const tabCrossChecks = [];
for (let index = 0; index < tabs().length; index++) {
tabs()[index].click(); await settleMode();
const cross = tabs()[index].querySelector('.closex');
const rect = cross.getBoundingClientRect();
const style = getComputedStyle(cross);
const hit = sr().elementFromPoint(rect.left + 1, rect.top + rect.height / 2);
const horizontalFootprint = rect.width
+ parseFloat(style.marginLeft) + parseFloat(style.marginRight);
const verticalFootprint = rect.height
+ parseFloat(style.marginTop) + parseFloat(style.marginBottom);
const glyphStays13 = style.getPropertyValue('--mdc-icon-size').trim() === '13px';
hit?.dispatchEvent(new MouseEvent('click', {
bubbles: true,
composed: true,
clientX: rect.left + 1,
clientY: rect.top + rect.height / 2,
}));
await settleMode();
tabCrossChecks.push({
target: rect.width >= 24 && rect.height >= 24,
// Capture computed style before the click removes the active X from DOM.
glyph: glyphStays13,
footprint: Math.abs(horizontalFootprint - 15) <= 0.1
&& Math.abs(verticalFootprint - 13) <= 0.1,
edgeHit: hit === cross,
closed: c._mode === 'view',
});
}
out.tabCrossTargetsAtLeast24 = tabCrossChecks.every((check) => check.target);
out.tabCrossGlyphStays13 = tabCrossChecks.every((check) => check.glyph);
out.tabCrossKeepsLayoutFootprint = tabCrossChecks.every((check) => check.footprint);
out.tabCrossExpandedEdgeWorks = tabCrossChecks.every((check) => check.edgeHit && check.closed);
out.tabCrossWorks = tabCrossChecks.every((check) => check.closed);
// Existing navigation contracts are not rewritten by the CSS fix: an open
// Walls chain finishes on the same click, while the hard geometry limit
// keeps the draft and gives explicit feedback.
tabs()[0].click(); await settleMode();
const chainSpace = c._curSpaceCfg;
const savedPartitions = chainSpace.partitions ? [...chainSpace.partitions] : undefined;
const savedSaveConfig = c._saveConfig;
c._saveConfig = () => {};
const partitionCount = (chainSpace.partitions || []).length;
c._tool = 'draw';
c._path = [[100, 100], [200, 100], [250, 150]];
c._draftSegmentCms = [15, 15];
tabs()[0].querySelector('.closex').click(); await settleMode();
out.tabCrossFinishesWallChain = c._mode === 'view'
&& (chainSpace.partitions || []).length === partitionCount + 2
&& c._path.length === 0;
chainSpace.partitions = savedPartitions;
tabs()[0].click(); await settleMode();
c._tool = 'draw';
c._path = [[100, 100], [200, 100]];
c._draftSegmentCms = [15];
chainSpace.partitions = new Array(2000);
tabs()[0].querySelector('.closex').click();
out.tabCrossLimitKeepsDraftWithFeedback = c._mode === 'plan'
&& c._path.length === 2
&& c._toast === c._t('toast.physical_limit');
chainSpace.partitions = savedPartitions;
c._path = [];
c._draftSegmentCms = [];
c._saveConfig = savedSaveConfig;
c._setMode('view', false); await c.updateComplete;
// Layout-independent editor history: physical KeyZ works when `key` is the
// Russian «я», while an input keeps its native browser history.
+12 -1
View File
@@ -10,6 +10,15 @@ const res = await page.evaluate(async () => {
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active;
};
const composedContains = (root, target) => {
let node = target;
while (node) {
if (node === root) return true;
const tree = node.getRootNode?.();
node = node.parentNode || (tree instanceof ShadowRoot ? tree.host : null);
}
return false;
};
// A11Y-02: the shared native fallback carries the same modal/focus contract
// that ha-dialog supplies in Home Assistant.
@@ -26,7 +35,9 @@ const res = await page.evaluate(async () => {
&& native?.getAttribute('aria-modal') === 'true'
&& !!titleId && !!hp.shadowRoot.getElementById(titleId)?.textContent.trim();
let active = deepActive();
out.initialFocus = !!active && (hp.contains(active) || hp.shadowRoot.contains(active));
// Unified colour controls put the first focus target in their own shadow
// root. Test composed ownership, not only light-DOM containment.
out.initialFocus = !!active && composedContains(hp, active);
const focusable = [...hp.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])')];
const last = focusable.at(-1);
last?.focus();

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