Compare commits

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

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

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

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

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

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

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

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

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

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

Issue: #206
User-Visible: no
2026-08-19 20:06:38 +03:00
Sergey Matyunin 5ff80f3bdc test: accept v1.65.0-beta.5 Linux golden baselines
Issue: #200
Issue: #203
User-Visible: no
Release: v1.65.0-beta.5
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32276442988
2026-08-19 19:39:41 +03:00
Sergey Matyunin e6016a966f Release v1.65.0-beta.5 candidate
Issue: #176
Issue: #186
Issue: #195
Issue: #200
Issue: #201
Issue: #203
User-Visible: yes
2026-08-19 19:31:44 +03:00
claude[bot] 142bd611b1 docs: code review document for #203
Issue: #203
User-Visible: no
2026-08-19 16:04:50 +00:00
Sergey Matyunin 1290927f10 fix: hide disabled room names
Issue: #203
User-Visible: yes
2026-08-19 18:47:33 +03:00
claude[bot] 84e62dcd0f docs: review document for #203
Issue: #203
User-Visible: no
2026-08-19 15:35:50 +00:00
Sergey Matyunin f7b811a621 docs: protect hidden iso labels in spec
Issue: #203
User-Visible: no
2026-08-19 18:29:28 +03:00
claude[bot] 950403debd docs: review document for #203
Issue: #203
User-Visible: no
2026-08-19 15:28:37 +00:00
Sergey Matyunin 009fed9bc0 docs: specify hidden room names
Issue: #203
User-Visible: no
2026-08-19 18:17:05 +03:00
claude[bot] 10c0f3c95a docs: review document for #201
Issue: #201
User-Visible: no
2026-08-19 15:12:41 +00:00
Sergey Matyunin f7abf14abd fix: inherit parent thickness for atomic walls
Issue: #201
User-Visible: yes
2026-08-19 18:04:11 +03:00
claude[bot] 8b8b9ed90d docs: review document for #201
Issue: #201
User-Visible: no
2026-08-19 14:55:30 +00:00
Sergey Matyunin 7b759f316b docs: specify atomic wall thickness lookup
Issue: #201
User-Visible: no
2026-08-19 17:48:56 +03:00
claude[bot] b583d663e3 docs: review document for #200
Issue: #200
User-Visible: no
2026-08-19 14:42:10 +00:00
Sergey Matyunin 88a28775e7 docs: refresh screenshots for room label parity
Issue: #200
User-Visible: no
2026-08-19 17:16:50 +03:00
Sergey Matyunin cd029a0415 fix: keep room labels aligned across modes
Issue: #200
User-Visible: yes
2026-08-19 17:12:02 +03:00
claude[bot] bf83246b7b docs: review document for #200
Issue: #200
User-Visible: no
2026-08-19 13:57:42 +00:00
Sergey Matyunin 4089c912c6 docs: specify room label parity
Issue: #200
User-Visible: no
2026-08-19 16:50:29 +03:00
claude[bot] 0b411dd802 docs: review document for #195
Issue: #195
User-Visible: no
2026-08-19 13:46:04 +00:00
claude[bot]andSergey Matyunin 07b0b3dec2 docs: review document for #195
Issue: #195
User-Visible: no
2026-08-19 16:38:43 +03:00
Sergey Matyunin 2ef32417bd test: sample editor close glyph before removal
Issue: #195
User-Visible: no
2026-08-19 16:38:43 +03:00
Sergey Matyunin 016c75f539 fix: enlarge editor close hit target
Issue: #195
User-Visible: yes
2026-08-19 16:38:43 +03:00
claude[bot] 71d369a295 docs: review document for #176
Issue: #176
User-Visible: no
2026-08-19 13:24:29 +00:00
Sergey Matyunin f66e671b89 refactor: remove dead partition tool state
Issue: #176
User-Visible: no
2026-08-19 16:17:09 +03:00
claude[bot] 41e2cfffc6 docs: spec review document for #176
Issue: #176
User-Visible: no
2026-08-19 13:09:19 +00:00
claude[bot] a0c4c1cdd8 docs: code review r1 for #186
Validate / docs (push) Failing after 27s
Validate / provenance (push) Successful in 46s
Validate / process-gate (push) Failing after 46s
Validate / changes (push) Successful in 40s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / smoke (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Issue: #186
User-Visible: no
2026-08-19 13:00:16 +00:00
Sergey Matyunin edfea67ddd docs: refresh screenshot source fingerprint
Issue: #186
User-Visible: no
2026-08-19 15:57:00 +03:00
Sergey Matyunin 57ba75b9da fix: keep jamb margin on partition openings
Issue: #186
User-Visible: yes
2026-08-19 15:43:23 +03:00
claude[bot]andSergey Matyunin 804b282f5f docs: review document for #186
Issue: #186
User-Visible: no
2026-08-19 15:33:45 +03:00
Sergey Matyunin a356ec29ab docs: preserve legacy jambs on full restore
Issue: #186
User-Visible: no
2026-08-19 15:33:45 +03:00
claude[bot]andSergey Matyunin 67bf85e7d2 docs: review document for #186
Issue: #186
User-Visible: no
2026-08-19 15:33:44 +03:00
Sergey Matyunin 2453ec0d7f docs: specify partition opening jamb margin
Issue: #186
User-Visible: no
2026-08-19 15:33:44 +03:00
255 changed files with 23281 additions and 3370 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: |
+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 }}
+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.4"
VERSION = "1.65.0-beta.9"
# 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
+5 -1
View File
@@ -52,6 +52,7 @@ from .validation import (
MarkerControlError,
OpeningPassageError,
PartitionOpeningHostError,
PartitionOpeningJambMarginError,
)
FORMAT = "houseplan-export"
@@ -993,7 +994,10 @@ def build_space_merge(
validate_marker_value_badges(merged_config, current_config)
validate_opening_passages(merged_config, current_config)
validate_partition_opening_hosts(merged_config, current_config)
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
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.4"
"version": "1.65.0-beta.9"
}
+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
+82 -9
View File
@@ -52,30 +52,103 @@ class PartitionOpeningHostError(ValueError):
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:
"""Prevent an older writer from silently downgrading hosted openings.
"""Validate hosted-opening write deltas without rejecting legacy reads.
Deleting the opening together with its partition remains valid. Only a
surviving record that previously had an explicit host must keep one.
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 []:
old_space = old_spaces.get(str(space.get("id", "")))
if not old_space:
continue
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.get("openings") or []
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 []:
old = old_openings.get(str(opening.get("id", "")))
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.get('id', '')}; opening={opening.get('id', '')}; host removed"
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
)
+9 -3
View File
@@ -58,7 +58,7 @@ 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,
PartitionOpeningHostError, sanitize_filename,
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,
@@ -1258,7 +1258,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
validate_marker_value_badges(msg["config"], data.get("config"))
validate_opening_passages(msg["config"], data.get("config"))
validate_partition_opening_hosts(msg["config"], data.get("config"))
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
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
@@ -1370,7 +1373,10 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
validate_marker_value_badges(msg["config"], config_data.get("config"))
validate_opening_passages(msg["config"], config_data.get("config"))
validate_partition_opening_hosts(msg["config"], config_data.get("config"))
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
except (
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError,
) as err:
connection.send_error(msg["id"], err.code, str(err))
return
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env node
// Issue #211: human-reviewable Reference SVG <-> Runtime matrix.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { mdiLightbulbSpot } from '@mdi/js';
import { launch } from './serve.mjs';
const artifactDir = resolve('artifacts/device-icon-reference');
mkdirSync(artifactDir, { recursive: true });
const referenceAsset = (theme, file, coreSize) => {
let source = readFileSync(resolve('demo/srv/reference/device-icons', theme, file), 'utf8');
if (theme === 'Dark' && file === 'Unlock.svg') source = source.replaceAll('#1DC21D', '#F0A00C');
const nativeWidth = Number(source.match(/<svg[^>]*width="([\d.]+)"/)?.[1] || 127);
return {
url: `data:image/svg+xml;base64,${Buffer.from(source).toString('base64')}`,
displayWidth: nativeWidth * coreSize / 80,
};
};
const { page, browser } = await launch(
{ width: 1280, height: 960 }, 1, [], { colorScheme: 'dark' },
);
await page.evaluate((path) => { window.__ICONS['mdi:lightbulb-spot'] = path; }, mdiLightbulbSpot);
await page.evaluate(async () => {
const c = window.__card;
const marker = (id, patch) => ({
...(c._serverCfg.markers || []).find((item) => item.id === id),
id, binding: `device:${id}`, ...patch,
});
const replacements = new Map([
['d_light1', marker('d_light1', { display: 'badge', icon: 'mdi:lightbulb-spot' })],
['d_tv', marker('d_tv', { display: 'value' })],
['d_temp', marker('d_temp', {
display: 'badge',
value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: 'sensor.living_temp' },
position: 'right',
},
})],
]);
c._serverCfg.markers = [
...(c._serverCfg.markers || []).filter((item) => !replacements.has(item.id)),
...replacements.values(),
];
c.hass = {
...c.hass,
states: {
...c.hass.states,
'sensor.living_temp': {
...c.hass.states['sensor.living_temp'],
state: '23',
attributes: { ...c.hass.states['sensor.living_temp']?.attributes, unit_of_measurement: '%' },
},
'media_player.tv': {
...c.hass.states['media_player.tv'],
state: 'Working',
},
},
};
c._regSignature = '';
c._cfgEpoch++;
c._maybeRebuildDevices();
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
const qaStyle = document.createElement('style');
qaStyle.textContent = '.devtip{display:none!important}';
(c.renderRoot || c.shadowRoot).append(qaStyle);
await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)));
});
const selector = (id) => `.dev[data-id="${id}"]`;
async function runtimePng(theme, row, size) {
await page.mouse.move(1, 1);
await page.evaluate(({ id, themeName, classes, px, clearValues }) => {
const node = (window.__card.renderRoot || window.__card.shadowRoot)
.querySelector(`.dev[data-id="${id}"]`);
for (const marker of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.dev'))
marker.style.visibility = marker === node ? 'visible' : 'hidden';
node.classList.remove(...[
'theme-light', 'theme-dark', 'on', 'open', 'alarm', 'unavail', 'virtual',
'sel', 'lock-locked', 'lock-unlocked',
]);
node.classList.add(`theme-${themeName}`, ...classes);
node.style.setProperty('--device-base-size', `${px}px`);
node.style.setProperty('--dev-scale', '1');
node.querySelector('.device-core')?.style.setProperty('transition', 'none');
node.querySelector('.device-shell-frame')?.style.setProperty('transition', 'none');
if (clearValues) node.querySelectorAll('.value-badge').forEach((value) => value.remove());
node.blur();
}, {
id: row.id,
themeName: theme.toLowerCase(),
classes: row.classes || [],
px: size,
clearValues: row.clearValues || false,
});
if (row.hover) {
await page.hover(selector(row.id));
await page.waitForTimeout(180);
}
if (row.focus) {
await page.$eval(selector(row.id), (node) => node.focus());
}
await page.$eval(selector(row.id), (node) => {
for (const tooltip of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.devtip'))
tooltip.style.setProperty('display', 'none', 'important');
node.querySelector('.lqi')?.style.setProperty('display', 'none');
});
const clip = await page.$eval(selector(row.id), (node) => {
const shell = node.querySelector('.device-shell-frame').getBoundingClientRect();
const pad = 22;
return {
x: Math.max(0, shell.left - pad),
y: Math.max(0, shell.top - pad),
width: shell.width + pad * 2,
height: shell.height + pad * 2,
};
});
return (await page.screenshot({ clip })).toString('base64');
}
const rows = [
{ label: 'Default', file: 'Icon Default.svg', id: 'd_light1' },
{ label: 'Hover', file: 'Icon Hover.svg', id: 'd_light1', hover: true },
{ label: 'Active', file: 'Icon Active.svg', id: 'd_light1', classes: ['on'] },
{ label: 'Lock', file: 'Lock.svg', id: 'd_lock', classes: ['lock-locked'] },
{ label: 'Unlock', file: 'Unlock.svg', id: 'd_lock', classes: ['lock-unlocked'] },
{ label: 'Selected', file: 'Selected.svg', id: 'd_light1', classes: ['sel'] },
{ label: 'Focus', file: 'Focus Visible.svg', id: 'd_light1', focus: true },
{ label: 'Alert', file: 'Alert Value.svg', id: 'd_temp', classes: ['alarm'] },
{ label: 'Virtual', file: 'Virtual Device Default.svg', id: 'd_motion', classes: ['virtual'] },
{ label: 'Unavailable', file: 'Unavailable.svg', id: 'd_light1', classes: ['unavail'] },
{ label: 'Text', file: 'Text Default.svg', id: 'd_tv' },
{ label: 'Double Right', file: 'Double Default Right.svg', id: 'd_temp' },
];
const matrix = [];
for (const theme of ['Light', 'Dark']) {
for (const row of rows) {
matrix.push({
theme,
row,
size: 56,
runtime: await runtimePng(theme, row, 56),
});
}
for (const size of [32, 96]) {
const row = rows[0];
matrix.push({ theme, row, size, runtime: await runtimePng(theme, row, size) });
}
}
const escapeHtml = (value) => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
const body = matrix.map(({ theme, row, size, runtime }) => {
const reference = referenceAsset(theme, row.file, size);
return `
<tr>
<td>${theme}</td><td>${escapeHtml(row.label)}</td><td>${size}px</td>
<td class="preview"><img style="width:${reference.displayWidth}px" src="${reference.url}" alt="Reference ${escapeHtml(row.label)}"></td>
<td class="preview runtime"><img src="data:image/png;base64,${runtime}" alt="Runtime ${escapeHtml(row.label)}"></td>
</tr>`;
}).join('');
const html = `<!doctype html>
<html><head><meta charset="utf-8"><title>Device icon reference/runtime matrix</title>
<style>
body{margin:24px;background:#777;color:#111;font:16px system-ui,sans-serif}
h1,p{max-width:1100px} table{border-collapse:collapse;width:100%;background:#aaa}
th,td{border:1px solid #555;padding:8px;text-align:left} th{position:sticky;top:0;background:#ddd;z-index:2}
.preview{width:38%;text-align:center;background:linear-gradient(135deg,#d5d5d5 50%,#666 50%)}
.preview img{display:block;margin:auto;max-width:300px;max-height:180px}.runtime img{image-rendering:auto}
</style></head><body>
<h1>House Plan device icons: package 1.1.1 vs runtime</h1>
<p>Issue #211. Reference SVG is loaded directly from the designer package; Runtime is a fresh browser capture. Default also covers 32/56/96 px. Dark Unlock is evaluated using the owner's amber override from #179.</p>
<table><thead><tr><th>Theme</th><th>State/layout</th><th>Core</th><th>Reference SVG</th><th>Runtime</th></tr></thead>
<tbody>${body}</tbody></table></body></html>`;
const htmlPath = resolve(artifactDir, 'device-icons-reference-runtime.html');
writeFileSync(htmlPath, html);
await page.setViewportSize({ width: 1600, height: 1000 });
await page.setContent(html, { waitUntil: 'load' });
await page.screenshot({
path: resolve(artifactDir, 'device-icons-reference-runtime.png'),
fullPage: true,
});
await browser.close();
console.log(`OK device icon reference/runtime matrix: ${htmlPath}`);
+55 -49
View File
@@ -1,81 +1,87 @@
{
"schema": 1,
"matrixVersion": 30,
"acceptedAt": "2026-08-19T11:34:09.162Z",
"sourceFingerprint": "ad0031892ee8d0c3d21fbefccea8be7c7c89aa3b89e3afceaf2c88f6024ab533",
"matrixVersion": 32,
"acceptedAt": "2026-08-20T08:32:47.949Z",
"sourceFingerprint": "599e6528b07f4762db20025ae84f888e0a61f173c9605195a9a9348c9b08393a",
"chromium": "151.0.7922.34",
"scenarios": {
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
"split-zero-divider-taper-dark": "4c63083b1cb9e1079fa56e6991a035124d0ce22527489712a7a9161f1ccf2107",
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
"isometric-geometry-view-dark": "9bc8eb0da8746bddc0b5245b337eef7477d4ce6fb39ac576cab1e49057fd837c",
"isometric-geometry-view-light": "144f1cc3107562bc252cdcb54165f2c92569f4cf3484554148a7fdb78de372ec",
"isometric-live-layers-dark": "c841269f672c7ad8b208a549cc87592bd26f2a9e226f793a2b79592b65ee54c6",
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
"isometric-live-layers-dark": "7744e9b005c307778308e7f41cf89c893436b6175321baafa72c3ed14a6236a8",
"isometric-no-borders-dark": "f5f68092afc878cc061e81f73c4676c306879b89e4efdf6d32cea3275f3190ac",
"isometric-touch-kiosk-dark": "745482a797e236058e3ddd56cb5d85d700c9333a3aeb764333addeaba40cf6b0",
"isometric-large-warm-remount-dark": "0afc1069d334f10be2d0ed135ee0eb52e9272a1a1ad1224e00f05753b8789d0c",
"isometric-large-warm-remount-dark": "b01bd4bc1e0b33a82faf7a6effb02a887ba867875b751b96978c95ad262f839b",
"geometry-view-dark-fit": "438817c56cd8f91ef63778a3d3d22a4ee1fb675064bc24bf613fb752d6c503d9",
"geometry-view-light-fit": "667d38fd55a946ea6930b8674c4036e75c261ba4f111f893367ae72ed19500fc",
"washer-active-cycle-dark": "7597ffad91165f2f3cc9647e5081c03d4ffd722313bf74f8dcf5fe6dc52d1dcf",
"washer-idle-cycle-dark": "6b1de9786f4852659d2d453fa39340626a7944417f88c5a37355b62c458b6ac1",
"room-label-parity-view-dark": "3a636de579cd2d1de338d017ed6b53c3e2f70a901a0f5c81dfca85870d1b4bd5",
"room-label-parity-plan-dark": "e9b2253f94eb74d43e22a3bbc1229f34a3a923b777fddb069dad3dae516583cd",
"room-label-parity-view-light": "6eb2ba0023932c9b4e82a45b7ca9565a9ed1b89d14e543facbaf1f84aa5db9ff",
"room-label-parity-plan-light": "333139affbf8936554072aebcaf58b4640779adbd6a64686c0929ca071063733",
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
"day-cycle-dawn-dark": "289e6edd6c206308d3257775b2948085a24698398995bc6ce72201745705c58c",
"day-cycle-day-dark": "df27409e83f4492d3f69dc6cebf2a6e7799e154698425b17fb3ab8cfddb426a6",
"day-cycle-dusk-dark": "9798e9dac27e69727adbe9c9a782a3bd3dc5dd0850e7a49852c069d54b382f9a",
"day-cycle-night-dark": "1abf9528ac05dc3a963eb19a8b3ed4b7bb9ce647f7b83dab4fd2e736756cd770",
"geometry-plan-editor-dark": "0e7b01e74c82ab5072bffbfaddfaa21b07089662b19f491a7c6783a4152af9fb",
"plan-snap-endpoint-light": "6187b35a780db09626bd9b8a06461dc133e4027722e965908811226f260d327a",
"plan-snap-line-gaps-dark": "5ae0634f63718be4b2c1fcabd2b474ecbc980fe96a3bdd2987ca8356472b1076",
"wall-junctions-plan-preview-light": "a8a04b5c611a880880b6b6d64d10eb50576a5a377c984a5b397075b225739bd5",
"wall-junctions-plan-t-dark": "1812cbf5f68a0431efed7823990020bd910f98e8e78c8e658f3012829395931a",
"wall-junctions-view-dark": "73a64c65c8e77aa767bb7f401d68c61df5749e65e75273c85b0d6c72cb430766",
"junction-patch-resilience-plan-dark": "c1511849cd59b2b1ce50c040e572cf431e9de7029d7c4f2dbbfa1c168cd29cf3",
"junction-patch-resilience-view-dark": "140ee1df8eaf997ebf9adf3c6f0658f1728a2339102e079e68a4ce86eead9f2c",
"isometric-wall-junctions-dark": "cb1e28f484b304ecda3e35f66e0c0429016d10a5a3022dcc9315a92eb537300f",
"opening-placement-door-thick-wall-dark": "4393770bb97cb051d76f5b2aba9d314a8789a20278ccafb31c048300ccb4d8cf",
"opening-placement-passage-thick-wall-dark": "514420fc90aab872bb6149cc41eea23bcfb673db358d5a9e54e18bb612182a08",
"opening-placement-passage-thick-wall-light": "139fbd14d655482d43f1dbceba38a8f22b931a86677997986b75d1160216a6bd",
"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": "995477b887a302c25d8a0fe540a1b8ee56369e21100cdb367a3bf37c8dcc99c1",
"tray-wide-tool-ru": "0fa205ba48803255f8e0dc51286b7fc151ccfd319dfb932fd4927fff124d0396",
"tray-medium-group-en": "b6170877b12de6ff7b03e4c3a0099141a7fa010448bac914ce6dafe321594b83",
"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": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
"lighting-glow-sun-dark": "a98eee332f25a43c8d9d126c118c8cfea4ee73752b1060227548efedaf0efcdd",
"device-value-badge-positions-dark": "1ad43f2bd866733aa75c34de38fb97d469661799b540a8ae22150067d788cec8",
"openings-thick-wall-dark": "0c33cdb4637ee9788e75931ba142099e1d068b770e26ca24f4a59f8631f3373f",
"openings-filled-tunnel-dark": "bb2a4343a285e2fd867c6090108589549facf87b1a9471f3ae331d743af6653c",
"openings-hidden-view-dark": "19ab4fd2ffce0fea0fe609b007a3e60f7abd84b40935cbf3c5fa9dc1fda21046",
"lighting-glow-sun-dark": "3f02abbcb3379b2e224ba7dd3126ec4cb736c0f71edbcdd2f2961c33d7d1b12a",
"device-value-badge-positions-dark": "831f04e8b97e4cb473e2c9e53976a5422dd3718eb74086ed2503ffc6d3f5ad87",
"device-icon-state-table-light": "c2bc269fb9600294e4eca4eeda2316da1cb4c150b5e9d49c69ce74ce52f26875",
"device-icon-state-table-dark": "aea23ced341b8838a0616518da85585e978f53c8cfe61f3c7e023c724c2ee550",
"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",
"lighting-fill-light-axis-split-dark": "4cb3f7591f005768d1d8e42f651184a7317b439318302d6bc48fcca000266c82",
"lighting-fill-temp-axis-split-dark": "47383a2e81b34cd7cf385a0f33efe755906c1cb20de8345dc69f599597378801",
"lighting-fill-lqi-axis-split-dark": "480b518fb2da24fc7d70297a4456a92485f5f5629a6bc951a36eedcc7631e5dc",
"lighting-temp-glow-dark": "a5c8f7b981d4f310ca8cf6c19f72236dc29abdd88a1d60be6ef916dd9091f7b8",
"lighting-temp-glow-light": "86620d7b68e797469fbec36438bf2725bd913bc8b4641dbe75c03098210d17f7",
"lighting-custom-glow-dark": "86a1ba7ac8e6bc9515a4df550065c60fd600ce6898bdc5447122c9fc87fb3965",
"lighting-opaque-glow-two-doorways-dark": "0cff53678f935bb9cee4570a47bf34a05d71b1fd512d76158e846c0b1aa7a5c5",
"lighting-custom-glow-light": "2568f1ca7be270faabd2c97d955fd6e27dec00e921d8e6f21ab7425a86e7f958",
"lighting-temp-glow-no-sources-dark": "ee37a7dda19a0bfe02405d4e9cf61023cecb623896388a4197adc4a5afe6b584",
"lighting-temp-glow-room-override-dark": "fa20be4d2a29710143f437d5049c6e71959c6a56df2a12a9d2e9728e7833d9ed",
"lighting-manual-auto-spill-overlap-dark": "695380b4d5809ecee27dbe060c70573d75ac423fdb19823dc9f112d4adf9e272",
"hover-over-glow-dark": "d505d1514e492dd3f5e299ebace0392e14734f21626310a8efdc0d148a00ddf0",
"hover-nested-room-dark": "2db78e53a76fa9b7cdc4597f23fc2c8ae439e5a56d82895109ca968d73075640",
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
"device-dialog-desktop-en": "343b79ef61e1fb71906bce79db50d4d1d73b2af09ca86ed550fd91c7e2b03164",
"large-house-zoom-040-dark": "cae0d852e1d13c77a57b4a040ba4f3e7f263cfaf716bd406c022985db560c2e5",
"large-house-zoom-250-dark": "9d956270ad71d2e3326d36ebff1eca4e6bc3079b79b6c22ab4ef619a12347ca8",
"large-house-warm-remount-dark": "c92f73b080617cf7ad83a0269bb6ee25a7c3481570ff8452c1cf811e8572ab1d",
"device-dialog-desktop-en": "ea341f6ea9db5f61bbcb6fd7ca5078fd2f347023de4279cb70bf190c1379c364",
"device-dialog-mobile-ru": "c90be98e65c37963fd4443e413848ca68a412c9ee568d648aba76bf9232ee59d",
"toggle-entity-dialog-desktop-en": "013e2d01382435d995e8deece19e07e9bcced4fe80952cbab765e642b3d641d6",
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
"device-help-popover-light-ru": "3f14b5f159d4911f01f3a5d8f9db579bbc5da617a46653fe2d98c1e612a3f05c",
"device-help-popover-light-ru": "c74c83ecfb4ee6cd693d87d4516e9ee4920ad453f5b7b9606cd98853b7273568",
"decor-color-popover-mobile-ru": "a731bbd8c794457fceb6f281124c20ef403fc3a50d2e3d6a640ad6389e90eeae",
"decor-color-popover-desktop-en": "11189416fd3752310979444aa92ce38764996655871009ff028ba9b4b31959a8",
"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": "a5d62d68097af1ff5d2556be0c889efc25768ffc61af5d302f0b5775e082f296",
"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: 177 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

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

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 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: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

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

+69 -2
View File
@@ -163,6 +163,24 @@ export function prepareGoldenFixture(scenario) {
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>'}`);
@@ -295,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) => {
@@ -399,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;
@@ -435,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}`);
@@ -733,6 +763,22 @@ export async function prepareGoldenScenario(page, scenario) {
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');
const active = card.renderRoot.querySelector('.hp-day-cycle-bg.active');
@@ -766,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) {
+60 -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 = 30;
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' } },
@@ -189,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
+23 -11
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,24 @@ 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;
// …and a locked lock is neutral again, so the frame still MEANS something
&& getComputedStyle(winEl.querySelector('.device-core')).backgroundColor === ORANGE;
// …and a locked lock keeps the distinct package face: dark core + white glyph.
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');
const lockedFace = lockedEl?.classList.contains('theme-dark')
? 'rgb(37, 37, 37)'
: 'rgb(0, 0, 0)';
o.lockedLockKeepsPackageFace = !!lockedEl?.classList.contains('lock-locked')
&& getComputedStyle(lockedCore).backgroundColor === lockedFace
&& getComputedStyle(lockedCore).color === 'rgb(255, 255, 255)';
// 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;
+573
View File
@@ -0,0 +1,573 @@
// Issues #179/#211: package shell/state/motion/a11y contract in the shared renderer.
import { readFileSync } from 'node:fs';
import { mdiLightbulbSpot } from '@mdi/js';
import { launch, checkAll, finish } from './serve.mjs';
const referenceSvg = (theme, name) => readFileSync(
new URL(`./srv/reference/device-icons/${theme}/${name}`, import.meta.url),
'utf8',
);
const tagAttribute = (tag, name) => tag.match(new RegExp(`${name}="([^"]+)"`))?.[1] || '';
const coreTag = (svg) => svg.match(/<rect[^>]*width="80"[^>]*height="80"[^>]*rx="40"[^>]*>/)?.[0] || '';
const shellTag = (svg) => svg.match(/<(?:path|rect)[^>]*stroke="[^"]+"[^>]*stroke-width="[^"]+"[^>]*>/)?.[0] || '';
const glyphTag = (svg) => [...svg.matchAll(/<path[^>]*fill="[^"]+"[^>]*>/g)].at(0)?.[0] || '';
const canonicalHex = (value) => ({ white: '#FFFFFF', black: '#000000' }[value.toLowerCase()] || value.toUpperCase());
const browserColor = (value, opacity = null) => {
const hex = canonicalHex(value).replace('#', '');
const full = hex.length === 3 ? [...hex].map((char) => char + char).join('') : hex;
const channels = [0, 2, 4].map((offset) => Number.parseInt(full.slice(offset, offset + 2), 16));
return opacity == null
? `rgb(${channels.join(', ')})`
: `rgba(${channels.join(', ')}, ${Number(opacity)})`;
};
const svgState = (theme, name) => {
const svg = referenceSvg(theme, name);
const core = coreTag(svg);
const shell = shellTag(svg);
const glyph = glyphTag(svg);
return {
core: browserColor(tagAttribute(core, 'fill')),
glyph: browserColor(tagAttribute(glyph, 'fill')),
shell: browserColor(
tagAttribute(shell, 'stroke'),
tagAttribute(shell, 'stroke-opacity') || null,
),
shellWidth: Number(tagAttribute(shell, 'stroke-width')) / 80,
};
};
const reference = {
light: {
default: svgState('Light', 'Icon Default.svg'),
hover: svgState('Light', 'Icon Hover.svg'),
active: svgState('Light', 'Icon Active.svg'),
lock: svgState('Light', 'Lock.svg'),
unlock: svgState('Light', 'Unlock.svg'),
unavailable: svgState('Light', 'Unavailable.svg'),
alert: svgState('Light', 'Alert Value.svg'),
},
dark: {
default: svgState('Dark', 'Icon Default.svg'),
hover: svgState('Dark', 'Icon Hover.svg'),
active: svgState('Dark', 'Icon Active.svg'),
lock: svgState('Dark', 'Lock.svg'),
unlock: svgState('Dark', 'Unlock.svg'),
unavailable: svgState('Dark', 'Unavailable.svg'),
alert: svgState('Dark', 'Alert Value.svg'),
},
};
// Owner decision in #179 overrides the stale green Dark/Unlock asset.
reference.dark.unlock = {
...reference.dark.unlock,
core: reference.light.unlock.core,
glyph: reference.dark.active.glyph,
shell: reference.light.unlock.shell,
};
const { page, browser } = await launch(
{ width: 1120, height: 900 }, 1, [], { colorScheme: 'dark' },
);
await page.evaluate((path) => { window.__ICONS['mdi:lightbulb-spot'] = path; }, mdiLightbulbSpot);
await page.evaluate(async () => {
const c = window.__card;
const marker = (id, patch) => ({
...(c._serverCfg.markers || []).find((item) => item.id === id),
id, binding: `device:${id}`, ...patch,
});
const replacements = new Map([
['d_light1', marker('d_light1', { display: 'badge', icon: 'mdi:lightbulb-spot' })],
['d_motion', marker('d_motion', { display: 'icon_ripple' })],
['d_tv', marker('d_tv', { display: 'value' })],
['d_temp', marker('d_temp', {
display: 'badge',
tap_action: 'more-info',
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-frame')).borderColor,
opacity: getComputedStyle(node).opacity,
}));
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-frame')).borderColor,
opacity: getComputedStyle(node).opacity,
}));
await page.hover(selector('d_tv'));
await page.waitForTimeout(220);
const ordinaryHover = await page.$eval(selector('d_tv'), (node) => ({
core: getComputedStyle(node.querySelector('.device-core')).backgroundColor,
glyph: getComputedStyle(node.querySelector('.device-core')).color,
shell: getComputedStyle(node.querySelector('.device-shell-frame')).borderColor,
}));
await page.$eval(selector('d_tv'), (node) => {
node.querySelector('.device-core').style.transition = 'none';
node.querySelector('.device-shell-frame').style.transition = 'none';
node.classList.remove('theme-dark');
node.classList.add('theme-light');
});
const lightHover = await page.$eval(selector('d_tv'), (node) => ({
core: getComputedStyle(node.querySelector('.device-core')).backgroundColor,
glyph: getComputedStyle(node.querySelector('.device-core')).color,
shell: getComputedStyle(node.querySelector('.device-shell-frame')).borderColor,
}));
await page.evaluate(() => {
const c = window.__card;
window.__capsuleActionCount = 0;
window.__capsuleOriginalMoreInfo = c._openMoreInfo;
c._openMoreInfo = () => { window.__capsuleActionCount++; };
});
const capsulePositions = [];
for (const [index, position] of ['right', 'bottom', 'left', 'top'].entries()) {
await page.mouse.move(1, 1);
await page.$eval(selector('d_temp'), (node, nextPosition) => {
const shell = node.querySelector('.device-shell');
const badge = node.querySelector('.value-badge');
for (const name of ['right', 'bottom', 'left', 'top']) {
shell.classList.remove(`pos-${name}`);
badge.classList.remove(`pos-${name}`);
}
shell.classList.add(`pos-${nextPosition}`);
badge.classList.add(`pos-${nextPosition}`);
}, position);
const valuePoint = await page.$eval(selector('d_temp'), (node, nextPosition) => {
const value = node.querySelector('.value-badge').getBoundingClientRect();
const center = { x: value.left + value.width / 2, y: value.top + value.height / 2 };
if (nextPosition === 'right') center.x = value.right - 1;
if (nextPosition === 'left') center.x = value.left + 1;
if (nextPosition === 'bottom') center.y = value.bottom - 1;
if (nextPosition === 'top') center.y = value.top + 1;
return center;
}, position);
await page.mouse.move(valuePoint.x, valuePoint.y);
await page.waitForTimeout(220);
const hover = await page.$eval(selector('d_temp'), (node, point) => {
return {
core: getComputedStyle(node.querySelector('.device-core')).backgroundColor,
hovered: node.matches(':hover'),
target: node.getRootNode().elementFromPoint(point.x, point.y)?.className || '',
};
}, valuePoint);
await page.mouse.click(valuePoint.x, valuePoint.y);
const actionCount = await page.evaluate(() => window.__capsuleActionCount);
capsulePositions.push({ position, hover, actionAccepted: actionCount === index + 1 });
}
const capsuleCorePoint = await page.$eval(selector('d_temp'), (node) => {
const core = node.querySelector('.device-core').getBoundingClientRect();
return { x: core.left + core.width / 2, y: core.top + core.height / 2 };
});
await page.mouse.click(capsuleCorePoint.x, capsuleCorePoint.y);
const capsuleActions = await page.evaluate(() => {
const c = window.__card;
const count = window.__capsuleActionCount;
c._openMoreInfo = window.__capsuleOriginalMoreInfo;
delete window.__capsuleOriginalMoreInfo;
delete window.__capsuleActionCount;
return count;
});
await page.$eval(selector('d_tv'), (node) => {
node.classList.remove('theme-light');
node.classList.add('theme-dark');
});
await page.mouse.move(1, 1);
await page.waitForTimeout(220);
const result = await page.evaluate(async ({ beforeUnavailable, afterUnavailable, ordinaryHover, lightHover, capsulePositions, capsuleActions, reference }) => {
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 lightFrame = light?.querySelector('.device-shell-frame');
const lightCore = light?.querySelector('.device-core');
const shellRect = rect(lightFrame);
const coreRect = rect(lightCore);
const presence = node('d_motion');
const unavailable = node('d_kettle');
const text = node('d_tv');
const lqiSamples = ['d_temp', 'd_motion', 'd_window', 'd_lamp'].map((id) => {
const marker = node(id);
const label = marker?.querySelector('.lqi');
const value = Number(label?.textContent?.trim());
if (!label || !Number.isFinite(value)) return null;
const hue = Math.max(0, Math.min(120, Math.round(((value - 40) / 140) * 120)));
const probe = document.createElement('span');
probe.style.color = `hsl(${hue}, 85%, 55%)`;
sr().append(probe);
const expected = getComputedStyle(probe).color;
probe.remove();
return { value, actual: getComputedStyle(label).color, expected };
}).filter(Boolean);
const textCoreRect = rect(text?.querySelector('.device-core'));
const textCoreRadius = parseFloat(
getComputedStyle(text?.querySelector('.device-core')).borderTopLeftRadius,
);
const double = node('d_temp');
const doubleShellRect = rect(double?.querySelector('.device-shell-frame'));
const doubleCoreRect = rect(double?.querySelector('.device-core'));
const doubleValueRect = rect(double?.querySelector('.value-badge'));
const pseudo = getComputedStyle(light, '::before');
lightCore.style.transition = 'none';
lightFrame.style.transition = 'none';
text.querySelector('.device-core').style.transition = 'none';
text.querySelector('.device-shell-frame').style.transition = 'none';
const coreStyle = getComputedStyle(lightCore);
const activeShellStyle = getComputedStyle(lightFrame);
const valueRadius = parseFloat(getComputedStyle(double?.querySelector('.value-badge')).borderTopLeftRadius);
const coreRadiusText = coreStyle.borderTopLeftRadius;
const radiusIsHalf = (value, rectangle) => value.endsWith('%')
? Math.abs(parseFloat(value) - 50) <= 0.01
: Math.abs(parseFloat(value) - rectangle.width / 2) <= 0.5;
const lockNode = node('d_lock');
lockNode.querySelector('.device-core').style.transition = 'none';
lockNode.querySelector('.device-shell-frame').style.transition = 'none';
const darkThemeClassProjected = lockNode.classList.contains('theme-dark');
const darkThemeCore = getComputedStyle(lockNode.querySelector('.device-core')).backgroundColor;
const darkLockGlyph = getComputedStyle(lockNode.querySelector('.device-core')).color;
const darkLockShell = getComputedStyle(lockNode.querySelector('.device-shell-frame')).borderColor;
const originalLockClasses = lockNode.className;
const originalLockStyle = lockNode.getAttribute('style') || '';
lockNode.style.setProperty('--device-base-size', '50.4px');
lockNode.style.setProperty('--dev-scale', '1');
const stateClasses = [
'theme-light', 'theme-dark', 'on', 'open', 'alarm', 'unavail', 'virtual',
'sel', 'lock-locked', 'lock-unlocked',
];
const sampleState = (theme, classes = []) => {
lockNode.classList.remove(...stateClasses);
lockNode.classList.add(`theme-${theme}`, ...classes);
const core = getComputedStyle(lockNode.querySelector('.device-core'));
const shell = getComputedStyle(lockNode.querySelector('.device-shell-frame'));
return {
core: core.backgroundColor,
glyph: core.color,
shell: shell.borderColor,
shellWidth: parseFloat(shell.borderTopWidth),
shellStyle: shell.borderStyle,
opacity: getComputedStyle(lockNode).opacity,
};
};
const stateMatrix = Object.fromEntries(['light', 'dark'].map((theme) => [theme, {
default: sampleState(theme),
active: sampleState(theme, ['on']),
lock: sampleState(theme, ['lock-locked']),
unlock: sampleState(theme, ['lock-unlocked']),
alert: sampleState(theme, ['alarm']),
virtual: sampleState(theme, ['virtual']),
unavailable: sampleState(theme, ['unavail']),
}]));
lockNode.className = originalLockClasses;
lockNode.setAttribute('style', originalLockStyle);
const originalLightStyle = light.getAttribute('style') || '';
const originalDoubleStyle = double.getAttribute('style') || '';
const sizeMatrix = [
{ configured: 32, effective: 28.8 },
{ configured: 56, effective: 50.4 },
{ configured: 96, effective: 86.4 },
].map(({ configured, effective }) => {
light.style.setProperty('--device-base-size', `${effective}px`);
light.style.setProperty('--dev-scale', '1');
double.style.setProperty('--device-base-size', `${effective}px`);
double.style.setProperty('--dev-scale', '1');
const sizedCore = rect(light.querySelector('.device-core'));
const sizedShell = rect(light.querySelector('.device-shell-frame'));
const sizedIcon = rect(light.querySelector('ha-icon'));
const sizedValue = rect(double.querySelector('.value-badge'));
const iconSvg = light.querySelector('ha-icon')?.shadowRoot?.querySelector('svg');
const pathBox = iconSvg?.querySelector('path')?.getBBox();
return {
configured,
effective,
core: sizedCore?.width || 0,
shell: sizedShell?.width || 0,
coreRadius: getComputedStyle(light.querySelector('.device-core')).borderTopLeftRadius,
valueHeight: sizedValue?.height || 0,
valueRadius: parseFloat(getComputedStyle(double.querySelector('.value-badge')).borderTopLeftRadius),
iconViewport: sizedIcon?.width || 0,
paintedWidth: pathBox && sizedIcon ? pathBox.width / 24 * sizedIcon.width : 0,
paintedHeight: pathBox && sizedIcon ? pathBox.height / 24 * sizedIcon.height : 0,
};
});
light.setAttribute('style', originalLightStyle);
double.setAttribute('style', originalDoubleStyle);
light.classList.remove('theme-dark');
light.classList.add('theme-light');
const lightActiveGlyph = getComputedStyle(lightCore).color;
const lightActiveShell = getComputedStyle(lightFrame).borderColor;
light.classList.remove('theme-light');
light.classList.add('theme-dark');
const defaultShell = getComputedStyle(text.querySelector('.device-shell-frame')).borderColor;
text.classList.remove('theme-dark');
text.classList.add('theme-light');
const lightThemeCore = getComputedStyle(text.querySelector('.device-core')).backgroundColor;
text.classList.remove('theme-light');
text.classList.add('theme-dark');
text.classList.add('sel');
const selectedShell = getComputedStyle(text.querySelector('.device-shell-frame')).borderColor;
const selectedCoreDecoration = getComputedStyle(text.querySelector('.device-core')).boxShadow;
text.classList.remove('sel');
text.focus({ focusVisible: true });
const focusVisibleMatched = text.matches(':focus-visible');
const focusShell = getComputedStyle(text.querySelector('.device-shell-frame')).borderColor;
const focusCoreDecoration = getComputedStyle(text.querySelector('.device-core')).boxShadow;
text.blur();
const originalCallService = c.hass.callService;
let actionDispatches = 0;
c.hass.callService = async () => { actionDispatches++; };
light.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await Promise.resolve();
const pressAnimation = lightShell.getAnimations().find((animation) =>
animation.effect?.getTiming().duration === 200);
const pressFrames = pressAnimation?.effect?.getKeyframes?.() || [];
const acceptedActionFeedback = actionDispatches === 1
&& pressAnimation?.effect?.getTiming().duration === 200
&& pressFrames.some((frame) => frame.scale === '0.95');
await new Promise((resolve) => setTimeout(resolve, 240));
const feedbackLifecycleBounded = !lightShell.getAnimations().some((animation) =>
animation.effect?.getTiming().duration === 200);
c._reducedMotion = true;
light.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await Promise.resolve();
const reducedAnimation = lightShell.getAnimations().find((animation) =>
animation.effect?.getTiming().duration === 200);
const reducedFrames = reducedAnimation?.effect?.getKeyframes?.() || [];
const reducedMotionUsesNoScale = !!reducedAnimation
&& reducedFrames.every((frame) => frame.scale == null)
&& reducedFrames.some((frame) => frame.outlineWidth === '2px');
c._cancelDevicePressFeedback();
c._reducedMotion = false;
const enterAction = new KeyboardEvent('keydown', {
key: 'Enter', bubbles: true, composed: true, cancelable: true,
});
light.dispatchEvent(enterAction);
await Promise.resolve();
const keyboardAnimation = lightShell.getAnimations().find((animation) =>
animation.effect?.getTiming().duration === 200);
const keyboardActionFeedback = enterAction.defaultPrevented && actionDispatches === 3
&& keyboardAnimation?.effect?.getKeyframes?.().some((frame) => frame.scale === '0.95');
c._cancelDevicePressFeedback();
c.hass.callService = originalCallService;
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 informationalActionHasNoFeedback = unavailable.querySelector('.device-shell')
.getAnimations().every((animation) => animation.effect?.getTiming().duration !== 200);
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 {
__debug: {
shellWidth: shellRect?.width,
coreWidth: coreRect?.width,
shellCoreRatio: shellRect && coreRect ? shellRect.width / coreRect.width : null,
coreRadiusText,
valueHeight: doubleValueRect?.height,
valueRadius,
lightActiveGlyph,
lightActiveShell,
lightClasses: light.className,
lightShellVariable: getComputedStyle(light).getPropertyValue('--device-shell-stroke'),
lightThemeCore,
focusVisibleMatched,
focusShell,
defaultShell,
focusCoreDecoration,
stateMatrix,
sizeMatrix,
reference,
lightHover,
ordinaryHover,
capsulePositions,
capsuleActions,
lqiSamples,
},
referenceAssetsLoaded: Object.values(reference).every((theme) => Object.values(theme)
.every((state) => state.core.startsWith('rgb')
&& state.glyph.startsWith('rgb') && state.shell.startsWith('rgb'))),
geometryMatchesAt32_56_96: sizeMatrix.every((sample) =>
Math.abs(sample.core - sample.effective) <= 0.5
&& Math.abs(sample.shell / sample.core - 1.26875) < 0.03
&& sample.coreRadius === '50%'
&& Math.abs(sample.valueHeight / sample.core - 0.7875) < 0.015
&& Math.abs(sample.valueRadius - sample.valueHeight / 2) <= 0.5
&& Math.abs(sample.iconViewport / sample.core - 0.55) < 0.01
&& Math.abs(sample.paintedWidth / sample.core - 0.458333) < 0.015
&& Math.abs(sample.paintedHeight / sample.core - 0.458333) < 0.015),
referenceStateMatrixMatches: ['light', 'dark'].every((theme) =>
['default', 'active', 'lock', 'unlock', 'alert'].every((state) =>
stateMatrix[theme][state].core === reference[theme][state].core
&& stateMatrix[theme][state].glyph === reference[theme][state].glyph
&& stateMatrix[theme][state].shell === reference[theme][state].shell
&& Math.abs(stateMatrix[theme][state].shellWidth
- Math.max(1, Math.floor(reference[theme][state].shellWidth * 50.4))) <= 0.5)),
referenceHoverMatches: lightHover.core === reference.light.hover.core
&& lightHover.glyph === reference.light.hover.glyph
&& lightHover.shell === reference.light.default.shell
&& ordinaryHover.core === reference.dark.hover.core
&& ordinaryHover.glyph === reference.dark.hover.glyph
&& ordinaryHover.shell === reference.dark.default.shell,
virtualUsesThemeDefaultWithDashedShell: ['light', 'dark'].every((theme) =>
stateMatrix[theme].virtual.core === reference[theme].default.core
&& stateMatrix[theme].virtual.glyph === reference[theme].default.glyph
&& stateMatrix[theme].virtual.shell === reference[theme].default.shell
&& stateMatrix[theme].virtual.shellStyle === 'dashed'),
sharedShellGeometry: !!shellRect && !!coreRect
&& Math.abs(shellRect.width / coreRect.width - 1.26875) < 0.03,
iconCoreIsCircle: !!coreRect && radiusIsHalf(coreRadiusText, coreRect),
textCoreIsStadium: !!textCoreRect && textCoreRect.width > textCoreRect.height
&& Math.abs(textCoreRadius - textCoreRect.height / 2) <= 0.5,
valueBadgeIsPill: !!doubleValueRect
&& Math.abs(valueRadius - doubleValueRect.height / 2) <= 0.5,
packageShadowColor: getComputedStyle(lightFrame).boxShadow.includes('37, 40, 45'),
noBackdropBlur: getComputedStyle(lightFrame).backdropFilter === 'none',
activeUsesPackageAmber: getComputedStyle(lightCore).backgroundColor === 'rgb(240, 160, 12)'
&& activeShellStyle.borderColor === 'rgb(240, 160, 12)'
&& getComputedStyle(lightCore).color === 'rgb(37, 37, 37)',
lightActiveUsesWhiteGlyphAndAmberShell: lightActiveGlyph === 'rgb(255, 255, 255)'
&& lightActiveShell === 'rgb(240, 160, 12)',
darkDefaultShellUsesPackageStroke: defaultShell === 'rgba(37, 37, 37, 0.75)',
darkHoverChangesCoreOnly: ordinaryHover.core === 'rgb(12, 130, 240)'
&& ordinaryHover.glyph === 'rgb(37, 37, 37)'
&& ordinaryHover.shell === 'rgba(37, 37, 37, 0.75)',
selectionDecoratesCoreNotShell: selectedShell === defaultShell
&& selectedCoreDecoration.includes('240, 160, 12'),
focusDecoratesCoreNotShell: focusVisibleMatched && focusShell === defaultShell
&& focusCoreDecoration.includes('12, 130, 240'),
acceptedActionFeedback,
feedbackLifecycleBounded,
reducedMotionUsesNoScale,
keyboardActionFeedback,
informationalActionHasNoFeedback,
lightThemeCoreIsWhite: lightThemeCore === 'rgb(255, 255, 255)',
darkThemeCoreIs252525: darkThemeClassProjected && darkThemeCore === 'rgb(37, 37, 37)',
darkLockUsesWhiteGlyphAndDarkShell: darkLockGlyph === 'rgb(255, 255, 255)'
&& darkLockShell === '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',
lqiUsesContinuousComputedColor: lqiSamples.length >= 3
&& lqiSamples.every((sample) => sample.actual === sample.expected)
&& lqiSamples.some((sample) => sample.value > 41 && sample.value < 179
&& sample.actual !== 'rgb(240, 160, 12)'),
unavailableHasNoHoverOrMotion: JSON.stringify(beforeUnavailable) === JSON.stringify(afterUnavailable)
&& beforeUnavailable.opacity === '0.35' && !unavailable.querySelector('.device-pulse'),
valueCapsuleOwnsHoverAndActionAtEveryPosition: capsulePositions.length === 4
&& capsulePositions.every(({ hover, actionAccepted }) => hover.hovered
&& hover.core === 'rgb(12, 130, 240)'
&& String(hover.target).includes('device-shell-frame')
&& actionAccepted)
&& capsuleActions === 5,
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, ordinaryHover, lightHover, capsulePositions, capsuleActions, reference });
const { __debug, ...out } = result;
if (Object.values(out).some((value) => value !== true)) {
console.error(`device icon debug: ${JSON.stringify(__debug, null, 2)}`);
}
checkAll(out);
await finish(browser, out);
+186
View File
@@ -0,0 +1,186 @@
// Issue #213: dense fractional geometry plus painted-centroid proof at common DPRs.
import { launch, checkAll, finish } from './serve.mjs';
const dprs = [1, 1.25, 1.5, 2];
const paintedSizes = [24, 24.25, 31.75, 32, 47.25, 50.4, 55.75, 63.25, 79.75, 95.5, 111.75, 112];
const evidence = [];
for (const dpr of dprs) {
const { page, browser } = await launch(
{ width: 980, height: 760 }, dpr, [], { colorScheme: 'light' },
);
await page.evaluate(async () => {
const c = window.__card;
c.hass = { ...c.hass, themes: { ...(c.hass.themes || {}), darkMode: false } };
c._regSignature = '';
c._maybeRebuildDevices();
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const root = c.shadowRoot || c.renderRoot;
const marker = root.querySelector('.dev[data-id="d_light1"]');
const layer = marker.closest('.devlayer');
for (const node of layer.children) {
if (node !== marker) node.style.display = 'none';
}
for (const node of layer.parentElement.children) {
if (node !== layer) node.style.visibility = 'hidden';
}
marker.className = 'dev theme-light';
marker.style.left = '50%';
marker.style.top = '50%';
marker.style.setProperty('--dev-scale', '1');
marker.style.setProperty('--device-face-bg', 'rgb(0 255 0)');
marker.style.setProperty('--device-face-fg', 'rgb(0 255 0)');
marker.style.setProperty('--device-shell-stroke', 'rgb(255 0 0)');
for (const extra of marker.querySelectorAll(
'.device-pulse, .activity-dot, .newdot, .habadge, .lqi, .device-sections',
)) extra.style.display = 'none';
marker.querySelector('ha-icon').style.visibility = 'hidden';
marker.querySelector('.device-core').style.transition = 'none';
marker.querySelector('.device-core').style.boxShadow = 'none';
const frame = marker.querySelector('.device-shell-frame');
frame.style.transition = 'none';
frame.style.boxShadow = 'none';
frame.style.border = '0';
// A solid diagnostic shell makes the alpha-weighted centroid stable even
// when the production stroke is only one sparse physical pixel wide.
frame.style.background = 'rgb(255 0 0)';
root.querySelector('.stage').style.background = 'rgb(0 0 255)';
});
const dense = await page.evaluate(async () => {
const root = window.__card.shadowRoot || window.__card.renderRoot;
const marker = root.querySelector('.dev[data-id="d_light1"]');
const core = marker.querySelector('.device-core');
const frame = marker.querySelector('.device-shell-frame');
let maxDx = 0;
let maxDy = 0;
let maxSizeError = 0;
let samples = 0;
for (let quarter = 24 * 4; quarter <= 112 * 4; quarter++) {
const size = quarter / 4;
marker.style.setProperty('--device-base-size', `${size}px`);
// A style/layout read is intentional: this smoke enumerates browser
// quantisation, production has no per-marker measurement loop.
const a = core.getBoundingClientRect();
const b = frame.getBoundingClientRect();
maxDx = Math.max(maxDx, Math.abs((a.left + a.width / 2) - (b.left + b.width / 2)));
maxDy = Math.max(maxDy, Math.abs((a.top + a.height / 2) - (b.top + b.height / 2)));
maxSizeError = Math.max(maxSizeError, Math.abs(a.width - size), Math.abs(a.height - size));
samples++;
}
return { samples, maxDx, maxDy, maxSizeError };
});
const painted = [];
for (const size of paintedSizes) {
const clip = await page.evaluate(async (nextSize) => {
const root = window.__card.shadowRoot || window.__card.renderRoot;
const marker = root.querySelector('.dev[data-id="d_light1"]');
marker.style.setProperty('--device-base-size', `${nextSize}px`);
await new Promise((resolve) => requestAnimationFrame(resolve));
const r = marker.querySelector('.device-core').getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
// Fixed integer clip bounds remove screenshot-crop quantisation from the
// centroid comparison; all tested shells fit with a generous margin.
return { x: Math.floor(cx - 85), y: Math.floor(cy - 85), width: 170, height: 170 };
}, size);
await page.evaluate(() => {
const marker = (window.__card.shadowRoot || window.__card.renderRoot)
.querySelector('.dev[data-id="d_light1"]');
marker.querySelector('.device-core').style.visibility = 'hidden';
marker.querySelector('.device-shell-frame').style.visibility = 'visible';
});
const shellPng = await page.screenshot({ type: 'png', clip });
await page.evaluate(() => {
const marker = (window.__card.shadowRoot || window.__card.renderRoot)
.querySelector('.dev[data-id="d_light1"]');
marker.querySelector('.device-core').style.visibility = 'visible';
marker.querySelector('.device-shell-frame').style.visibility = 'hidden';
});
const corePng = await page.screenshot({ type: 'png', clip });
await page.evaluate(() => {
const marker = (window.__card.shadowRoot || window.__card.renderRoot)
.querySelector('.dev[data-id="d_light1"]');
marker.querySelector('.device-shell-frame').style.visibility = 'visible';
});
const centroid = await page.evaluate(async ({ shellBase64, coreBase64 }) => {
const point = async (base64, channel) => {
const raw = atob(base64);
const bytes = Uint8Array.from(raw, (char) => char.charCodeAt(0));
const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(bitmap, 0, 0);
const pixels = context.getImageData(0, 0, bitmap.width, bitmap.height).data;
let weight = 0, weightedX = 0, weightedY = 0;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let y = 0; y < bitmap.height; y++) for (let x = 0; x < bitmap.width; x++) {
const i = (y * bitmap.width + x) * 4;
const [r, g, b, a] = [pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]];
const primary = channel === 'red' ? r : g;
const other = channel === 'red' ? Math.max(g, b) : Math.max(r, b);
const sample = Math.max(0, primary - other) * a / 255;
weight += sample; weightedX += x * sample; weightedY += y * sample;
if (sample > 0) {
minX = Math.min(minX, x); minY = Math.min(minY, y);
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
}
}
return {
x: weightedX / weight, y: weightedY / weight, weight,
boundsX: (minX + maxX) / 2, boundsY: (minY + maxY) / 2,
};
};
// Capture each paint layer in isolation. Subtracting the large core from
// a one-pixel annulus amplifies harmless edge AA into a false centroid.
const red = await point(shellBase64, 'red');
const green = await point(coreBase64, 'green');
return {
dx: Math.abs(red.x - green.x),
dy: Math.abs(red.y - green.y),
boundsDx: Math.abs(red.boundsX - green.boundsX),
boundsDy: Math.abs(red.boundsY - green.boundsY),
redWeight: red.weight,
greenWeight: green.weight,
};
}, { shellBase64: shellPng.toString('base64'), coreBase64: corePng.toString('base64') });
painted.push({ size, ...centroid });
}
const mutant = await page.evaluate(async () => {
const root = window.__card.shadowRoot || window.__card.renderRoot;
const marker = root.querySelector('.dev[data-id="d_light1"]');
marker.style.setProperty('--device-base-size', '50.4px');
const frame = marker.querySelector('.device-shell-frame');
frame.style.transform = 'translateX(1px)';
await new Promise((resolve) => requestAnimationFrame(resolve));
const core = marker.querySelector('.device-core').getBoundingClientRect();
const shell = frame.getBoundingClientRect();
return Math.abs((core.left + core.width / 2) - (shell.left + shell.width / 2));
});
evidence.push({ dpr, dense, painted, mutant });
await browser.close();
}
const result = {
denseQuarterPixelMatrixCovers24To112: evidence.every((row) => row.dense.samples === 353),
domCentresShareOneLayoutQuantum: evidence.every((row) =>
row.dense.maxDx <= 0.02 && row.dense.maxDy <= 0.02 && row.dense.maxSizeError <= 0.02),
paintedCentroidsStayWithinCalibratedHalfCssPixel: evidence.every((row) => row.painted.every((sample) =>
sample.redWeight > 0 && sample.greenWeight > 0
&& sample.dx <= row.dpr / 2 + 0.1 && sample.dy <= row.dpr / 2 + 0.1)),
paintedSupportStaysWithinOneRasterPixel: evidence.every((row) => row.painted.every((sample) =>
sample.boundsDx <= 1.01 && sample.boundsDy <= 1.01)),
onePixelNudgeMutantIsDetected: evidence.every((row) => row.mutant >= 0.98),
};
if (Object.values(result).some((value) => value !== true)) {
console.error(`pixel alignment evidence: ${JSON.stringify(evidence, null, 2)}`);
}
checkAll(result);
await finish(null, result);
+15 -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 || [])]
@@ -37,6 +37,11 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
const preview = sr().querySelector('hp-device-preview');
await preview?.updateComplete;
preview?.dispatchEvent(new PointerEvent('pointerover', {
pointerType: 'mouse', bubbles: true, composed: true,
}));
const previewHoverGatePropagates = c.hasAttribute('data-pointer-hover')
&& preview?.hasAttribute('data-pointer-hover');
const previewFace = face(preview?.renderRoot?.querySelector('.dev'));
const providerShown = /demo/i.test(preview?.renderRoot?.querySelector('.previewfacts')?.textContent || '');
@@ -160,9 +165,17 @@ const res = await page.evaluate(async () => {
const staticNode = card.renderRoot.querySelector('.dev[data-id="d_light1"]');
const staticFace = face(staticNode);
const staticBadgeFace = face(card.renderRoot.querySelector('.dev[data-id="d_temp"]'));
const resolvedBaseContract = [
sr().querySelector('.dev[data-id="d_light1"]'),
persistedPreview?.renderRoot?.querySelector('.dev'),
staticNode,
].every((node) => node
&& getComputedStyle(node).getPropertyValue('--device-visual-factor').trim() === ''
&& parseFloat(getComputedStyle(node).getPropertyValue('--device-base-size')) > 0);
return {
allFacesPresent: !!planFace && !!previewFace && !!staticFace,
previewHoverGatePropagates,
planPreviewEqual: JSON.stringify(planFace) === JSON.stringify(previewFace),
planStaticEqual: JSON.stringify(planFace) === JSON.stringify(staticFace),
providerShown,
@@ -175,6 +188,7 @@ const res = await page.evaluate(async () => {
badgeBounds,
valueBadgePlanPreviewParity: JSON.stringify(planBadgeFace) === JSON.stringify(persistedPreviewFace),
valueBadgePlanStaticParity: JSON.stringify(planBadgeFace) === JSON.stringify(staticBadgeFace),
resolvedBaseContract,
bottomBadgeLqiStacked,
staticBindingHook: staticNode?.getAttribute('data-binding-status') === 'active',
};
+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.
+18 -4
View File
@@ -38,12 +38,26 @@ const res = await page.evaluate(async () => {
const metaSz = parseFloat(getComputedStyle(lbl.querySelector('.rlmetrics')).fontSize);
out.metricsRatio = Math.round((metaSz / nameSz) * 100) / 100;
} else out.metricsRatio = 'no-metrics';
// 4) касание помечает сессию как тач и гасит тултип
// 4) modality lives on this card: touch clears transient hover, a synthetic
// compatibility mouse cannot restore it, and a real mouse can.
c._tip = { x: 1, y: 1, title: 't', meta: 'm' };
c._hoverRoom = { space: c._space, room: c._curSpaceCfg.rooms[0] };
c._notePointer(new PointerEvent('pointerdown', { pointerType: 'touch' }));
out.touchClearsTip = c._tip === null;
c._showTip(new MouseEvent('mousemove', { clientX: 5, clientY: 5 }), 'x', 'y');
out.noTipAfterTouch = !c._tip;
out.touchClearsTransientHover = c._tip === null && c._hoverRoom === null
&& !c.hasAttribute('data-pointer-hover');
const compatibilityMouse = new PointerEvent('pointermove', {
pointerType: 'mouse', clientX: 5, clientY: 5,
});
Object.defineProperty(compatibilityMouse, 'sourceCapabilities', {
value: { firesTouchEvents: true },
});
c._showTip(compatibilityMouse, 'compatibility', 'ignored');
out.compatibilityMouseIgnored = !c._tip && !c.hasAttribute('data-pointer-hover');
c._showTip(new PointerEvent('pointermove', {
pointerType: 'mouse', clientX: 7, clientY: 7,
}), 'real mouse', 'restored');
out.realMouseRestoresHover = c._tip?.title === 'real mouse'
&& c.hasAttribute('data-pointer-hover');
return out;
});
check('metricsRatio 0.75', res.metricsRatio, 0.75);
+97
View File
@@ -0,0 +1,97 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const base = window.__card;
const ids = base._model.map((space) => space.id);
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const mount = async (config) => {
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', ...config });
card.hass = base.hass;
document.body.appendChild(card);
await wait(450);
card.hass = { ...base.hass };
await card.updateComplete;
return card;
};
const root = (card) => card.shadowRoot || card.renderRoot;
localStorage.setItem('houseplan_card_nav_v1', JSON.stringify({ space: ids[1] }));
location.hash = `#space=${ids[1]}`;
const fixedId = await mount({ floor: ids[0] });
out.idWinsEveryInitialSource = fixedId._space === ids[0]
&& fixedId._fixedFloorState().kind === 'valid';
out.onlyFixedTab = root(fixedId).querySelectorAll('[data-hp="space-tab"]').length === 1
&& root(fixedId).querySelector('[data-hp="space-tab"]')?.dataset.id === ids[0]
&& !root(fixedId).querySelector('.tabadd');
fixedId._pickSpace(ids[1]);
await fixedId.updateComplete;
out.tabAndTransitionBlocked = fixedId._space === ids[0]
&& fixedId._commitSpace(ids[1]) === false;
fixedId._saveNav();
out.fixedDoesNotWriteNav = JSON.parse(localStorage.getItem('houseplan_card_nav_v1')).space === ids[1];
location.hash = `#space=${ids[1]}`;
window.dispatchEvent(new HashChangeEvent('hashchange'));
await fixedId.updateComplete;
out.hashIgnored = fixedId._space === ids[0];
const fixedIndex = await mount({ floor: 1, kiosk: true, cycle: 1 });
out.indexUsesServerOrder = fixedIndex._loadOk === true && fixedIndex._space === ids[1];
const indexStart = fixedIndex._space;
fixedIndex._cyclePausedUntil = 0;
fixedIndex._cycleTick();
await fixedIndex.updateComplete;
out.fixedKioskCycleDisabled = fixedIndex._space === indexStart && fixedIndex._cycleTimer == null;
out.fixedKioskDotsHidden = !root(fixedIndex).querySelector('.kioskdots');
const stage = root(fixedIndex).querySelector('.stage');
stage.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, pointerId: 41, clientX: 600, clientY: 300,
}));
stage.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, composed: true, pointerId: 41, clientX: 420, clientY: 305,
}));
await fixedIndex.updateComplete;
out.fixedKioskSwipeDisabled = fixedIndex._space === indexStart;
const remount = await mount({ floor: ids[0] });
out.remountStillFixed = remount._space === ids[0];
const invalid = await mount({ floor: '__missing_fixed_floor__' });
const invalidRoot = root(invalid);
out.invalidFailsVisibly = invalidRoot.querySelector('[data-fixed-floor-state="invalid"]')
?.dataset.fixedFloorReason === 'unknown-id'
&& !!invalidRoot.querySelector('[role="alert"]')
&& !invalidRoot.querySelector('.stage');
location.hash = '';
const ordinary = await mount({});
out.legacyCardStillRestoresNav = ordinary._space === ids[1]
&& ordinary._fixedFloorState().kind === 'absent';
const editor = document.createElement('houseplan-card-editor');
editor.hass = base.hass;
editor.setConfig({ type: 'custom:houseplan-card', floor: 1, title: 'Fixed' });
document.body.appendChild(editor);
await wait(150);
let changed = null;
editor.addEventListener('config-changed', (event) => { changed = event.detail.config; });
editor._valueChanged(new CustomEvent('value-changed', {
detail: { value: { ...editor._formData, title: 'Changed' } },
}));
out.editorPreservesYamlIndex = changed?.floor === 1 && changed?.title === 'Changed';
editor._valueChanged(new CustomEvent('value-changed', {
detail: { value: { ...editor._formData, floor: '' } },
}));
out.editorClearDeletesKey = changed && !Object.hasOwn(changed, 'floor');
for (const card of [fixedId, fixedIndex, remount, invalid, ordinary, editor]) card.remove();
location.hash = '';
return out;
});
checkAll(res);
await finish(browser, res);
+21 -8
View File
@@ -52,12 +52,28 @@ const out = await page.evaluate(async () => {
// Keep the smoke local: editor transactions still mutate the real config,
// invalidate derived models and history, but do not need a websocket write.
c._saveConfig = () => { c._cfgEpoch++; c._modelCache = null; c.requestUpdate(); };
c.requestUpdate();
await c.updateComplete;
c._tool = 'partition';
c._drawWallField = '15';
c._partitionClick([100, 100], false);
c._partitionClick([300, 100], false);
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
const clickAt = (x, y) => {
const rect = stage.getBoundingClientRect();
const view = c._viewOr(c._baseVb());
return new MouseEvent('click', {
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
bubbles: true,
});
};
const drawAndFinish = (a, b, cm) => {
c._activateMarkupTool('draw');
c._drawWallField = String(cm);
c._markupClick(clickAt(...a));
c._markupClick(clickAt(...b));
c._activateMarkupTool('select');
};
drawAndFinish([100, 100], [300, 100], 15);
let sp = c._serverCfg.spaces[0];
o.partitionCreated = sp.partitions?.length === 1 && sp.partitions[0].cm === 15;
@@ -174,10 +190,7 @@ const out = await page.evaluate(async () => {
c._deletePhysicalSelection();
o.deleteOnDraftRemovesWholeOutline = !c._serverCfg.spaces[0].room_drafts;
c._tool = 'partition';
c._drawWallField = '120';
c._partitionClick([400, 100], false);
c._partitionClick([500, 100], false);
drawAndFinish([400, 100], [500, 100], 120);
o.invalidThicknessCreatesNothing = !c._serverCfg.spaces[0].partitions;
return o;
+2 -2
View File
@@ -239,7 +239,7 @@ const res = await page.evaluate(async () => {
const poolBeforeHover = sr().querySelector('.glow-pool');
const gradientBeforeHover = sr().querySelector('radialGradient[id^="hp-glow-"]');
const roomEl = sr().querySelector('.room');
roomEl?.dispatchEvent(new MouseEvent('mouseenter'));
roomEl?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' }));
await c.updateComplete;
const hoverFill = sr().querySelector('.room-hover-fill');
const hoverFillLayer = sr().querySelector('.room-hover-fill-layer');
@@ -263,7 +263,7 @@ const res = await page.evaluate(async () => {
// late outline must follow it; absence is not a layer-order failure.
&& (!wallLayer
|| !!(wallLayer.compareDocumentPosition(hoverOutlineLayer) & Node.DOCUMENT_POSITION_FOLLOWING));
roomEl?.dispatchEvent(new MouseEvent('mouseleave'));
roomEl?.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'mouse' }));
await c.updateComplete;
return out;
});
+10 -2
View File
@@ -47,6 +47,14 @@ const res = await page.evaluate(async () => {
return help?.shadowRoot?.querySelector('.tooltip')
|| root().querySelector('hp-dialog')?.shadowRoot?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
};
const enterWithRealMouse = (target) => {
target?.dispatchEvent(new PointerEvent('pointerover', {
pointerType: 'mouse', bubbles: true, composed: true,
}));
target?.dispatchEvent(new PointerEvent('pointerenter', {
pointerType: 'mouse', bubbles: false, composed: true,
}));
};
// Empty or incomplete content must not leave a dead focus target behind.
const incompleteHelp = document.createElement('hp-help');
@@ -103,7 +111,7 @@ const res = await page.evaluate(async () => {
&& !roleButton.hasAttribute('aria-describedby');
const beforeHoverGeometry = scrollGeometry();
roleButton?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse', bubbles: true, composed: true }));
enterWithRealMouse(roleButton);
await wait(330);
out.mouseHover = roleButton?.getAttribute('aria-expanded') === 'true'
&& roleButton.getAttribute('aria-describedby') === roleDescription?.id
@@ -118,7 +126,7 @@ const res = await page.evaluate(async () => {
// away from the form control the user was editing.
const nameInput = root().querySelector('hp-dialog .namein');
nameInput?.focus();
roleButton?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse', bubbles: true, composed: true }));
enterWithRealMouse(roleButton);
await wait(330);
nameInput?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
+3 -2
View File
@@ -44,8 +44,9 @@ Object.assign(out, await page.evaluate(async () => {
o.ghostHasNoState = !!g && !g.classList.contains('on') && !g.classList.contains('open')
&& !g.classList.contains('unavail') && !g.classList.contains('alarm');
// и он синий, а не тёмный — отличим от недоступного устройства
o.ghostIsBlue = !!g && getComputedStyle(g).borderStyle.includes('dashed')
&& getComputedStyle(g).borderColor !== 'rgb(255, 255, 255)';
const ghostShell = g?.querySelector('.device-shell-frame');
o.ghostIsBlue = !!ghostShell && getComputedStyle(ghostShell).borderStyle.includes('dashed')
&& getComputedStyle(ghostShell).borderColor !== 'rgb(255, 255, 255)';
// тумблер локальный: конфиг не трогается
o.toggleIsLocal = c._serverCfg.settings.show_all === undefined;
+152
View File
@@ -0,0 +1,152 @@
// #203: `show_names:false` means no permanent room labels in every read-only
// renderer. Plan keeps its editor-only card and saving never discards layout.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 850 });
const out = await page.evaluate(async () => {
const card = window.__card;
const root = (node = card) => node.renderRoot || node.shadowRoot;
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const settle = async () => { await card.updateComplete; await frame(); };
const waitMode = async () => {
await settle();
while (card._modeTransitionBusy) await frame();
};
const labels = (node = card) => root(node).querySelectorAll('[data-hp="room-label"]');
const htmlLabels = (node = card) => root(node).querySelectorAll('div.roomlabel[data-hp="room-label"]');
const svgLabels = (node = card) => root(node).querySelectorAll('text.rlabel[data-hp="room-label"]');
card._setMode('view');
await waitMode();
const space = card._serverCfg.spaces.find((item) => item.id === card._space);
delete space.plan_url;
delete space.plan_aspect;
const room = space.rooms.find((item) => item.name && item.id && item.area)
|| space.rooms.find((item) => item.name && item.id);
if (!room) throw new Error('fixture has no named room');
space.settings = {
...(space.settings || {}), show_borders: true, show_names: true,
label_temp: true, label_hum: true, label_lqi: true, label_light: true,
};
const layoutKey = `rl_${room.id}`;
card._layout[layoutKey] = { s: space.id, x: 0.23, y: 0.31, k: 1.6 };
card._cfgEpoch++;
card._regSignature = '';
card.requestUpdate();
await settle();
const first = root().querySelector(`[data-hp="room-label"][data-id="${room.id}"]`);
const savedLayout = JSON.stringify(card._layout[layoutKey]);
const trueState = {
oneHtmlPerNamedRoom: htmlLabels().length === space.rooms.filter((item) => item.name).length,
noLegacySvg: svgLabels().length === 0,
savedScaleApplied: first?.getAttribute('style')?.includes('--rl-scale:1.6') === true,
areaIconKept: !room.area || !!first?.querySelector('.rlgo'),
};
// Cancel: the live preview follows the pending false, but persisted config
// and the room-label layout return untouched when the dialog is dismissed.
card._openSpaceDialog('edit', space.id);
card._spaceDialog = { ...card._spaceDialog, showNames: false };
card.requestUpdate();
await settle();
const livePreviewHidesAll = labels().length === 0;
card._spaceDialog = null;
card.requestUpdate();
await settle();
const cancelRestoresTrue = space.settings.show_names === true && htmlLabels().length > 0
&& JSON.stringify(card._layout[layoutKey]) === savedLayout;
// Save + reopen: exercise the real existing-space persistence path.
card._openSpaceDialog('edit', space.id);
card._spaceDialog = { ...card._spaceDialog, showNames: false };
await card._saveSpaceDialog();
await settle();
card._openSpaceDialog('edit', space.id);
const reopenReadsFalse = card._spaceDialog?.showNames === false
&& space.settings.show_names === false;
card._spaceDialog = null;
card.requestUpdate();
await settle();
const flatFalseHasNoLabels = labels().length === 0
&& htmlLabels().length === 0 && svgLabels().length === 0;
card._setMode('plan');
await waitMode();
const planFalseKeepsEditorCard = htmlLabels().length > 0
&& svgLabels().length === 0 && !!root().querySelector('.rlgearbtn');
card._setMode('view');
await waitMode();
const viewAfterPlanStillHidden = labels().length === 0;
// The Labs registry flag is intentionally expired; use the same dormant
// renderer fixture hook as the canonical Stage 2 smokes.
const active = Object.freeze(['iso']);
card._onLabsSnapshot({ active, space: '' });
window.__hpLabs = active;
await settle();
card._setProjection('iso');
await settle();
const isoFalseHasNoLabels = !!root().querySelector('[data-hp="iso-walls"]')
&& labels().length === 0;
card._setProjection('flat');
await settle();
await customElements.whenDefined('houseplan-space-card');
const cfgFalse = structuredClone(card._serverCfg);
const layout = structuredClone(card._layout);
const staticHass = {
...card.hass,
callWS: async (message) => {
if (message.type === 'houseplan/config/get') return { config: cfgFalse, rev: 1 };
if (message.type === 'houseplan/layout/get') return { layout, rev: 1 };
return { ok: true };
},
};
const host = document.createElement('div');
document.body.appendChild(host);
const compact = document.createElement('houseplan-space-card');
compact.setConfig({ type: 'custom:houseplan-space-card', space: space.id, show_button: false });
compact.hass = staticHass;
host.appendChild(compact);
const deadline = Date.now() + 6000;
while (!root(compact)?.querySelector('.hp-static-stage') && Date.now() < deadline) {
await new Promise((done) => setTimeout(done, 60));
}
await compact.updateComplete;
const compactFalseHasNoLabels = labels(compact).length === 0
&& htmlLabels(compact).length === 0 && svgLabels(compact).length === 0;
// Turning names back on restores the existing HTML card and saved layout.
space.settings.show_names = true;
card._cfgEpoch++;
card.requestUpdate();
await settle();
const restored = root().querySelector(`[data-hp="room-label"][data-id="${room.id}"]`);
const trueRestoresExistingCard = !!restored
&& restored.tagName.toLowerCase() === 'div'
&& restored.getAttribute('style')?.includes('--rl-scale:1.6') === true
&& JSON.stringify(card._layout[layoutKey]) === savedLayout;
host.remove();
card._onLabsSnapshot({ active: Object.freeze([]), space: '' });
window.__hpLabs = Object.freeze([]);
await settle();
return {
...trueState,
livePreviewHidesAll,
cancelRestoresTrue,
reopenReadsFalse,
flatFalseHasNoLabels,
planFalseKeepsEditorCard,
viewAfterPlanStillHidden,
isoFalseHasNoLabels,
compactFalseHasNoLabels,
trueRestoresExistingCard,
};
});
checkAll(out);
await finish(browser, out);
+6 -6
View File
@@ -57,10 +57,10 @@ const out2 = await page.evaluate(async () => {
await new Promise((r) => setTimeout(r, 80));
};
const fs = (el) => parseFloat(getComputedStyle(el).fontSize);
// базовый --icon-size в px: ширина немасштабированного бейджа минус рамка 1px×2
// Базовый --icon-size в px — фактический package core без shell stroke.
const plain = [...sr().querySelectorAll('.dev')].find(
(e) => !e.classList.contains('ghost') && !e.classList.contains('valonly') && !(e.getAttribute('style') || '').includes('--dev-scale'));
const iconPx = plain.getBoundingClientRect().width - 2;
const iconPx = plain.querySelector('.device-core').getBoundingClientRect().width;
// --- бейдж значения (valonly) ---
await setMarker({ display: 'value', size: 1 });
@@ -72,19 +72,19 @@ const out2 = await page.evaluate(async () => {
const v2 = devEl().getBoundingClientRect();
const vf2 = fs(devEl().querySelector('.valtext'));
o.valFontScales = vf2 / vf1 > 1.8 && vf2 / vf1 < 2.2;
o.valPlateScales = (v2.height - 2) / (v1.height - 2) > 1.8 && (v2.height - 2) / (v1.height - 2) < 2.2
o.valPlateScales = v2.height / v1.height > 1.8 && v2.height / v1.height < 2.2
&& v2.width / v1.width > 1.6 && v2.width / v1.width < 2.4;
// --- temp-плашка (.tval) рядом со значком ---
await setMarker({ size: 1 });
const t1 = devEl().querySelector('.value-badge').getBoundingClientRect();
const tf1 = fs(devEl().querySelector('.value-badge'));
// дефолт: line-height 0.68 * icon-size + рамка 1px×2
o.tvalDefaultKept = Math.abs(t1.height - (iconPx * 0.68 + 2)) < 1;
// Package Double section height is 0.7875 of the icon core.
o.tvalDefaultKept = Math.abs(t1.height - iconPx * 0.7875) < 1;
await setMarker({ size: 2 });
const t2 = devEl().querySelector('.value-badge').getBoundingClientRect();
const tf2 = fs(devEl().querySelector('.value-badge'));
o.tvalScales = (t2.height - 2) / (t1.height - 2) > 1.8 && (t2.height - 2) / (t1.height - 2) < 2.2
o.tvalScales = t2.height / t1.height > 1.8 && t2.height / t1.height < 2.2
&& tf2 / tf1 > 1.8 && tf2 / tf1 < 2.2;
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== 'd_temp');
+4 -4
View File
@@ -248,11 +248,11 @@ Object.assign(out, await page.evaluate(async () => {
const farIcon = await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
// the badge is --dev-size PLUS a 1px border on each side, and the border
// is not part of the scaled geometry (same correction as smoke_icon_scale)
// Measure the package core: shell stroke/shadow are decoration, while the
// saved icon geometry must still scale with the plan.
const size = () => {
const el = sr.querySelector('.devlayer .dev');
return Math.round((el.getBoundingClientRect().width - 2) * 100) / 100;
const el = sr.querySelector('.devlayer .dev .device-core');
return Math.round(el.getBoundingClientRect().width * 100) / 100;
};
const o = {};
c._resetZoom(); await c.updateComplete;
+1 -1
View File
@@ -118,7 +118,7 @@ const res = await page.evaluate(async () => {
const holdRing = holdEl?.querySelector('.device-pulse.continuous.presence i:first-child');
const hAfter = holdRing ? getComputedStyle(holdRing) : null;
out.holdAnimated = !!hAfter && hAfter.animationName === 'hp-pulse-continuous';
out.holdAnimationCalm = !!hAfter && hAfter.animationDuration === '2.4s';
out.holdAnimationCalm = !!hAfter && hAfter.animationDuration === '3.6s';
out.holdBgNeutral = !!holdEl && !!neutral
&& getComputedStyle(holdEl).backgroundColor === getComputedStyle(neutral).backgroundColor;
out.holdNoFlashClass = !!holdEl && !holdEl.classList.contains('activity-event');
+48
View File
@@ -43,6 +43,33 @@ const out = await page.evaluate(async () => {
const contactValues = c._contactCandidates().map((item) => item.value);
const lockValues = c._lockCandidates().map((item) => item.value);
const openings = c._openingsR;
const lockBadge = sr().querySelector('.oplock');
const originalLockClass = lockBadge?.className || '';
const lockVisual = (theme, state) => {
lockBadge.className = `oplock theme-${theme} ${state}`;
const shell = lockBadge.querySelector('.oplock-shell');
const core = lockBadge.querySelector('.oplock-core');
const icon = lockBadge.querySelector('ha-icon');
const shellStyle = getComputedStyle(shell);
const coreStyle = getComputedStyle(core);
const shellRect = shell.getBoundingClientRect();
const coreRect = core.getBoundingClientRect();
const iconRect = icon.getBoundingClientRect();
return {
shell: shellStyle.borderColor,
core: coreStyle.backgroundColor,
glyph: coreStyle.color,
shellCoreRatio: shellRect.width / coreRect.width,
glyphCoreRatio: iconRect.width / coreRect.width,
footprint: shellRect.width,
};
};
const lockVisualMatrix = Object.fromEntries(['light', 'dark'].map((theme) => [theme, {
locked: lockVisual(theme, 'locked'),
unlocked: lockVisual(theme, 'unlocked'),
unknown: lockVisual(theme, 'unknown'),
}]));
if (lockBadge) lockBadge.className = originalLockClass;
const result = {
entityTombstoneCandidate: contactValues.includes('binary_sensor.window'),
deviceTombstoneCandidate: lockValues.includes('lock.front_door'),
@@ -55,6 +82,27 @@ const out = await page.evaluate(async () => {
sharedContactDrivesBoth: openings.length === 2
&& openings.every((opening) => c._openingAmt(opening) === 1),
sharedLockRendersTwice: sr().querySelectorAll('.oplock').length === 2,
openingLockUsesPackageGeometryAndStates:
Object.values(lockVisualMatrix).every((theme) => Object.values(theme).every((state) =>
Math.abs(state.shellCoreRatio - 1.26875) < 0.03
&& Math.abs(state.glyphCoreRatio - 0.55) < 0.03
&& state.footprint > 0))
&& lockVisualMatrix.light.locked.core === 'rgb(0, 0, 0)'
&& lockVisualMatrix.light.locked.shell === 'rgb(0, 0, 0)'
&& lockVisualMatrix.light.locked.glyph === 'rgb(255, 255, 255)'
&& lockVisualMatrix.dark.locked.core === 'rgb(37, 37, 37)'
&& lockVisualMatrix.dark.locked.shell === 'rgb(37, 37, 37)'
&& lockVisualMatrix.dark.locked.glyph === 'rgb(255, 255, 255)'
&& lockVisualMatrix.light.unlocked.core === 'rgb(240, 160, 12)'
&& lockVisualMatrix.light.unlocked.shell === 'rgb(240, 160, 12)'
&& lockVisualMatrix.light.unlocked.glyph === 'rgb(255, 255, 255)'
&& lockVisualMatrix.dark.unlocked.core === 'rgb(240, 160, 12)'
&& lockVisualMatrix.dark.unlocked.shell === 'rgb(240, 160, 12)'
&& lockVisualMatrix.dark.unlocked.glyph === 'rgb(37, 37, 37)'
&& lockVisualMatrix.light.unknown.core === 'rgb(255, 255, 255)'
&& lockVisualMatrix.light.unknown.shell === 'rgb(188, 188, 188)'
&& lockVisualMatrix.dark.unknown.core === 'rgb(37, 37, 37)'
&& lockVisualMatrix.dark.unknown.shell === 'rgba(37, 37, 37, 0.75)',
deletionKeepsOpeningFields: JSON.stringify(openingRefs()) === refsBefore,
};
+96
View File
@@ -0,0 +1,96 @@
// #198: explicit Optimize removes only a proven isolated wall-thickness
// micro-interval. Exercise the production bundle's Preview → Apply → Undo flow.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 });
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const S = 1 / 240;
const wallKey = (a, b) => {
const q = (value) => Math.round(value / S) * S;
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy) || 1;
dx /= length; dy /= length;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
const bucket = Math.round(angle * 1800) / 1800;
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${bucket.toFixed(4)}`;
};
const entry = (a, b, cm) => ({ key: wallKey(a, b), a, b, cm });
const y = 0.2, split = 0.5, microEnd = split + S / 3;
const original = {
model_version: 6,
spaces: [{
id: 'micro', title: 'Micro wall', view_box: [0, 0, 1, 1], cell_cm: 5,
rooms: [{ id: 'room', name: 'Room', poly: [[0.2, y], [0.8, y], [0.8, 0.8], [0.2, 0.8]] }],
walls: [
entry([0.2, y], [split, y], 22),
entry([split, y], [microEnd, y], 15),
entry([microEnd, y], [0.8, y], 22),
],
}],
markers: [], settings: {},
};
const clone = (value) => JSON.parse(JSON.stringify(value));
let serverConfig = clone(original), serverLayout = {}, backup = null;
const sent = [];
const baseCall = card.hass.callWS.bind(card.hass);
card.hass = {
...card.hass,
callWS: async (message) => {
if (message.type === 'houseplan/plan/optimize') {
sent.push(message.type);
backup = { config: clone(serverConfig), layout: clone(serverLayout) };
serverConfig = clone(message.config); serverLayout = clone(message.layout);
return { ok: true, config_rev: 2, layout_rev: 2, can_undo: true };
}
if (message.type === 'houseplan/plan/optimize_undo') {
sent.push(message.type);
serverConfig = clone(backup.config); serverLayout = clone(backup.layout);
return { ok: true, config_rev: 3, layout_rev: 3, can_undo: false };
}
if (message.type === 'houseplan/config/get')
return { config: clone(serverConfig), rev: 3, can_write: true };
if (message.type === 'houseplan/layout/get')
return { layout: clone(serverLayout), rev: 3 };
return baseCall(message);
},
};
card._serverCfg = clone(original); card._layout = {};
card._modelCache = null; card._frame = null; card._space = 'micro';
card.requestUpdate(); await card.updateComplete;
card._openAlignDialog(); await card.updateComplete;
const preview = card._alignDialog;
result.previewOffersChange = !!preview?.changed
&& preview.report.canonicalized === 1 && preview.report.wallsMerged === 2
&& preview.config.spaces[0].walls.length === 1
&& preview.config.spaces[0].walls[0].cm === 22;
result.previewDoesNotWrite = sent.length === 0
&& JSON.stringify(card._serverCfg) === JSON.stringify(original);
result.previewIsVisible = !!card.renderRoot.querySelector('hp-dialog .alignmsg');
card._alignDialog = null; await card.updateComplete;
result.cancelDoesNotWrite = sent.length === 0
&& JSON.stringify(card._serverCfg) === JSON.stringify(original);
card._openAlignDialog(); await card.updateComplete;
await card._runAlignToGrid(); await card.updateComplete;
const appliedWalls = card._serverCfg.spaces[0].walls;
result.applyUsesOneAtomicWrite = sent.filter((type) => type === 'houseplan/plan/optimize').length === 1;
result.applyStoresOneUniformRun = appliedWalls.length === 1 && appliedWalls[0].cm === 22
&& JSON.stringify(appliedWalls[0].a) === JSON.stringify([0.2, 0.2])
&& JSON.stringify(appliedWalls[0].b) === JSON.stringify([0.8, 0.2]);
result.applyEnablesUndo = card._canOptimizeUndo === true;
await card._undoPlanOptimization(); await card.updateComplete;
result.undoUsesServerSnapshot = sent.filter((type) => type === 'houseplan/plan/optimize_undo').length === 1;
result.undoRestoresExactEntries = JSON.stringify(card._serverCfg.spaces[0].walls)
=== JSON.stringify(original.spaces[0].walls);
result.undoIsOneDeep = card._canOptimizeUndo === false;
return result;
});
await finish(browser, checkAll(out));
+64 -1
View File
@@ -1,4 +1,4 @@
/** Issues #132/#185: independent-wall host lifecycle and structural wall continuity. */
/** Issues #132/#185/#186: hosted-opening lifecycle, continuity and jamb margin. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 960, height: 820 }, 1);
@@ -35,7 +35,9 @@ const out = await page.evaluate(async () => {
card._space = 'partition-openings';
card._modelCache = null;
card._cfgEpoch++;
let writes = 0;
card._saveConfig = () => {
writes++;
card._cfgEpoch++;
card._modelCache = null;
card._physicalBodiesCache = null;
@@ -61,6 +63,67 @@ const out = await page.evaluate(async () => {
&& Math.abs(saved.y - 0.5) < 1e-9
&& !!root().querySelector(`[data-hp="opening"][data-id="${saved.id}"]`);
const endpointCandidate = card._resolveOpeningPlacement([250, 500]);
const endpointJamb = endpointCandidate?.target?.physicalHalfWidth || 0;
const endpointX = 250 + (endpointCandidate?.renderedLength || 0) / 2 + endpointJamb;
result.placementClampsToHalfThicknessJamb = !!endpointCandidate
&& Math.abs(endpointCandidate.x - endpointX) < 1e-6
&& Math.abs(endpointCandidate.host.t - (endpointX - 250) / 500) < 1e-9
&& !!endpointCandidate.measure.labels[0].text;
const originalSvgPoint = card._svgPoint;
card._opDrag = {
id: saved.id, moved: false, sx: 0, sy: 0, dirty: false,
before: card._geometrySnapshot(),
};
card._svgPoint = () => [250, 500];
card._opPointerMove(new PointerEvent('pointermove', {
pointerId: 186, clientX: 20, clientY: 0, bubbles: true,
}), saved);
card._svgPoint = originalSvgPoint;
card._opPointerUp(new PointerEvent('pointerup', { pointerId: 186, bubbles: true }), saved);
await update();
result.directDragStopsAtSameJambBoundary = Math.abs(saved.host.t - (endpointX - 250) / 500) < 1e-9
&& Math.abs(saved.x - endpointX / 1000) < 1e-9;
card._editOpening(card._openingsR.find((opening) => opening.id === saved.id));
card._openingDialog = { ...card._openingDialog, lengthCm: 600, lengthTouched: true };
const beforeRejectedDialog = JSON.stringify(space.openings);
const writesBeforeRejectedDialog = writes;
const historyBeforeRejectedDialog = card._geometryHistory.size;
await update();
const dialogGuidance = root().querySelector('.habindingbanner')?.textContent || '';
result.dialogShowsJambSpecificGuidance = dialogGuidance.includes('Leave at least')
|| dialogGuidance.includes('Оставьте');
card._saveOpening();
result.invalidLengthWritesNeitherConfigNorHistory = JSON.stringify(space.openings)
=== beforeRejectedDialog
&& writes === writesBeforeRejectedDialog
&& card._geometryHistory.size === historyBeforeRejectedDialog;
card._openingDialog = null;
saved.host.t = 0.5;
saved.x = 0.5;
saved.y = 0.5;
card._saveConfig();
space.partitions.push({ id: 'short', a: [0.2, 0.7], b: [0.27, 0.7], cm: 15 });
card._cfgEpoch++;
card._modelCache = null;
card._openingPlacementIntervalsCache = null;
const previousToast = card._showToast;
let jambToast = '';
card._showToast = (message) => { jambToast = message; };
const shortPoint = [235, 700];
const shortCandidate = card._resolveOpeningPlacement(shortPoint);
card._openingClick(shortPoint);
card._showToast = previousToast;
result.tooShortPartitionHasNoPreviewAndExplainsWhy = shortCandidate === null
&& (jambToast.includes('Leave at least') || jambToast.includes('Оставьте'));
space.partitions = space.partitions.filter((partition) => partition.id !== 'short');
card._cfgEpoch++;
card._modelCache = null;
card._openingPlacementIntervalsCache = null;
card._physicalBodiesCache = null;
const bodies = card._physicalBodiesR();
result.hostBodyHasFullDepthOpeningGap = !bodies.some((body) => pointIn(centre, body));
+10 -4
View File
@@ -182,9 +182,11 @@ const out = await page.evaluate(async () => {
await card.updateComplete;
result.currentAnchorDoesNotCreateZeroSegment = !active();
card._cancelPath();
card._tool = 'partition';
card._activateMarkupTool('draw');
await update();
card._markupClick(eventAt(735, 606, 'click'));
// Use a fresh point on the same physical line: the earlier assertion left
// 735,600 as a saved-draft endpoint, and public Walls correctly resumes it.
card._markupClick(eventAt(775, 606, 'click'));
await card.updateComplete;
result.tapWithoutHoverSnapsFirstPoint = card._path.length === 1
&& close(card._path[0][1], 600)
@@ -192,7 +194,9 @@ const out = await page.evaluate(async () => {
Math.round((card._path[0][0] - 600) / card._gridPitch));
card._markupClick(eventAt(896, 496, 'click'));
await card.updateComplete;
result.secondPartitionClickSnapsEndpoint = card._path.length === 0
card._activateMarkupTool('select');
await update();
result.secondWallsClickFinishesSnappedPartition = card._path.length === 0
&& card._curSpaceCfg.partitions.length === 2
&& close(card._curSpaceCfg.partitions[1].b[0] * 1000, 900)
&& close(card._curSpaceCfg.partitions[1].b[1] * 1000, 500);
@@ -202,6 +206,8 @@ const out = await page.evaluate(async () => {
drafts: card._curSpaceCfg.room_drafts,
partitions: card._curSpaceCfg.partitions,
});
card._activateMarkupTool('draw');
await update();
card._suppressClick = true;
card._markupClick(eventAt(700, 600, 'click'));
result.suppressedClickDoesNotCommit = card._path.length === 0;
@@ -222,7 +228,7 @@ const out = await page.evaluate(async () => {
partitions: card._curSpaceCfg.partitions,
}) === gestureGeometry;
card._tool = 'select';
card._activateMarkupTool('select');
await update();
result.otherPlanToolsHaveNoOverlay = !overlay();
card._setMode('view');
+34
View File
@@ -195,6 +195,40 @@ const res = await page.evaluate(async () => {
out.virtualTJunctionMitred = values.some((x, i) => i % 2 === 0
&& Math.abs(x - (500 + half)) < 1e-6
&& Math.abs(values[i + 1] - (500 - half)) < 1e-6);
// #201: a fully virtual stretch has no own cm to restore. Its nearest
// solid continuation is one exact 22 cm parent run, atomised at x=600 by a
// third room. Close must read the parent through either child instead of
// falling back to the 15 cm draw default.
sp().rooms = [
{ id: 'parent', name: 'Parent', poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.5], [0.1, 0.5]], open_to: ['left'] },
{ id: 'left', name: 'Left', poly: [[0.1, 0.5], [0.3, 0.5], [0.3, 0.9], [0.1, 0.9]], open_to: ['parent'] },
{ id: 'middle', name: 'Middle', poly: [[0.3, 0.5], [0.6, 0.5], [0.6, 0.9], [0.3, 0.9]] },
{ id: 'right', name: 'Right', poly: [[0.6, 0.5], [0.9, 0.5], [0.9, 0.9], [0.6, 0.9]] },
];
sp().walls = [{
key: '0.600000,0.500000@0.0000', cm: 22,
a: [0.3, 0.5], b: [0.9, 0.5],
}];
sp().open_spans = [{ a: [0.1, 0.5], b: [0.3, 0.5] }];
delete sp().openings;
c._tool = 'boundary';
await upd();
const atomicParentBefore = JSON.stringify({
rooms: sp().rooms, walls: sp().walls, open_spans: sp().open_spans,
});
const atomicParentCut = c._openCuts()[0];
const atomicParentPlan = atomicParentCut && c._planClosedOpenSpan(atomicParentCut);
out.atomicParentClosePreviewInherits = atomicParentPlan?.cm === 22;
if (atomicParentCut) c._closeOpenSpan(atomicParentCut);
await upd();
out.atomicParentClosePersistsNeighbourCm = !sp().open_spans
&& c._intervalCm([100, 500, 300, 500]) === 22;
c._undoGeometry();
await upd();
out.atomicParentCloseUndo = JSON.stringify({
rooms: sp().rooms, walls: sp().walls, open_spans: sp().open_spans,
}) === atomicParentBefore;
return out;
});
+184 -37
View File
@@ -1,38 +1,185 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// включить имена комнат
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), show_names: true } })) };
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250));
// 1) комната не кликабельна: cursor default, обработчика клика нет
const room = sr().querySelector('.room');
out.roomCursor = getComputedStyle(room).cursor === 'default';
// 2) значок ссылки у комнат с зоной, кликабелен, ведёт в зону
const links = sr().querySelectorAll('.rlgo');
out.linksShown = links.length > 0;
const withArea = c._spaceModel().rooms.filter((r) => r.name && r.area).length;
out.linkPerArearoom = links.length === withArea;
const icon = links[0];
out.linkCursor = getComputedStyle(icon).cursor === 'pointer';
out.linkClickable = getComputedStyle(icon).pointerEvents === 'auto';
// навигация: перехват history
let navTo = null;
const orig = history.pushState.bind(history);
history.pushState = (a, b, url) => { navTo = url; };
icon.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
history.pushState = orig;
out.navigates = typeof navTo === 'string' && navTo.includes('/config/areas/area/');
// 3) комнаты без зоны — без значка (проверено соотношением выше); в разметке значков нет
c._setMode('plan'); await c.updateComplete;
out.noneInPlan = sr().querySelectorAll('.rlgo').length === 0;
c._setMode('view'); await c.updateComplete;
return out;
});
checkAll(res);
await finish(browser, res);
const checks = {};
const geometry = [];
for (const scale of [1, 2]) {
const { page, browser } = await launch({ width: 820, height: 760 }, scale);
for (const dark of [false, true]) {
const key = `dpr${scale}-${dark ? 'dark' : 'light'}`;
const setup = await page.evaluate(async ({ dark }) => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const settle = async () => {
c.requestUpdate();
await c.updateComplete;
const deadline = performance.now() + 2000;
while (c._modeTransitionBusy && performance.now() < deadline)
await new Promise((resolve) => setTimeout(resolve, 20));
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const labelById = (id) => [...sr().querySelectorAll('.roomlabel')]
.find((label) => label.dataset.id === id);
const relativeBox = (label, selector) => {
const anchorBox = label.getBoundingClientRect();
const box = label.querySelector(selector)?.getBoundingClientRect();
if (!box) return null;
const anchorX = anchorBox.left + anchorBox.width / 2;
const anchorY = anchorBox.top + anchorBox.height / 2;
return {
left: box.left - anchorX,
top: box.top - anchorY,
width: box.width,
height: box.height,
};
};
const snapshot = (label) => ({
name: relativeBox(label, '.rlname'),
metrics: relativeBox(label, '.rlmetrics'),
metricsText: label.querySelector('.rlmetrics')?.textContent?.replace(/\s+/g, ' ').trim() || '',
});
c.hass = {
...c.hass,
themes: { ...(c.hass.themes || {}), darkMode: dark },
};
c._serverCfg = {
...c._serverCfg,
spaces: c._serverCfg.spaces.map((space) => space.id !== c._space ? space : ({
...space,
settings: {
...(space.settings || {}),
show_names: true,
label_temp: true,
label_hum: true,
label_lqi: true,
label_light: true,
},
})),
};
const targetRoom = c._spaceModel().rooms.find((room) => room.name && room.area);
if (targetRoom?.id) {
c._layout = {
...c._layout,
[`rl_${targetRoom.id}`]: { s: c._space, x: 0.16, y: 0.22 },
};
}
c._setMode('view');
await settle();
const labels = [...sr().querySelectorAll('.roomlabel')];
const label = labels.find((candidate) => candidate.querySelector('.rlgo')
&& candidate.querySelector('.rlmetrics'));
if (!label) return { missingFixture: true };
const roomId = label.dataset.id;
const view = snapshot(label);
const links = sr().querySelectorAll('.rlgo');
const withArea = c._spaceModel().rooms.filter((room) => room.name && room.area).length;
const room = sr().querySelector('.room');
const viewIcon = label.querySelector('.rlgo');
const viewCursor = getComputedStyle(viewIcon).cursor;
const viewPointerEvents = getComputedStyle(viewIcon).pointerEvents;
let viewNav = null;
const viewPushState = history.pushState;
history.pushState = (state, title, url) => { viewNav = url; };
viewIcon.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
history.pushState = viewPushState;
c._setMode('plan');
await settle();
const planLabel = labelById(roomId);
const planIcon = planLabel?.querySelector('.rlgo');
const plan = planLabel ? snapshot(planLabel) : null;
let planNav = null;
const planPushState = history.pushState;
history.pushState = (state, title, url) => { planNav = url; };
planIcon?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
history.pushState = planPushState;
const iconBox = planIcon?.getBoundingClientRect();
window.__roomLinkOriginalPushState = history.pushState;
window.__roomLinkDragNav = null;
history.pushState = (state, title, url) => { window.__roomLinkDragNav = url; };
return {
missingFixture: false,
roomId,
view,
plan,
roomCursor: getComputedStyle(room).cursor,
viewLinkCount: links.length,
withArea,
viewCursor,
viewPointerEvents,
viewNav,
planLinkCount: sr().querySelectorAll('.rlgo').length,
planCursor: planIcon ? getComputedStyle(planIcon).cursor : '',
planTitle: planIcon?.getAttribute('title') || '',
planNav,
planHandles: planLabel?.querySelectorAll('.rlhandle').length || 0,
planSettingsButtons: sr().querySelectorAll('.rlgearbtn').length,
beforeLayout: c._layout[`rl_${roomId}`] || null,
dragTarget: iconBox ? { x: iconBox.left + iconBox.width / 2, y: iconBox.top + iconBox.height / 2 } : null,
themeApplied: c.hass.themes.darkMode === dark,
};
}, { dark });
if (setup.missingFixture) {
checks[`${key}:fixture`] = false;
continue;
}
let drag = { changed: false, dragNav: 'not-run', stayedInPlan: false };
if (setup.dragTarget) {
await page.mouse.move(setup.dragTarget.x, setup.dragTarget.y);
await page.mouse.down();
await page.mouse.move(setup.dragTarget.x + 24, setup.dragTarget.y + 16, { steps: 4 });
await page.mouse.up();
drag = await page.evaluate(async ({ roomId, beforeLayout }) => {
const c = window.__card;
await c.updateComplete;
const after = c._layout[`rl_${roomId}`] || null;
const changed = !!after && (!beforeLayout
|| Math.abs(after.x - beforeLayout.x) > 1e-6
|| Math.abs(after.y - beforeLayout.y) > 1e-6);
const dragNav = window.__roomLinkDragNav;
history.pushState = window.__roomLinkOriginalPushState;
delete window.__roomLinkOriginalPushState;
delete window.__roomLinkDragNav;
return { changed, dragNav, stayedInPlan: c._mode === 'plan' };
}, { roomId: setup.roomId, beforeLayout: setup.beforeLayout });
}
const values = ['left', 'top', 'width', 'height'];
const nameDeltas = values.map((field) => Math.abs(setup.view.name[field] - setup.plan.name[field]));
const metricDeltas = values.map((field) => Math.abs(setup.view.metrics[field] - setup.plan.metrics[field]));
geometry.push({ key, nameDeltas, metricDeltas });
checks[`${key}:fixture`] = !setup.missingFixture;
checks[`${key}:room-cursor`] = setup.roomCursor === 'default';
checks[`${key}:view-link-per-area-room`] = setup.viewLinkCount === setup.withArea;
checks[`${key}:view-link-interactive`] = setup.viewCursor === 'pointer'
&& setup.viewPointerEvents === 'auto';
checks[`${key}:view-navigates`] = typeof setup.viewNav === 'string'
&& setup.viewNav.includes('/config/areas/area/');
checks[`${key}:plan-keeps-links`] = setup.planLinkCount === setup.withArea;
checks[`${key}:plan-link-is-drag-affordance`] = setup.planCursor === 'grab'
&& setup.planTitle === '';
checks[`${key}:plan-does-not-navigate`] = setup.planNav == null
&& drag.dragNav == null && drag.stayedInPlan;
checks[`${key}:plan-icon-drags-label`] = drag.changed;
checks[`${key}:core-name-parity`] = nameDeltas.every((delta) => delta <= 0.5);
checks[`${key}:core-metrics-parity`] = metricDeltas.every((delta) => delta <= 0.5)
&& setup.view.metricsText === setup.plan.metricsText;
checks[`${key}:plan-controls-preserved`] = setup.planHandles === 4
&& setup.planSettingsButtons > 0;
checks[`${key}:theme-applied`] = setup.themeApplied;
}
await browser.close();
}
checkAll(checks);
await finish(null, { checks, geometry });
+4 -4
View File
@@ -45,15 +45,15 @@ const res = await page.evaluate(async () => {
const lbl = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(model.name));
out.cardShowsSource = lbl ? lbl.textContent.includes('30.2°') : false;
const modelIndex = c._spaceModel().rooms.findIndex((r) => r.id === editedId);
[...sr().querySelectorAll('.room')][modelIndex].dispatchEvent(new MouseEvent('mousemove', {
bubbles: true, composed: true, clientX: 200, clientY: 200,
[...sr().querySelectorAll('.room')][modelIndex].dispatchEvent(new PointerEvent('pointermove', {
pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200,
}));
await c.updateComplete;
out.tooltipShowsHumiditySource = c._tip?.hum === 47
&& (sr().querySelector('.tip')?.textContent || '').includes(`${c._t('tip.hum_avg')} 47%`);
model.settings = { ...model.settings, hum_source: 'entity:sensor.missing_room_hum' };
[...sr().querySelectorAll('.room')][modelIndex].dispatchEvent(new MouseEvent('mousemove', {
bubbles: true, composed: true, clientX: 210, clientY: 210,
[...sr().querySelectorAll('.room')][modelIndex].dispatchEvent(new PointerEvent('pointermove', {
pointerType: 'mouse', bubbles: true, composed: true, clientX: 210, clientY: 210,
}));
await c.updateComplete;
out.invalidHumiditySourceIsOmitted = c._tip?.hum == null
@@ -0,0 +1,103 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const state = () => ({
source: c._spaceDialog?.source,
showBorders: c._spaceDialog?.showBorders,
showNames: c._spaceDialog?.showNames,
displayTouched: c._spaceDialog?.displayTouched,
});
const choose = async (source) => {
const radios = [...sr().querySelectorAll('hp-dialog input[name="plansrc"]')];
radios[source === 'file' ? 0 : 1].click();
await c.updateComplete;
};
const toggle = async (key) => {
const label = [...sr().querySelectorAll('hp-dialog label.srcrow')]
.find((row) => row.textContent.includes(c._t(key)));
label.querySelector('input[type="checkbox"], ha-switch').click();
await c.updateComplete;
};
c._openSpaceDialog('create'); await c.updateComplete;
out.freshFile = state();
await choose('draw');
out.untouchedDraw = state();
await choose('file');
out.untouchedFileAgain = state();
await choose('draw');
await toggle('space.show_borders');
out.mixedTouched = state();
await choose('file');
out.mixedFile = state();
await choose('draw');
out.mixedDrawAgain = state();
c._spaceDialog = { ...c._spaceDialog, title: 'Mixed display defaults' };
await c._saveSpaceDialog(); await c.updateComplete;
const mixed = c._serverCfg.spaces.find((s) => s.title === 'Mixed display defaults');
out.savedMixed = [mixed?.settings?.show_borders, mixed?.settings?.show_names];
c._openSpaceDialog('edit', mixed.id); await c.updateComplete;
out.reopenedMixed = state();
await choose('file');
out.editSourcePreservesPair = state();
const beforeCancel = JSON.stringify(c._serverCfg);
c._spaceDialog = null; await c.updateComplete;
c._openSpaceDialog('create'); await c.updateComplete;
await choose('draw');
await toggle('space.show_names');
c._spaceDialog = null; await c.updateComplete;
out.cancelDidNotWrite = JSON.stringify(c._serverCfg) === beforeCancel;
c._openSpaceDialog('create'); await c.updateComplete;
out.freshAfterCancel = state();
c._spaceDialog = null;
c._importQueue = ['Floor one', 'Floor two'];
c._importTotal = 2;
c._openNextImport(); await c.updateComplete;
out.onboardingFirst = { title: c._spaceDialog.title, ...state() };
await choose('draw');
await toggle('space.show_borders');
c._skipImport(); await c.updateComplete;
out.onboardingSecond = { title: c._spaceDialog.title, ...state() };
c._importQueue = [];
c._importTotal = 0;
c._spaceDialog = null;
c._openSpaceDialog('create'); await c.updateComplete;
const existingPlan = c._serverCfg.spaces.find((s) => s.plan_url)?.plan_url;
c._spaceDialog = {
...c._spaceDialog, title: 'File display defaults', planUrl: existingPlan || '/local/houseplan/plan.svg',
};
await c._saveSpaceDialog(); await c.updateComplete;
const file = c._serverCfg.spaces.find((s) => s.title === 'File display defaults');
out.savedFileDefaults = [file?.settings?.show_borders, file?.settings?.show_names];
return out;
});
checkAll(res, {
freshFile: { source: 'file', showBorders: false, showNames: false, displayTouched: false },
untouchedDraw: { source: 'draw', showBorders: true, showNames: true, displayTouched: false },
untouchedFileAgain: { source: 'file', showBorders: false, showNames: false, displayTouched: false },
mixedTouched: { source: 'draw', showBorders: false, showNames: true, displayTouched: true },
mixedFile: { source: 'file', showBorders: false, showNames: true, displayTouched: true },
mixedDrawAgain: { source: 'draw', showBorders: false, showNames: true, displayTouched: true },
savedMixed: [false, true],
reopenedMixed: { source: 'draw', showBorders: false, showNames: true, displayTouched: true },
editSourcePreservesPair: { source: 'file', showBorders: false, showNames: true, displayTouched: true },
freshAfterCancel: { source: 'file', showBorders: false, showNames: false, displayTouched: false },
onboardingFirst: {
title: 'Floor one', source: 'file', showBorders: false, showNames: false, displayTouched: false,
},
onboardingSecond: {
title: 'Floor two', source: 'file', showBorders: false, showNames: false, displayTouched: false,
},
savedFileDefaults: [false, false],
});
await finish(browser, res);
+3 -1
View File
@@ -62,7 +62,9 @@ const res = await page.evaluate(async () => {
out.labelSaved = !!c._layout['rl_r1'];
// 6) диалог: create + draw
c._openSpaceDialog('create'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', source: 'draw', orientation: 'square' };
[...sr().querySelectorAll('input[name="plansrc"]')][1].click();
await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', orientation: 'square' };
await c.updateComplete;
out.saveEnabled = !sr().querySelector('hp-dialog .btn.on[disabled]');
await c._saveSpaceDialog(); await c.updateComplete;
+2 -2
View File
@@ -18,10 +18,10 @@ const out = await page.evaluate(async () => {
'static-icon', 'on', 'open', 'alarm', 'unavail', 'valonly',
'activity-running', 'activity-event', 'activity-presence', 'activity-transition',
].includes(name)),
icon: node.querySelector(':scope > ha-icon')?.getAttribute('icon') || '',
icon: node.querySelector('.device-core > ha-icon')?.getAttribute('icon') || '',
satellites: node.querySelectorAll('.tval,.hval,.lqi,.valtext').length,
scale: node.style.getPropertyValue('--dev-scale'),
angle: node.querySelector(':scope > ha-icon')?.style.transform || '',
angle: node.querySelector('.device-core > ha-icon')?.style.transform || '',
});
const show = (device, states) => {
const visibleSnapshot = card._visibleDeviceSnapshot;
+5 -8
View File
@@ -3,7 +3,7 @@
// НАХОДЯТСЯ через querySelector — ровно так, как их будет искать чужой CSS.
// 1) устройство: data-hp="device" + data-id + data-entity + data-area;
// 2) комната: data-hp="room" + data-id + data-area;
// 3) подпись комнаты: data-hp="room-label" + data-id (и в SVG, и в HTML-карточке);
// 3) видимая подпись комнаты: data-hp="room-label" + data-id на HTML-карточке;
// 4) проём: data-hp="opening" + data-id + data-kind (door/window/gate);
// 5) декор: data-hp="decor" + data-id + data-kind (line/rect/ellipse/text);
// 6) вкладка пространства: data-hp="space-tab" + data-id;
@@ -50,8 +50,8 @@ const res = await page.evaluate(async () => {
out.roomIsAnSvgShape = !!kitchen && ['polygon', 'rect', 'path'].includes(kitchen.tagName.toLowerCase());
// ============ 3. подписи комнат ============
// одно имя — две разные реализации: <text> в SVG на плане без подложки и
// HTML-карточка, когда включены «имена на плане». Хук на обеих один и тот же.
// #203: false означает отсутствие подписи, а не вторую SVG-реализацию.
// При true единственный публичный hook живёт на HTML-карточке.
const disp = c._curSpaceCfg.settings || (c._curSpaceCfg.settings = {});
const wasNames = disp.show_names;
const planUrl = c._curSpaceCfg.plan_url;
@@ -60,11 +60,8 @@ const res = await page.evaluate(async () => {
await c.updateComplete; await sleep(60); await c.updateComplete;
};
delete c._curSpaceCfg.plan_url; disp.show_names = false; await redraw();
const svgLabels = qa('text.rlabel[data-hp="room-label"]');
out.svgRoomLabelHooked = svgLabels.length > 0
&& svgLabels.every((e) => !!e.getAttribute('data-id'));
out.svgRoomLabelFoundById = !!q('text[data-hp="room-label"][data-id="r1"]');
out.svgRoomLabelHasArea = q('text[data-hp="room-label"][data-id="r1"]')?.getAttribute('data-area') === 'living_room';
out.hiddenRoomLabelsAreAbsent = qa('[data-hp="room-label"]').length === 0
&& qa('text.rlabel').length === 0;
c._curSpaceCfg.plan_url = planUrl; disp.show_names = true; await redraw();
const htmlLabel = q('div.roomlabel[data-hp="room-label"][data-id="r2"]');
out.htmlRoomLabelHooked = !!htmlLabel;
+1 -1
View File
@@ -76,7 +76,7 @@ const res = await page.evaluate(async () => {
document.dispatchEvent(new Event('visibilitychange'));
await c.updateComplete;
out.visibleRestartsClock = c._dayCycleTimer !== 0;
out.visibilityKeepsHover = c._hoverRoom?.room === room;
out.visibilityClearsHover = c._hoverRoom === null;
if (ownVisibilityState) Object.defineProperty(document, 'visibilityState', ownVisibilityState);
else delete document.visibilityState;
+44 -13
View File
@@ -1,22 +1,53 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
// эмуляция тач-устройства: переопределяем matchMedia ДО загрузки бандла
await page.addInitScript(() => {
const orig = window.matchMedia.bind(window);
window.matchMedia = (q) => q.includes('hover: none')
? { matches: true, media: q, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, onchange: null, dispatchEvent: () => false }
: orig(q);
});
await page.reload();
await page.waitForFunction(() => window.__card && window.__card._devices?.length, null, { timeout: 15000 });
const { page, browser } = await launch(
{ width: 390, height: 760 }, 1, [], { hasTouch: true, isMobile: true },
);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
c._setMode('view'); await c.updateComplete;
// "тап" по комнате → mousemove-эмуляция → тултип НЕ должен появиться
c._showTip(new MouseEvent('mousemove', { clientX: 100, clientY: 100 }), 'Room', 'meta');
out.touchHardwareHasNoFineHover = !matchMedia(
'(any-hover: hover) and (any-pointer: fine)',
).matches;
c._tip = { x: 1, y: 1, title: 'stale', meta: 'stale' };
c._hoverRoom = { space: c._space, room: c._curSpaceCfg.rooms[0] };
c._notePointer(new PointerEvent('pointerdown', {
pointerType: 'touch', pointerId: 9, clientX: 100, clientY: 100,
}));
await c.updateComplete;
out.noTipOnTouch = c._tip === null || c._tip === undefined || !c._tip;
out.touchClearsAllTransientHover = !c._tip && !c._hoverRoom
&& !c.hasAttribute('data-pointer-hover');
c._showTip(new PointerEvent('pointermove', {
pointerType: 'touch', pointerId: 9, clientX: 100, clientY: 100,
}), 'Room', 'meta');
out.noTipOnTouch = !c._tip;
c._notePointer(new PointerEvent('pointermove', {
pointerType: 'mouse', clientX: 110, clientY: 110,
}));
out.touchOnlyHardwareRejectsMouseHover = !c.hasAttribute('data-pointer-hover');
out.nestedHoverGateIsOff = [...c.renderRoot.querySelectorAll(
'hp-dialog, hp-help, hp-color-opacity',
)].every((node) => !node.hasAttribute('data-pointer-hover'));
const marker = c.renderRoot.querySelector('.dev[data-id="d_light1"]');
const shell = marker?.querySelector('.device-shell');
const originalCallService = c.hass.callService;
let dispatches = 0;
c.hass.callService = async () => { dispatches++; };
marker?.dispatchEvent(new PointerEvent('pointerdown', {
pointerType: 'touch', pointerId: 10, bubbles: true, composed: true,
}));
marker?.dispatchEvent(new PointerEvent('pointerup', {
pointerType: 'touch', pointerId: 10, bubbles: true, composed: true,
}));
marker?.click();
await Promise.resolve();
const touchFeedback = shell?.getAnimations().find((animation) =>
animation.effect?.getTiming().duration === 200);
out.touchDispatchGetsFeedback = dispatches === 1
&& touchFeedback?.effect?.getKeyframes?.().some((frame) => frame.scale === '0.95');
c._cancelDevicePressFeedback();
c.hass.callService = originalCallService;
return out;
});
checkAll(res);
+21 -8
View File
@@ -41,7 +41,9 @@ const res = await page.evaluate(async () => {
// тултип комнаты: площадь и агрегаты температуры/влажности в стабильном порядке
const rooms = [...sr().querySelectorAll('.room')];
const room = rooms[0];
room.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, composed: true, clientX: 200, clientY: 200 }));
room.dispatchEvent(new PointerEvent('pointermove', {
pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200,
}));
await c.updateComplete;
out.tipTemp = c._tip?.temp;
out.tipHum = c._tip?.hum;
@@ -60,26 +62,37 @@ const res = await page.evaluate(async () => {
out.tipHasAreaLine = (sr().querySelector('.tip')?.textContent || '').includes(expectedArea);
// Комната без источника влажности не получает пустую строку.
rooms[1].dispatchEvent(new MouseEvent('mousemove', { bubbles: true, composed: true, clientX: 210, clientY: 210 }));
rooms[1].dispatchEvent(new PointerEvent('pointermove', {
pointerType: 'mouse', bubbles: true, composed: true, clientX: 210, clientY: 210,
}));
await c.updateComplete;
out.noHumidityOmitted = c._tip?.hum == null
&& !(sr().querySelector('.tip')?.textContent || '').includes('средняя влажность');
// Общий device tooltip остаётся без room humidity.
sr().querySelector('.dev').dispatchEvent(new MouseEvent('mousemove', {
bubbles: true, composed: true, clientX: 220, clientY: 220,
sr().querySelector('.dev').dispatchEvent(new PointerEvent('pointermove', {
pointerType: 'mouse', bubbles: true, composed: true, clientX: 220, clientY: 220,
}));
await c.updateComplete;
out.deviceTooltipHasNoHumidity = c._tip?.hum == null
&& !(sr().querySelector('.tip')?.textContent || '').includes('средняя влажность');
// После первого touch/pen hover-подсказки по-прежнему не создаются.
c.constructor._touchSeen = true;
// Touch/pen input never creates hover-only tooltips.
room.dispatchEvent(new PointerEvent('pointerdown', {
pointerType: 'touch', pointerId: 91, bubbles: true, composed: true,
clientX: 230, clientY: 230,
}));
c._tip = null;
room.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, composed: true, clientX: 230, clientY: 230 }));
room.dispatchEvent(new PointerEvent('pointermove', {
pointerType: 'touch', pointerId: 91, bubbles: true, composed: true,
clientX: 230, clientY: 230,
}));
await c.updateComplete;
out.noHoverSuppressesTooltip = c._tip === null;
c.constructor._touchSeen = false;
room.dispatchEvent(new PointerEvent('pointerup', {
pointerType: 'touch', pointerId: 91, bubbles: true, composed: true,
clientX: 230, clientY: 230,
}));
// диалог: радио заливки, компактные поля, ширина
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
out.fillRadios = sr().querySelectorAll('input[name="fillmode"]').length;
+38 -5
View File
@@ -39,11 +39,18 @@ const out = await page.evaluate(async () => {
// owner 2026-07-31: the base badge, but round and 20% smaller — same plate
// vs a NEUTRAL badge: the robot's own base is yellow while cleaning
const devEl = sr().querySelector('.dev:not(.on)');
const pcs = getComputedStyle(puck), dcs = getComputedStyle(devEl);
o.puckPlateMatchesDev = pcs.backgroundColor === dcs.backgroundColor
&& pcs.borderTopColor === dcs.borderTopColor;
o.puck80pct = Math.abs(puck.getBoundingClientRect().width
- devEl.getBoundingClientRect().width * 0.8) < 2;
const devCore = devEl.querySelector('.device-core');
const devShell = devEl.querySelector('.device-shell-frame');
const pcs = getComputedStyle(puck);
o.puckPlateMatchesDev = pcs.backgroundColor === getComputedStyle(devCore).backgroundColor
&& pcs.borderTopColor === getComputedStyle(devShell).borderTopColor;
const originalScale = devEl.style.getPropertyValue('--dev-scale');
devEl.style.setProperty('--dev-scale', '1');
const puckWidth = parseFloat(getComputedStyle(puck).width);
const deviceCoreWidth = devCore.getBoundingClientRect().width;
if (originalScale) devEl.style.setProperty('--dev-scale', originalScale);
else devEl.style.removeProperty('--dev-scale');
o.puck80pct = Math.abs(puckWidth - deviceCoreWidth * 0.8) < 0.5;
o.noWedge = !sr().querySelector('.vacwedge'); // owner 2026-07-31: no heading arrow
// the glyph is DEAD centre (owner report: it floated on the text baseline)
const pr = puck.getBoundingClientRect();
@@ -109,6 +116,31 @@ const out = await page.evaluate(async () => {
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
o.unknownMapNoPuck = !sr().querySelector('.vacpuck');
// ---- resumed server run: dock hides it, same current returns whole ----
// Backend owns the 30-minute decision. The card must neither paint an ended
// current in default mode nor lose its pre-dock points after ended is cleared.
c._vacSrvTrails = { e_vacuum_robo: {
current: { map_id: 'm1', started: 10, ended: 20,
points: [[700, 500], [900, 500], [1100, 640], [950, 1150]] },
} };
c.hass = { ...c.hass, states: { ...c.hass.states,
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } },
'camera.robo_map': { state: 'idle', attributes: mkAttrs(950, 1150) } } };
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
o.srvEndedCurrentHiddenDefault = !sr().querySelector('.vactrail path, .vactrail polyline');
c._vacSrvTrails = { e_vacuum_robo: {
current: { map_id: 'm1', started: 10, ended: null,
points: [[700, 500], [900, 500], [1100, 640], [950, 1150], [1200, 1200]] },
} };
c.hass = { ...c.hass, states: { ...c.hass.states,
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } },
'camera.robo_map': { state: 'idle', attributes: mkAttrs(1200, 1200) } } };
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
c.hass = { ...c.hass, states: { ...c.hass.states } }; await c.updateComplete;
const resumedD = sr().querySelector('.vactrail > path.case')?.getAttribute('d') || '';
o.srvResumedCurrentKeepsEarlierPoints = (resumedD.match(/[ML]/g) || []).length === 4;
// ---- server-side runs: current + one previous (owner 2026-07-31) ----
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = 'always';
c._vacSrvTrails = { e_vacuum_robo: {
@@ -247,6 +279,7 @@ checkAll(out, {
trailCasing: true, trailToggleOff: true,
puckGoneWhenDocked: true, trailHiddenAfterDock: true, hiddenNoPuck: true,
unknownMapNoPuck: true,
srvEndedCurrentHiddenDefault: true, srvResumedCurrentKeepsEarlierPoints: true,
srvPrevRunShown: true, srvCurTrimmed: true, srvPrevFaded: true,
multiSubpathNoBridge: true, integrationPathAlwaysAtRest: true,
tipExists: true, tipGluedToPuck: true, neverHidesAll: true,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 52 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 52 KiB

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