Compare commits

...
27 Commits
Author SHA1 Message Date
Matysh 5814cfe0c2 ci: handle first performance-gated main promotion
Validate / smoke (push) Failing after 19m59s
Validate / hacs (push) Failing after 26s
Validate / hassfest (push) Failing after 22s
Validate / frontend (push) Successful in 3m58s
Validate / golden (push) Failing after 1m8s
Validate / backend (push) Failing after 10m47s
Validate / performance (push) Failing after 25m35s
2026-08-09 15:44:05 +03:00
Matysh d839eb88eb test: accept editor tray golden baselines 2026-08-09 15:30:14 +03:00
Matysh bf5a040508 Release v1.60.3 2026-08-09 15:20:50 +03:00
Matysh faa1f4ea9a fix: settle editor chrome and opening render regressions 2026-08-09 15:14:58 +03:00
Matysh caf3c44ad9 ci: validate branch pushes without duplicate tag runs 2026-08-09 08:52:28 +03:00
Matysh 08b19363fd ci: make performance gate runner-noise aware 2026-08-09 08:50:26 +03:00
Matysh d5e6c5cff0 test: accept editor context tray golden baselines 2026-08-09 08:40:57 +03:00
Matysh c85dbaf4cb v1.60.3-beta.2: stabilize editor context tray 2026-08-09 08:34:05 +03:00
Matysh b1deb0beab Avoid hassfest manifest collision
Validate / hacs (push) Failing after 8s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 3m52s
Validate / backend (push) Failing after 8m33s
Validate / golden (push) Failing after 6m9s
Validate / smoke (push) Failing after 22m51s
2026-08-08 22:03:09 +03:00
Matysh 9590011ddb Add reviewed Linux golden baselines 2026-08-08 21:52:26 +03:00
Matysh ddbd3288fe Fix prerelease smoke regressions 2026-08-08 21:42:49 +03:00
Matysh e177c14603 v1.60.3-beta.1: harden rendering and QA tooling 2026-08-08 21:33:09 +03:00
Matysh 164f7cf76a chore(hacs): zip_release + workflow attaching houseplan.zip to every release
HACS will install from the named asset instead of the auto zipball, so
GitHub's public download counter becomes a per-version install metric.
The workflow also has a manual dispatch to backfill an existing release.
2026-08-08 19:43:24 +03:00
Matysh 2219700d63 Release v1.60.2
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 12s
Validate / frontend (push) Successful in 3m58s
Validate / backend (push) Failing after 8m11s
Validate / smoke (push) Failing after 22m39s
2026-08-08 17:39:25 +03:00
Matysh 854a6944a0 test: align smokes with active registry projection 2026-08-08 15:55:33 +03:00
Matysh 2a8302f4d6 v1.60.2-beta.3: unify boundaries and device presentation 2026-08-08 15:46:07 +03:00
Matysh f1537b2108 v1.60.2-beta.2: harden device state and editor interactions
Validate / hassfest (push) Failing after 19s
Validate / hacs (push) Failing after 20s
Validate / frontend (push) Successful in 3m2s
Validate / backend (push) Failing after 9m17s
Validate / smoke (push) Failing after 14m1s
2026-08-08 00:12:47 +03:00
Matysh 5e1315f61d ci: announce releases and prereleases in the Telegram chat
Validate / hacs (push) Failing after 20s
Validate / hassfest (push) Failing after 23s
Validate / frontend (push) Successful in 3m38s
Validate / smoke (push) Failing after 53s
Validate / backend (push) Failing after 7m14s
2026-08-07 22:35:10 +03:00
Matysh 4e29db1afb test: settle zoom baseline after editor collapse 2026-08-07 22:18:03 +03:00
Matysh 953063b984 fix: keep wall thickness beside room outline 2026-08-07 22:09:42 +03:00
Matysh d48d220a8c v1.60.2-beta.1: add persistent physical geometry 2026-08-07 22:02:41 +03:00
Matysh 1c949ae49d test: poll navigation background transitions
Validate / hacs (push) Failing after 11s
Validate / hassfest (push) Failing after 9s
Validate / frontend (push) Successful in 4m38s
Validate / backend (push) Failing after 10m49s
Validate / smoke (push) Failing after 25m55s
2026-08-07 14:39:24 +03:00
Matysh 9956a6cfe6 test: settle navigation transitions in browser smokes 2026-08-07 14:31:02 +03:00
Matysh fe5f5b6a24 v1.60.1-beta.1: harden editing and device state 2026-08-07 14:19:02 +03:00
Matysh 6db9eb0a66 test: use access token for non-admin websocket
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 4m33s
Validate / backend (push) Failing after 10m31s
Validate / smoke (push) Failing after 20m7s
2026-08-07 13:10:27 +03:00
Matysh 3028122016 v1.60.0: harden background editing and device state 2026-08-07 13:02:46 +03:00
Matysh 29fb9deb43 v1.60.0-beta.1: unify background editing and device deletion 2026-08-07 11:14:20 +03:00
211 changed files with 29834 additions and 4713 deletions
+14
View File
@@ -0,0 +1,14 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.pdf binary
*.mp4 binary
*.webm binary
*.zip binary
*.woff binary
*.woff2 binary
+42
View File
@@ -0,0 +1,42 @@
name: Announce release
# Telegram notifications for t.me/ha_houseplan (owner request, 2026-08-07).
# Fires on every published release, prereleases included: the workflow file
# lives at the TAGGED commit, so it works for beta tags cut from dev as soon
# as this file is on dev, and for stable tags once it reaches main.
# workflow_dispatch exists purely as a connectivity test button.
on:
release:
types: [published]
workflow_dispatch: {}
permissions: {}
jobs:
telegram:
runs-on: ubuntu-latest
steps:
- name: Send to Telegram
env:
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
TAG: ${{ github.event.release.tag_name }}
NAME: ${{ github.event.release.name }}
URL: ${{ github.event.release.html_url }}
PRE: ${{ github.event.release.prerelease }}
# The body goes through env, never through shell interpolation —
# release notes are arbitrary text.
BODY: ${{ github.event.release.body }}
EVENT: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT" = "workflow_dispatch" ]; then
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
else
KIND=$([ "$PRE" = "true" ] && echo "🧪 Пре-релиз" || echo "🏠 Релиз")
SUMMARY=$(printf '%s' "$BODY" | head -c 2500)
TEXT=$(printf '%s houseplan-card %s — %s\n\n%s\n\n%s' \
"$KIND" "$TAG" "$NAME" "$SUMMARY" "$URL")
fi
curl -sS --fail-with-body -X POST \
"https://api.telegram.org/bot$TOKEN/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=$TEXT" \
-d disable_web_page_preview=true
+40
View File
@@ -0,0 +1,40 @@
name: Attach HACS zip to release
# hacs.json declares zip_release + filename=houseplan.zip, so every release
# (prereleases included) must carry the asset — HACS installs from it and
# GitHub's public download counter becomes a free per-version install metric
# (owner request, 2026-08-08). Like announce.yml, the workflow file lives at
# the TAGGED commit: betas cut from dev pick it up as soon as this file is on
# dev, stable tags once it reaches main.
# workflow_dispatch lets us attach the zip to an EXISTING release (needed
# once for the latest stable after the hacs.json change reaches main).
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to attach the zip to"
required: true
permissions:
contents: write
jobs:
zip:
runs-on: ubuntu-latest
steps:
- name: Resolve tag
id: tag
env:
EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ github.event.inputs.tag }}
run: echo "tag=${EVENT_TAG:-$INPUT_TAG}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
run: cd custom_components/houseplan && zip -qr ../../houseplan.zip .
- name: Sanity check
run: unzip -l houseplan.zip | grep -q "manifest.json"
- name: Upload asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ steps.tag.outputs.tag }}" houseplan.zip --clobber --repo "$GITHUB_REPOSITORY"
+123 -4
View File
@@ -1,6 +1,10 @@
name: Validate
on:
push:
# The branch commit is the release-gate authority. An annotated tag points
# to the same SHA and must not duplicate every expensive browser/perf job.
branches:
- '**'
pull_request:
schedule:
- cron: "0 4 * * 1"
@@ -32,8 +36,10 @@ jobs:
run: npm test
- name: Build
run: npm run build
- name: Card bundle in sync with integration
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
- name: Card bundle snapshots in sync
run: |
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
smoke:
# audit T2: the end-to-end layer used to run only when a human remembered.
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
@@ -47,8 +53,8 @@ jobs:
- name: Install Chromium for Playwright
run: npx playwright install --with-deps chromium
- name: Build a FRESH bundle for the smokes
# the committed demo/srv/assets copy is a snapshot; testing it would
# report green about code that no longer exists (audit T2)
# Never depend on a previous job's artefact; build the exact source
# under test again before the browser suite.
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Smoke suite
run: |
@@ -72,10 +78,123 @@ jobs:
name: smoke-logs
path: /tmp/smoke-logs
golden:
# Separate deterministic pixel layer. Before the first reviewed baseline
# set it captures candidates; once baselines exist the same job becomes a
# blocking comparison and uploads actual/diff/report on failure.
needs: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- name: Install pinned Chromium
run: npx playwright install --with-deps 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
id: golden
run: |
if find demo/golden/baselines -maxdepth 1 -name '*.png' -print -quit | grep -q .; then
echo "has_baselines=true" >> "$GITHUB_OUTPUT"
npm run golden:verify
else
echo "has_baselines=false" >> "$GITHUB_OUTPUT"
npm run golden:capture
fi
- name: Upload golden candidates/diffs
if: failure() || steps.golden.outputs.has_baselines == 'false'
uses: actions/upload-artifact@v4
with:
name: golden-images
path: artifacts/golden
performance:
# Candidate and base SHA run sequentially on the same hosted runner with
# one Playwright/Chromium installation. This avoids turning a developer
# laptop or cross-runner variance into a timing budget.
needs: frontend
runs-on: ubuntu-latest
steps:
- name: Check out candidate
uses: actions/checkout@v4
with:
path: candidate
fetch-depth: 2
- name: Resolve comparison SHA
id: base
working-directory: candidate
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
if [ "$EVENT_NAME" = "pull_request" ] && [ -n "$PR_BASE_SHA" ]; then
sha="$PR_BASE_SHA"
elif [ "$EVENT_NAME" = "push" ] && [ -n "$PUSH_BEFORE_SHA" ] && ! printf '%s' "$PUSH_BEFORE_SHA" | grep -Eq '^0+$'; then
sha="$PUSH_BEFORE_SHA"
else
sha="$(git rev-parse HEAD^)"
fi
# The first main promotion after HP-PERF-01 can legitimately point
# at an older stable commit which predates the benchmark's embedded
# bundle fingerprint. Comparing against that legacy tree would fail
# the freshness guard before any measurements are taken. Fall back
# to the candidate's direct parent, which is the last dev revision
# included in the promotion and already has the current harness.
git fetch --no-tags --depth=1 origin "$sha"
if ! git cat-file -e "${sha}:demo/bundle-freshness.mjs" 2>/dev/null; then
echo "comparison $sha predates HP-PERF-01; using HEAD^" >&2
sha="$(git rev-parse HEAD^)"
fi
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- name: Check out base SHA
uses: actions/checkout@v4
with:
ref: ${{ steps.base.outputs.sha }}
path: baseline
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: |
candidate/package-lock.json
baseline/package-lock.json
- name: Install candidate and baseline dependencies
run: npm ci --prefix candidate && npm ci --prefix baseline
- name: Install pinned Chromium
working-directory: candidate
run: npx playwright install --with-deps chromium
- name: Build both exact source trees
run: |
npm --prefix candidate run build
cp candidate/dist/houseplan-card.js candidate/demo/srv/assets/houseplan-card.js
npm --prefix baseline run build
cp baseline/dist/houseplan-card.js baseline/demo/srv/assets/houseplan-card.js
- name: Capture base and candidate profiles
working-directory: candidate
run: |
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
- name: Enforce performance budgets
working-directory: candidate
run: npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
- name: Upload performance reports
if: always()
uses: actions/upload-artifact@v4
with:
name: large-house-performance
path: artifacts/performance
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Browser fixtures are generated by their real ESM factories and then
# validated through the Python CONFIG_SCHEMA/LAYOUT_SCHEMA in the same test.
- uses: actions/setup-node@v4
with: { node-version: 22 }
- uses: actions/setup-python@v5
with: { python-version: "3.13" }
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
+1
View File
@@ -5,3 +5,4 @@ test-build/
__pycache__/
.pytest_cache/
.venv-backend/
artifacts/
+31 -3
View File
@@ -8,13 +8,32 @@ House Plan is one HACS package with two parts plus a demo harness:
Standard commands live in `package.json` scripts, `CONTRIBUTING.md`, and `docs/DEVELOPMENT.md`. Read `docs/ARCHITECTURE.md` and `docs/STATUS.md` before non-trivial changes.
## Canonical backlog
GitHub is the only active backlog for House Plan:
- [GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the
canonical task records: problem, scope, acceptance criteria and discussion.
- [GitHub Projects (v2)](https://github.com/users/Matysh/projects/1) is
the canonical prioritization and workflow-status view. Every open in-scope
issue must be present there.
Before starting planned work, find or create its issue and keep its description,
labels and Project status current as decisions and implementation state change.
Close an issue only after the result is verified. Specs, audits and ADRs may
remain under `docs/`, but must link to their issue and must not become a parallel
task list. When repository documentation disagrees with Issues or Project v2,
the GitHub backlog wins.
## Cursor Cloud specific instructions
The startup update script already runs `npm ci`, provisions a Python 3.13 backend venv at `.venv-backend`, and installs Playwright Chromium. You do not need to reinstall dependencies.
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, ~270 tests), `npm run build`. After building, keep the integration copy in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/`. CI enforces `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` byte-for-byte.
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (126 tests). Running `python3 -m pytest tests_backend` on the system 3.12 silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the ~83 pure tests.
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed `demo/srv/assets/houseplan-card.js` is a stale snapshot; testing it reports green about code that no longer exists. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, 424 tests at v1.60.0), `npm run build`. After building, keep both committed snapshots in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/` and `cp dist/houseplan-card.js demo/srv/assets/`. CI enforces both comparisons byte-for-byte.
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (150 tests at v1.60.0: 100 pure + 50 HA harness). Running `python3 -m pytest tests_backend` without Home Assistant silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the pure set.
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed demo snapshot must remain byte-identical to `dist` (CI checks it); rebuilding first also guarantees the browser suite tests the current source in an uncommitted worktree. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
- **Golden images**: `npm run golden:capture` and `npm run golden:verify` refuse a stale demo bundle. Build and copy the current bundle first, then review `artifacts/golden/actual/` and `diff/`. Update baselines only with `npm run golden:accept -- --reviewed`, using the complete Linux CI artifact; never accept a partial scenario or images merely to make CI green. See `demo/golden/README.md`.
- **Freshness contract**: the embedded fingerprint covers `src/` plus Rollup, TypeScript and package-lock build inputs. Benchmark and golden tooling must call `assertFreshDemoBundle` before recording any result; a missing or mismatched fingerprint is a hard failure, not a warning.
- **Demo harness render quirk**: the fake `hass` in `demo.html` is set once, so opening the page directly in a browser renders the floor plan but **device icons only appear after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does not. This is a harness limitation, not a card bug.
- **Known environment-sensitive smoke**: `demo/smoke_opening_measure.mjs` fails two sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It reproduces against the pristine committed bundle, so treat it as pre-existing/pixel-precision, not a regression you introduced.
- **Owner's local Windows checkout is invisible here.** Path
@@ -25,3 +44,12 @@ The startup update script already runs `npm ci`, provisions a Python 3.13 backen
Agent / My Machines worker inside that checkout. Day-to-day source of truth
for cloud sessions remains **`origin/dev`** (minors) and **`origin/main`**
(releases) — see `docs/STATUS.md`.
## Promotion rule
Every new feature or material behaviour change must be published as a beta/RC
before it can enter a stable release, even when its local audit is clean. The
stable release commit is promotion-only: version fields, generated bundle
snapshots and changelog/release metadata. Do not add feature source code in
that commit. An explicit owner-requested emergency hotfix is the only exception
and must be called out in the release handoff.
+10
View File
@@ -16,6 +16,16 @@ writing code? The **[Telegram chat @ha_houseplan](https://t.me/ha_houseplan)**
is the quickest route to the author and other users. Bugs and concrete feature
requests still belong in [issues](https://github.com/Matysh/houseplan-card/issues).
## Backlog and work status
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and the linked
[GitHub Project v2](https://github.com/users/Matysh/projects/1) are the
only active project backlog. Issues own scope and acceptance criteria; Project
v2 owns prioritization and workflow status. Before starting planned work, link
it to an existing issue or create one, add it to the Project, and keep both
surfaces current until the verified result is closed. Design specs and ADRs may
support an issue, but they do not replace it or maintain a separate checklist.
## Five-minute setup
```bash
+26 -12
View File
@@ -15,6 +15,11 @@ room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wa
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
right on your Lovelace dashboard.
> **Use a desktop computer to edit plans.** View and kiosk are fully supported
> on phones and tablets. The editors are designed primarily for a desktop
> browser with a mouse and keyboard; individual editing operations on touch
> devices may be awkward, limited, or unavailable.
![Interactive Home Assistant floor plan: live rooms, devices, lights and climate on a real floorplan card](docs/images/demo.gif)
> ### 🚀 Try it live — no install needed
@@ -101,9 +106,10 @@ Assistant integration:
| **Zoom** | Smooth vector zoom | Usually a fixed image | SVG / virtual canvas |
**One sentence:** House Plan is the shared, area-aware live map of your home —
not a drawing app for furniture. If you want to sketch sofas and walls from
scratch, a draw-centric card may fit better; if you already have a plan image
and a real HA areas registry, House Plan keeps every tablet on the same layout.
not a general-purpose CAD package. Its Background editor covers practical
decor, labels and furniture; if you need unrestricted architectural drafting,
a draw-centric tool may fit better. With a plan and HA areas, House Plan keeps
every tablet on the same live layout.
Key advantages in short:
@@ -112,11 +118,17 @@ Key advantages in short:
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
a light is shining, a socket is powering, a fan is spinning, media is playing, a vacuum is
cleaning, a radiator valve is actually heating (not merely enabled). Orange = open / unlocked.
a light is shining, a socket is powering, a fan is spinning, a vacuum is
cleaning, a radiator valve is actually heating (not merely enabled). For climate integrations,
a reported work action is authoritative; when an integration exposes only its enabled HVAC mode,
that mode is the best available fallback. Orange = open / unlocked.
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
A translucent icon = unavailable. Dark = idle.
- **A coherent visual Background editor.** Draw lines/shapes, place labels and
furniture, edit physical styles and transform every object with the same
selection model. The plan image has its own move/resize/rotate tool, numeric
properties and shared Undo/Redo.
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
---
@@ -198,7 +210,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions and can be moved and resized at any time in the Background editor.
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions by default and can be moved, resized or rotated at any time in the Background editor.
![Space creation dialog](docs/images/03-space-dialog.png)
@@ -222,15 +234,17 @@ Rooms may not overlap: a click strictly inside an existing room, or an outline t
- **Split** — click a room, then two points on its walls; the chord cuts it in two. The bigger part stays the room it was (name, area, devices); the smaller one asks for a new name and area.
### Doors, windows and locks
### Doors, windows, gates and locks
In markup mode the **"Opening"** tool places doors and windows: click next to a wall and the
In markup mode the **"Opening"** tool places doors, windows and gates: click next to a wall and the
opening snaps onto it. Pick the type, the **length in real centimetres** (defaults: door 90 cm,
window 120 cm), an open/close sensor and — for doors — a **lock entity**.
window 120 cm, gate 300 cm), an open/close sensor and — for doors and gates — a **lock entity**.
With a sensor bound, the plan comes alive: the door leaf swings on its hinge and the swing arc
draws itself in as the real door opens; a window opens its two casements. While open, the moving
parts take an accent colour. A door with a lock shows a padlock badge next to it — green when
parts take an accent colour. A gate keeps a 3–4 m opening compact on the plan: two half-width
leaves open only 10° outwards, without a full-width swing arc, while contact, lock and light
passage work exactly like a door. A door or gate with a lock shows a padlock badge next to it — green when
locked, orange when unlocked. For safety the lock can **not** be toggled from the plan; a click
on the opening shows a status card with both states instead.
@@ -241,7 +255,7 @@ walls** (it slides around corners too), and a **double click opens its propertie
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Show hidden"**: hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light.
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Hidden and disabled"**: user-hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light. A device disabled in Home Assistant appears there as a labelled grey service ghost and is excluded from all plan data/actions until it is enabled in HA again.
From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
@@ -327,7 +341,7 @@ Services → House Plan**.
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden (the "Hide device from plan" checkbox — set automatically for bridges, scenes and other non-physical records) — open the device editor, press **"Show hidden"** and untick the box in its dialog.
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. Open the device editor and press **"Hidden and disabled"**: a blue ghost is user-hidden and can be shown; a grey disabled ghost must first be enabled in Home Assistant.
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
+23 -15
View File
@@ -6,7 +6,7 @@
[![Live demo](https://img.shields.io/badge/демо-попробовать-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
[![Telegram chat](https://img.shields.io/badge/Telegram-чат-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · **[Продуктовый аудит и план улучшений](docs/PRODUCT-IMPROVEMENT-PLAN.ru.md)**
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · 🗂 **[Беклог проекта](https://github.com/users/Matysh/projects/1)**
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
@@ -15,6 +15,11 @@
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
редакторов — весь план настраивается прямо на дашборде.
> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
> операции могут быть менее удобны, работать ограниченно или отсутствовать.
![Интерактивный план дома для Home Assistant: комнаты, устройства, свет и климат на реальном поэтажном плане](docs/images/demo.gif)
> ### 🚀 Попробовать вживую — без установки
@@ -31,7 +36,7 @@
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
план обратно в экран.
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
- 🖱 **Редакторы прямо в карточке** — комнаты, двери, окна и ворота, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
линейка в реальных метрах.
@@ -99,9 +104,10 @@ YAML/SVG, либо конфиг живёт в YAML карточки. House Plan
| **Масштаб** | Плавный векторный зум | Обычно фиксированная картинка | SVG / виртуальный холст |
**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не
редактор мебели. Если нужно с нуля нарисовать диваны и стены — лучше
draw-карточка; если есть картинка плана и зоны в HA — House Plan держит один
и тот же layout на всех планшетах.
универсальная CAD-система. Редактор подложки покрывает практический декор,
надписи и мебель; для свободного архитектурного черчения лучше отдельный
draw-инструмент. При наличии плана и зон HA House Plan держит один живой layout
на всех планшетах.
Ключевые преимущества коротко:
@@ -110,12 +116,14 @@ draw-карточка; если есть картинка плана и зоны
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно;
если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением.
Оранжевый = открыто / не заперто. Пульсирующее красное
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Подложку можно двигать и масштабировать.** Картинка плана не прибита к холсту: в редакторе подложки её тянут за тело и за угол, а плашка показывает реальный размер в метрах — чертёж и фотография плана наконец совмещаются.
- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
---
@@ -199,7 +207,7 @@ title: План дома
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png)
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка сохраняет свои пропорции, а подвинуть и отмасштабировать её можно в любой момент в редакторе подложки.
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка по умолчанию сохраняет пропорции, а подвинуть, изменить размер или повернуть её можно в любой момент в редакторе подложки.
![Диалог создания пространства](docs/images/03-space-dialog.png)
@@ -223,15 +231,15 @@ title: План дома
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
### Двери, окна и замки
### Двери, окна, ворота и замки
В режиме разметки инструмент **«Проём»** ставит двери и окна: кликните рядом со стеной — проём
В режиме разметки инструмент **«Проём»** ставит двери, окна и ворота: кликните рядом со стеной — проём
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
окно 120 см), датчик открытия и — для двери — **замок**.
окно 120 см, ворота 300 см), датчик открытия и — для двери или ворот — **замок**.
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
подвижные части подсвечены акцентным цветом. У двери с замком рядом отображается замочек —
подвижные части подсвечены акцентным цветом. Ворота не занимают полплана даже при ширине 3–4 м: две половинные створки показаны открытыми наружу всего на 10°, без большой дуги. Датчик, замок и пропуск света работают как у двери. У двери или ворот с замком рядом отображается замочек —
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
переключить — клик по проёму показывает карточку с обоими статусами.
@@ -242,7 +250,7 @@ title: План дома
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти скрытые устройства, откройте редактор устройств и нажмите **«Показать скрытые»**: они отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти их, откройте редактор устройств и нажмите **«Скрытые и деактивированные»**: пользовательски скрытые устройства отображаются синими призраками. Деактивированное в HA устройство показывается серым служебным призраком и полностью исключается из данных и действий плана до повторной активации.
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
@@ -329,7 +337,7 @@ title: План дома
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто автоматически — откройте редактор устройств, нажмите **«Показать скрытые»**, откройте его диалог, нажмите **«Показать»**, затем **«Сохранить»**.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Откройте **«Скрытые и деактивированные»**: синий призрак можно показать в его диалоге, серый сначала нужно активировать в Home Assistant.
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.59.2"
VERSION = "1.60.3"
DEFAULT_CONFIG: dict = {
"spaces": [],
@@ -31,6 +31,9 @@ async def async_get_config_entry_diagnostics(
"has_plan": bool(s.get("plan_url")),
"rooms": len(s.get("rooms", [])),
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
"room_drafts": len(s.get("room_drafts", [])),
"partitions": len(s.get("partitions", [])),
"wall_columns": len(s.get("wall_columns", [])),
}
for s in config.get("spaces", [])
],
File diff suppressed because one or more lines are too long
@@ -64,6 +64,16 @@ def migrate_space(space: dict[str, Any]) -> bool:
if room.get("poly"):
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
for draft in space.get("room_drafts") or []:
draft["points"] = [_pt(p, dx, dy, kx, ky) for p in draft.get("points") or []]
for part in space.get("partitions") or []:
part["a"] = _pt(part.get("a"), dx, dy, kx, ky)
part["b"] = _pt(part.get("b"), dx, dy, kx, ky)
for column in space.get("wall_columns") or []:
column["center"] = _pt(column.get("center"), dx, dy, kx, ky)
for op in space.get("openings") or []:
op["x"] = dx + float(op.get("x", 0)) * kx
op["y"] = dy + float(op.get("y", 0)) * ky
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.59.2"
"version": "1.60.3"
}
@@ -27,6 +27,9 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"config_rev": cfg_raw.get("rev", 0),
"spaces": len(config.get("spaces", [])),
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
"room_drafts": sum(len(s.get("room_drafts", [])) for s in config.get("spaces", [])),
"partitions": sum(len(s.get("partitions", [])) for s in config.get("spaces", [])),
"wall_columns": sum(len(s.get("wall_columns", [])) for s in config.get("spaces", [])),
"markers": len(config.get("markers", [])),
"layout_entries": len(layout_raw.get("layout", {})),
}
+42 -10
View File
@@ -90,6 +90,10 @@ class TrailBook:
return True
return False
def delete(self, marker: str) -> bool:
"""Forget every stored run of one plan marker."""
return self.data.pop(marker, None) is not None
class TrailRecorder:
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
@@ -131,6 +135,8 @@ class TrailRecorder:
cfg = stored.get("config") or {}
pairs: dict[str, list[tuple[str, str]]] = {}
for m in cfg.get("markers") or []:
if m.get("removed") is True:
continue
v = m.get("vacuum") or {}
src = v.get("source")
if not src or v.get("live") is False:
@@ -141,16 +147,7 @@ class TrailRecorder:
# marker records its own copy of the run
pairs.setdefault(src, []).append((str(m.get("id")), vac))
self.pairs = pairs
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
# deduplicated: two markers of one robot share source AND vacuum
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
if ents:
self._unsub_track = async_track_state_change_event(
self.hass, sorted(ents), self._on_state
)
self._resubscribe()
# A run already in progress (HA restarted mid-cleanup, or the user
# just finished calibrating) must start recording NOW, not at the
# next state change — otherwise the first seconds of the path are
@@ -158,6 +155,41 @@ class TrailRecorder:
for src in self.pairs:
self._sample(src, time.time())
async def async_delete(self, marker: str) -> bool:
"""Stop and erase one marker without racing subscription refresh/save."""
async with self._refresh_lock:
# The trail book owns deletion. When it has no such marker, this
# is a no-op and must not silently damage the live tracking graph.
removed = self.book.delete(marker)
if not removed:
return False
for src in list(self.pairs):
kept = [pair for pair in self.pairs[src] if pair[0] != marker]
if kept:
self.pairs[src] = kept
else:
del self.pairs[src]
self._resubscribe()
if self._unsub_save:
self._unsub_save()
self._unsub_save = None
await self.store.async_save(self.book.data)
self.hass.bus.async_fire("houseplan_trail_updated", {})
return True
def _resubscribe(self) -> None:
"""Replace the state subscription for the current pair graph."""
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
# deduplicated: two markers of one robot share source AND vacuum
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
if ents and not self._closed:
self._unsub_track = async_track_state_change_event(
self.hass, sorted(ents), self._on_state
)
def teardown(self) -> None:
# HP-1540-05: flag FIRST — a refresh parked on its awaited load must
# not re-subscribe after this cleanup has already run
+153 -12
View File
@@ -69,6 +69,10 @@ MAX_MARKERS = 2000
MAX_OPENINGS = 500
MAX_DECOR = 1000
MAX_WALLS = 500
MAX_ROOM_DRAFTS = 200
MAX_DRAFT_SEGMENTS = 2000
MAX_PARTITIONS = 2000
MAX_WALL_COLUMNS = 500
# Open (virtual) wall stretches, docs/superpowers/specs/2026-08-05-open-spans-delete-design.md.
# Every span is a piece of a shared boundary, so there can never be more of
# them than there are wall segments — the cap is the walls' one (AUD-159B6-03).
@@ -95,6 +99,8 @@ MAX_URL = 2000
# they can act on. For scale, a real three-floor home with ~200 devices stores
# about 70 KB, so this is ~30x headroom.
MAX_CONFIG_BYTES = 2 * 1024 * 1024
CELL_CM_MIN = 0.1
CELL_CM_MAX = 1000.0
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
_TEXT_OR_NONE = vol.Any(None, _TEXT)
@@ -283,6 +289,7 @@ MAX_DECOR_UNIT = 16
# could mean on a 1000-unit canvas, the rest is garbage insurance.
DECOR_TEXT_SCALE_MIN = 0.15
DECOR_TEXT_SCALE_MAX = 20.0
DECOR_TEXT_CM_MAX = 2000.0
# A furniture symbol id (docs/FURNITURE.md). Deliberately NOT the card's list:
# the backend must accept a plan written by a NEWER card, and a card that has
# learnt a new symbol must not have to wait for the integration to be updated
@@ -299,6 +306,10 @@ _FURN_SIZE = vol.All(_finite, vol.Range(min=0.0000001, max=CANVAS_LIMIT))
_DECOR_COMMON = {
vol.Required("id"): str,
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0)),
# Physical centimetres are canonical. `width` remains accepted so plans
# written by older cards keep their exact appearance until edited.
vol.Optional("width_cm"): vol.All(_finite, vol.Range(min=0.1, max=100)),
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
}
# Decor lives on the same unbounded canvas as everything else (docs/CANVAS.md):
@@ -307,14 +318,18 @@ _NORM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
DECOR_SCHEMA = vol.Any(
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM},
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM,
vol.Optional("line_style"): vol.In(["solid", "dashed"])},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
# sizes are extents — negative/zero is garbage, not "canvas slack"
vol.Required("w"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
vol.Required("h"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
vol.Optional("fill"): bool},
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
vol.Optional("fill"): bool,
vol.Optional("fill_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("fill_opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0))},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
@@ -326,8 +341,11 @@ DECOR_SCHEMA = vol.Any(
# written before that keeps it, and it is read as the scale it
# used to render at. Kept in the schema so it stays BOUNDED.
vol.Optional("size"): vol.In(["s", "m", "l"]),
# size and rotation of the block (docs/LIVE-TEXT.md §block).
# Both optional: a shape without them is the old one exactly.
# Canonical physical font size plus the legacy scale. Both are
# accepted so older plans remain pixel-identical until edited
# or explicitly optimized.
vol.Optional("size_cm"): vol.All(
_finite, vol.Range(min=0.1, max=DECOR_TEXT_CM_MAX)),
vol.Optional("scale"): vol.All(
_finite, vol.Range(min=DECOR_TEXT_SCALE_MIN, max=DECOR_TEXT_SCALE_MAX)),
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
@@ -374,10 +392,107 @@ WALL_SCHEMA = vol.All(
_wall_endpoints_pair,
)
SPACE_SCHEMA = vol.Schema(
def _room_draft_segments(value: dict) -> dict:
"""An open draft has exactly one thickness per consecutive edge."""
if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1):
raise vol.Invalid("room draft segments must match consecutive point pairs")
if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:])):
raise vol.Invalid("room draft consecutive points must differ")
return value
ROOM_DRAFT_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("points"): vol.All([POINT], vol.Length(min=2, max=500)),
vol.Required("segments"): vol.All(
[vol.Schema({vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100))},
extra=vol.ALLOW_EXTRA)],
vol.Length(min=1, max=499),
),
},
extra=vol.ALLOW_EXTRA,
),
_room_draft_segments,
)
def _partition_nonzero(value: dict) -> dict:
if value["a"] == value["b"]:
raise vol.Invalid("partition endpoints must differ")
return value
PARTITION_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("a"): POINT,
vol.Required("b"): POINT,
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
},
extra=vol.ALLOW_EXTRA,
),
_partition_nonzero,
)
def _strict_wall_column(value: dict) -> dict:
"""Reject shape-inapplicable or non-canonical column fields."""
if value["shape"] == "circle" and "angle" in value:
raise vol.Invalid("angle is allowed only for square wall columns")
if value["shape"] == "square" and value.get("angle", 0) >= 90:
raise vol.Invalid("square wall column angle must be in [0, 90)")
return value
WALL_COLUMN_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("shape"): vol.In(["square", "circle"]),
vol.Required("center"): POINT,
# Outer side for a square, outer diameter for a circle.
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=150)),
vol.Optional("angle"): vol.All(
_finite, vol.Range(min=0, max=90)
),
},
extra=vol.ALLOW_EXTRA,
),
_strict_wall_column,
)
def _space_geometry_invariants(value: dict) -> dict:
"""All stored geometry shares ids; draft segments also have a space cap."""
seen: set[str] = set()
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
for item in value.get(key, []):
item_id = item.get("id")
if not item_id:
continue
if item_id in seen:
raise vol.Invalid("geometry object ids must be unique within a space")
seen.add(item_id)
draft_segments = sum(
len(item.get("segments", [])) for item in value.get("room_drafts", [])
)
if draft_segments > MAX_DRAFT_SEGMENTS:
raise vol.Invalid("too many saved room-draft segments")
return value
SPACE_SCHEMA = vol.All(vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Match(SPACE_ID_RE.pattern)),
vol.Required("title"): str,
# Physical grid scale. It feeds every px/cell -> centimetres migration,
# so NaN/Infinity or an absurd value must not be allowed to manufacture
# invalid decor sizes later.
vol.Optional("cell_cm"): vol.All(
_finite, vol.Range(min=CELL_CM_MIN, max=CELL_CM_MAX)
),
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
vol.Optional("plan_url"): vol.Any(str, None),
# The canvas is square since v1.48.0. What used to be the space's own
@@ -389,10 +504,10 @@ SPACE_SCHEMA = vol.Schema(
vol.Optional("plan_aspect"): vol.Any(
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
),
# Backdrop placement (docs/BACKDROP.md): the picture may be moved and
# scaled UNIFORMLY on the canvas. All three are optional and their
# absence is the pre-v1.58.0 behaviour exactly — there is no migration,
# and an old config validates unchanged. The offset is a normalised
# Backdrop placement (docs/BACKDROP.md): the picture may be moved,
# resized per axis and rotated. Every transform field is optional; its
# complete absence is the pre-v1.58.0 behaviour exactly, and an old
# config validates unchanged. The offset is a normalised
# coordinate like every other one (the ±CANVAS_LIMIT garbage guard);
# the scale is a positive multiplier in a range a human could mean.
vol.Optional("plan_x"): vol.Any(None, _COORD),
@@ -400,6 +515,17 @@ SPACE_SCHEMA = vol.Schema(
vol.Optional("plan_scale"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
# New writes may stretch each axis independently and rotate. The old
# uniform field remains a read-compatible fallback for both axes.
vol.Optional("plan_scale_x"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
vol.Optional("plan_scale_y"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
vol.Optional("plan_angle"): vol.Any(
None, vol.All(_finite, vol.Range(min=-360.0, max=360.0))
),
vol.Required("view_box"): _view_box,
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
@@ -407,7 +533,7 @@ SPACE_SCHEMA = vol.Schema(
vol.Schema(
{
vol.Required("id"): str,
vol.Required("type"): vol.Any("door", "window"),
vol.Required("type"): vol.Any("door", "window", "gate"),
vol.Required("x"): _GEOM,
vol.Required("y"): _GEOM,
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
@@ -427,6 +553,15 @@ SPACE_SCHEMA = vol.Schema(
# (midpoint + direction), thickness always in centimetres. Optional —
# a space without `walls` validates and renders exactly as before.
vol.Optional("walls"): vol.All([WALL_SCHEMA], vol.Length(max=MAX_WALLS)),
vol.Optional("room_drafts"): vol.All(
[ROOM_DRAFT_SCHEMA], vol.Length(max=MAX_ROOM_DRAFTS)
),
vol.Optional("partitions"): vol.All(
[PARTITION_SCHEMA], vol.Length(max=MAX_PARTITIONS)
),
vol.Optional("wall_columns"): vol.All(
[WALL_COLUMN_SCHEMA], vol.Length(max=MAX_WALL_COLUMNS)
),
# Open (virtual) wall stretches: a piece of a shared boundary that the
# user opened. Optional and bounded — a space without `open_spans`
# validates exactly as before, and the legacy `rooms[].open_to` index
@@ -443,7 +578,7 @@ SPACE_SCHEMA = vol.Schema(
vol.Remove("segments"): object,
},
extra=vol.ALLOW_EXTRA,
)
), _space_geometry_invariants)
MARKER_SCHEMA = vol.Schema(
{
vol.Required("id"): str,
@@ -456,6 +591,9 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("space"): vol.Any(str, None),
vol.Optional("area"): vol.Any(str, None),
vol.Optional("hidden"): bool,
# A binding-level tombstone: not rendered or aggregated, but retained
# so automatic discovery does not put a deleted device straight back.
vol.Optional("removed"): bool,
vol.Optional("name"): _TEXT_OR_NONE,
vol.Optional("icon"): _TEXT_OR_NONE,
vol.Optional("model"): _TEXT_OR_NONE,
@@ -494,7 +632,7 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("room_id"): vol.Any(str, None),
# Keep in sync with DISPLAY_MODES in src/logic.ts. `ripple` is no longer
# offered, but remains accepted while old stores migrate to icon_ripple.
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", "static_icon", None),
vol.Optional("ripple_color"): vol.Any(
None, vol.Match(r"^#[0-9a-fA-F]{6}$")
),
@@ -521,6 +659,9 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
# legacy field so an existing stored config can still load; the
# frontend ignores it and removes it on the next settings save.
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
+86 -1
View File
@@ -73,6 +73,7 @@ def async_register(hass: HomeAssistant) -> None:
"""Register the WS commands."""
websocket_api.async_register_command(hass, ws_layout_get)
websocket_api.async_register_command(hass, ws_trail_get)
websocket_api.async_register_command(hass, ws_trail_delete)
websocket_api.async_register_command(hass, ws_layout_set)
websocket_api.async_register_command(hass, ws_geometry_repair)
websocket_api.async_register_command(hass, ws_layout_update)
@@ -110,6 +111,36 @@ def _check_write(hass: HomeAssistant, connection) -> bool:
# ---------------- layout ----------------
def _live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
"""Drop positions which a deleted marker can no longer own.
HA device ids may be layout-only because auto-discovered devices need no
marker entry. Virtual ids are different: every live virtual marker is
explicit, so a missing `v_*` owner is stale data. The prefix itself is
only a legacy naming convention, however; an explicit real marker remains
authoritative even when its id happens to begin with `v_`.
"""
markers = config.get("markers") or []
removed_ids = {
str(m.get("id")) for m in markers if m.get("removed") is True
}
virtual_ids = {
str(m.get("id")) for m in markers
if m.get("removed") is not True and m.get("binding") == "virtual"
}
explicit_live_ids = {
str(m.get("id")) for m in markers
if m.get("removed") is not True and m.get("binding") != "virtual"
}
return {
marker_id: pos for marker_id, pos in layout.items()
if marker_id not in removed_ids
and (not marker_id.startswith("v_")
or marker_id in virtual_ids
or marker_id in explicit_live_ids)
}
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
@websocket_api.async_response
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
@@ -151,6 +182,7 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None:
return
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
data = await rt.store.async_load() or {}
current_rev = int(data.get("rev", 0))
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
@@ -158,10 +190,11 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
)
return
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
new_rev = current_rev + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
"layout": msg["layout"], "rev": new_rev})
"layout": layout, "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -183,6 +216,39 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
if rt is None:
return
async with rt.write_lock:
# A stale browser may still finish a drag after another client deleted
# the marker. Its tombstone is the server-side authority: acknowledge
# but ignore the late point so re-adding starts without a zombie
# position.
config_data = await rt.config_store.async_load() or {}
config = config_data.get("config") or {}
markers = config.get("markers") or []
deleted = any(
str(m.get("id")) == msg["device_id"] and m.get("removed") is True
for m in markers
)
live_virtual = any(
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
and m.get("binding") == "virtual"
for m in markers
)
live_explicit = any(
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
for m in markers
)
orphan_virtual = (
msg["device_id"].startswith("v_")
and not live_virtual
and not live_explicit
)
if deleted or orphan_virtual:
data = await rt.store.async_load() or {}
connection.send_result(msg["id"], {
"ok": True,
"ignored": "removed" if deleted else "missing_virtual",
"rev": int(data.get("rev", 0)),
})
return
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
layout[msg["device_id"]] = msg["pos"]
@@ -1022,3 +1088,22 @@ async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConn
"""Current + previous cleanup runs per marker, raw robot coordinates."""
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
connection.send_result(msg["id"], {"trails": rec.book.data if rec else {}})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/trail/delete",
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=256)),
}
)
@websocket_api.async_response
async def ws_trail_delete(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
"""Permanently forget one deleted marker's current and previous runs."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete trails")
return
if _runtime(hass, connection, msg["id"]) is None:
return
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
removed = await rec.async_delete(msg["marker_id"]) if rec else False
connection.send_result(msg["id"], {"ok": True, "removed": removed})
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env node
/** Reproducible browser benchmark and report producer for HP-PERF-01. */
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { launch } from './serve.mjs';
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const targetRoot = resolve(valueArg('target-root') ?? '.');
const fixture = makeLargeHouseFixture();
const viewport = { width: 1440, height: 1000 };
const { page, browser } = await launch(
viewport,
1,
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
{},
resolve(targetRoot, 'demo/srv'),
);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.addStyleTag({
content: '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important;caret-color:transparent!important}',
});
const chromium = await browser.version();
let buildFingerprint;
try {
buildFingerprint = await assertFreshDemoBundle(page, targetRoot);
} catch (error) {
await browser.close();
throw error;
}
const rows = [];
try {
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const measuredSample = iteration - warmups;
const row = await page.evaluate(async ({ fixture, sample }) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error('large-house benchmark timed out');
await new Promise((done) => setTimeout(done, 10));
}
};
const startLongTaskWindow = () => {
const entries = [];
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask')) {
return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) };
}
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
observer.observe({ type: 'longtask', buffered: false });
return {
stop: async () => {
await new Promise((done) => setTimeout(done, 0));
entries.push(...observer.takeRecords());
observer.disconnect();
const durations = entries.map((entry) => entry.duration);
return {
supported: true,
count: durations.length,
maxMs: Number((durations.length ? Math.max(...durations) : 0).toFixed(2)),
totalMs: Number(durations.reduce((sum, value) => sum + value, 0).toFixed(2)),
};
},
};
};
const duration = async (action) => {
const longTasks = startLongTaskWindow();
const started = performance.now();
await action();
await frame();
return {
ms: Number((performance.now() - started).toFixed(2)),
longTasks: await longTasks.stop(),
};
};
const forceGc = async () => {
if (typeof globalThis.gc !== 'function') return false;
globalThis.gc();
await frame();
globalThis.gc();
await frame();
return true;
};
const cacheSnapshot = (card) => ({
cleanFloor: card._cleanFloorCache?.size ?? 0,
glowClip: card._glowClipCache?.size ?? 0,
wallUnion: card._wallUnionCache ? 1 : 0,
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
});
window.__card?.remove?.();
localStorage.clear();
const host = document.getElementById('host');
const card = document.createElement('houseplan-card');
card.setConfig({
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
});
const connection = {
subscribeEvents: async () => () => undefined,
subscribeMessage: async () => () => undefined,
};
const hassFor = (states) => ({
language: 'en', locale: { language: 'en' },
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas, states,
floors: {
one: { floor_id: 'one', name: 'One', level: 0 },
two: { floor_id: 'two', name: 'Two', level: 1 },
three: { floor_id: 'three', name: 'Three', level: 2 },
},
callWS: async (message) => {
if (message.type === 'houseplan/config/get')
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
if (message.type === 'houseplan/layout/get')
return { layout: structuredClone(fixture.layout), rev: 1 };
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
if (message.type === 'config_entries/get')
return [{ entry_id: 'perf_entry', domain: 'houseplan_perf', title: 'Synthetic performance fixture' }];
if (message.type === 'manifest/list') return [{ domain: 'houseplan_perf', name: 'House Plan Performance' }];
return { ok: true };
},
callService: async () => undefined,
connection,
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
});
const loadLongTasks = startLongTaskWindow();
const loadStarted = performance.now();
host.replaceChildren(card);
card.hass = hassFor(fixture.states);
await until(() => card._loadOk && card._model?.length === fixture.counts.floors);
await card.updateComplete;
await frame();
const modelReadyMs = Number((performance.now() - loadStarted).toFixed(2));
await until(() => card._booting === false);
await frame();
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
const loadLongTaskResult = await loadLongTasks.stop();
const spaceSwitch = await duration(async () => {
card._pickSpace('perf-floor-2');
await card.updateComplete;
});
const firstEntity = Object.keys(fixture.states)[0];
const nextStates = {
...fixture.states,
[firstEntity]: { ...fixture.states[firstEntity], state: fixture.states[firstEntity].state === 'on' ? 'off' : 'on' },
};
const stateUpdate = await duration(async () => {
card.hass = hassFor(nextStates);
await card.updateComplete;
});
const resizePreview = await duration(async () => {
card._setMode('plan');
card._tool = 'resize';
await card.updateComplete;
const room = card._rszRooms()[0];
const pointerId = 777;
const quietEvent = {
pointerId,
stopPropagation: () => undefined,
preventDefault: () => undefined,
target: null,
};
card._rszEdgeDown(quietEvent, room.id, 1);
const plan = card._rszDrag?.plan;
if (!plan) throw new Error('large-house resize plan was not created');
const target = [
plan.a[0] + plan.n[0] * card._gridPitch,
plan.a[1] + plan.n[1] * card._gridPitch,
];
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const view = card._viewOr(card._baseVb());
card._rszMove({
...quietEvent,
clientX: rect.left + ((target[0] - view.x) / view.w) * rect.width,
clientY: rect.top + ((target[1] - view.y) / view.h) * rect.height,
});
await card.updateComplete;
card._rszCancelDrag();
card._setMode('view');
await card.updateComplete;
});
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const panZoom = await duration(async () => {
stage.dispatchEvent(new WheelEvent('wheel', {
deltaY: -120, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
bubbles: true, cancelable: true,
}));
await card.updateComplete;
});
const settingsDialog = await duration(async () => {
card._openSettingsDialog();
await card.updateComplete;
});
card._settingsDialog = null;
await card.updateComplete;
const switchCycle = await duration(async () => {
for (let index = 0; index < 12; index++) {
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
await card.updateComplete;
// A user cannot produce twelve tab clicks in one JavaScript task.
// Yield between interactions so Long Task entries describe one
// switch, while switchCycleMs still measures the complete cycle.
await new Promise((done) => setTimeout(done, 0));
}
});
await forceGc();
const cacheBefore = cacheSnapshot(card);
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
for (let round = 0; round < 4; round++) {
for (let index = 0; index < 12; index++) {
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
await card.updateComplete;
await new Promise((done) => setTimeout(done, 0));
}
await forceGc();
}
const cacheEntries = cacheSnapshot(card);
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
const cacheGrowth = Object.fromEntries(
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
);
const result = {
sample,
modelReadyMs,
firstStableRenderMs,
spaceSwitchMs: spaceSwitch.ms,
stateUpdateMs: stateUpdate.ms,
resizePreviewMs: resizePreview.ms,
panZoomMs: panZoom.ms,
settingsDialogMs: settingsDialog.ms,
switchCycleMs: switchCycle.ms,
longTasks: {
load: loadLongTaskResult,
spaceSwitch: spaceSwitch.longTasks,
stateUpdate: stateUpdate.longTasks,
resizePreview: resizePreview.longTasks,
panZoom: panZoom.longTasks,
settingsDialog: settingsDialog.longTasks,
switchCycle: switchCycle.longTasks,
},
cacheEntries,
cacheGrowth,
heapGrowthBytes: heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore,
preciseGc: typeof globalThis.gc === 'function',
renderedDevices: card._devices?.length ?? 0,
};
card.remove();
await frame();
return result;
}, { fixture, sample: measuredSample });
if (measuredSample >= 0) rows.push(row);
}
} finally {
await browser.close();
}
const metricNames = [
'modelReadyMs', 'firstStableRenderMs', 'spaceSwitchMs', 'stateUpdateMs',
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
];
const report = {
schema: 2,
profile: 'large-house-v1',
generatedAt: new Date().toISOString(),
buildFingerprint,
runtime: {
node: process.version,
chromium,
platform: process.platform,
arch: process.arch,
viewport,
deviceScaleFactor: 1,
reducedMotion: true,
},
fixture: LARGE_HOUSE_COUNTS,
samples,
warmups,
summary: summarizeTimings(rows, metricNames),
longTasks: summarizeLongTasks(rows),
rows,
note: 'Compare with a base-SHA report captured by the same runner and evaluate demo/performance/budgets.json.',
};
const text = `${JSON.stringify(report, null, 2)}\n`;
if (output) {
mkdirSync(dirname(output), { recursive: true });
writeFileSync(output, text, 'utf8');
console.log(output);
} else {
process.stdout.write(text);
}
+15
View File
@@ -0,0 +1,15 @@
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
/** Refuse measurements/screenshots made by a committed bundle from old source. */
export async function assertFreshDemoBundle(page, root = process.cwd()) {
const expected = sourceFingerprint(root);
const loaded = await page.evaluate(() => globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__ ?? null);
if (loaded !== expected) {
throw new Error(
'demo/srv/assets/houseplan-card.js is stale. Run npm run build and copy '
+ 'dist/houseplan-card.js to demo/srv/assets/houseplan-card.js first. '
+ `Expected ${expected}, loaded ${loaded || 'no fingerprint'}.`,
);
}
return expected;
}
+231
View File
@@ -0,0 +1,231 @@
/**
* Deterministic, fictional high-load fixture shared by performance and future
* visual-regression tooling. Nothing here depends on a real HA installation.
*/
const FLOOR_COUNT = 3;
const ROOMS_PER_FLOOR = 20;
const DEVICE_COUNT = 200;
const OPENING_COUNT = 100;
const PARTITION_COUNT = 60;
const COLUMN_COUNT = 40;
const DECOR_COUNT = 500;
const round = (value) => Number(value.toFixed(6));
const roomGrid = (floor) => {
const rooms = [];
const left = 0.04;
const top = 0.04;
const width = 0.92 / 5;
const height = 0.92 / 4;
for (let row = 0; row < 4; row++) {
for (let column = 0; column < 5; column++) {
const index = row * 5 + column;
const x1 = round(left + column * width);
const y1 = round(top + row * height);
const x2 = round(x1 + width);
const y2 = round(y1 + height);
rooms.push({
id: `perf-room-${floor}-${index}`,
name: `Room ${floor + 1}.${index + 1}`,
area: `perf_area_${floor}_${index}`,
poly: [[x1, y1], [x2, y1], [x2, y2], [x1, y2]],
});
}
}
return rooms;
};
const wallSegments = (rooms) => {
const unique = new Map();
for (const room of rooms) {
room.poly.forEach((a, index) => {
const b = room.poly[(index + 1) % room.poly.length];
const forward = `${a.join(',')}/${b.join(',')}`;
const reverse = `${b.join(',')}/${a.join(',')}`;
if (!unique.has(reverse)) unique.set(forward, { a, b });
});
}
return [...unique.values()];
};
const makeOpenings = (floor, walls, count) => walls.slice(0, count).map((wall, index) => {
const horizontal = Math.abs(wall.b[0] - wall.a[0]) >= Math.abs(wall.b[1] - wall.a[1]);
return {
id: `perf-opening-${floor}-${index}`,
type: index % 7 === 0 ? 'window' : index % 11 === 0 ? 'gate' : 'door',
x: round((wall.a[0] + wall.b[0]) / 2),
y: round((wall.a[1] + wall.b[1]) / 2),
angle: horizontal ? 0 : 90,
length: horizontal ? 0.045 : 0.055,
};
});
const makePartitions = (floor, rooms, count) => Array.from({ length: count }, (_, index) => {
const room = rooms[index % rooms.length];
const [a, , c] = room.poly;
const y = round(a[1] + (c[1] - a[1]) * (0.35 + (index % 3) * 0.12));
return {
id: `perf-partition-${floor}-${index}`,
a: [round(a[0] + 0.035), y],
b: [round(c[0] - 0.035), y],
cm: 10 + (index % 3) * 5,
};
});
const makeColumns = (floor, rooms, count) => Array.from({ length: count }, (_, index) => {
const room = rooms[(index * 3) % rooms.length];
const [a, , c] = room.poly;
return {
id: `perf-column-${floor}-${index}`,
shape: index % 3 === 0 ? 'circle' : 'square',
center: [
round(a[0] + (c[0] - a[0]) * (0.28 + (index % 2) * 0.44)),
round(a[1] + (c[1] - a[1]) * (0.28 + ((index >> 1) % 2) * 0.44)),
],
cm: 25 + (index % 4) * 5,
...(index % 3 === 0 ? {} : { angle: (index % 6) * 15 }),
};
});
const makeDecor = (floor, count) => Array.from({ length: count }, (_, index) => {
const column = index % 25;
const row = Math.floor(index / 25);
const x = round(0.02 + column * 0.039);
const y = round(0.018 + (row % 20) * 0.048);
if (index % 10 === 0) {
return {
id: `perf-decor-${floor}-${index}`,
kind: 'text', x, y, text: `F${floor + 1}-${index}`, size_cm: 14,
color: '#59636e', opacity: 0.75,
};
}
if (index % 3 === 0) {
return {
id: `perf-decor-${floor}-${index}`,
kind: 'rect', x, y, w: 0.022, h: 0.018, angle: (index % 12) * 5,
color: '#687681', opacity: 0.55, width_cm: 1.5,
fill: index % 2 === 0, fill_color: '#75838e', fill_opacity: 0.12,
};
}
return {
id: `perf-decor-${floor}-${index}`,
kind: 'line', x1: x, y1: y, x2: round(x + 0.025), y2: round(y + (index % 2 ? 0.012 : 0)),
color: '#687681', opacity: 0.6, width_cm: 1.2,
...(index % 9 === 0 ? { line_style: 'dashed' } : {}),
};
});
const entityKinds = [
['light', 'on'],
['switch', 'off'],
['sensor', '21.5'],
['binary_sensor', 'off'],
['climate', 'heat'],
['media_player', 'playing'],
['cover', 'closed'],
['fan', 'on'],
['lock', 'locked'],
['vacuum', 'docked'],
];
const makeRuntime = (spaces) => {
const devices = {};
const entities = {};
const states = {};
const areas = {};
const layout = {};
const roomRefs = spaces.flatMap((space) => space.rooms.map((room) => ({ space, room })));
for (const { room } of roomRefs) areas[room.area] = { area_id: room.area, name: room.name };
for (let index = 0; index < DEVICE_COUNT; index++) {
const { space, room } = roomRefs[index % roomRefs.length];
const [domain, baseState] = entityKinds[index % entityKinds.length];
const deviceId = `perf-device-${index}`;
const entityId = `${domain}.perf_${index}`;
devices[deviceId] = {
id: deviceId,
name: `Synthetic ${domain} ${index + 1}`,
model: `PERF-${String(index + 1).padStart(3, '0')}`,
area_id: room.area,
identifiers: [['houseplan_perf', deviceId]],
config_entries: ['perf_entry'],
entry_type: null,
via_device_id: null,
disabled_by: null,
};
entities[entityId] = {
entity_id: entityId,
device_id: deviceId,
platform: 'houseplan_perf',
config_entry_id: 'perf_entry',
disabled_by: null,
};
const attributes = { friendly_name: devices[deviceId].name };
if (domain === 'sensor') Object.assign(attributes, {
device_class: 'temperature', unit_of_measurement: '°C', state_class: 'measurement',
});
if (domain === 'binary_sensor') attributes.device_class = index % 2 ? 'motion' : 'occupancy';
if (domain === 'climate') Object.assign(attributes, { current_temperature: 21.5, temperature: 22 });
states[entityId] = { entity_id: entityId, state: index % 4 === 0 && domain === 'light' ? 'off' : baseState, attributes };
const [a, , c] = room.poly;
layout[deviceId] = {
s: space.id,
x: round(a[0] + (c[0] - a[0]) * (0.2 + (index % 4) * 0.2)),
y: round(a[1] + (c[1] - a[1]) * (0.28 + ((index >> 2) % 3) * 0.22)),
};
}
return { devices, entities, states, areas, layout };
};
export const LARGE_HOUSE_COUNTS = Object.freeze({
floors: FLOOR_COUNT,
rooms: FLOOR_COUNT * ROOMS_PER_FLOOR,
devices: DEVICE_COUNT,
openings: OPENING_COUNT,
partitions: PARTITION_COUNT,
columns: COLUMN_COUNT,
decor: DECOR_COUNT,
});
export const makeLargeHouseFixture = () => {
let openingsLeft = OPENING_COUNT;
let partitionsLeft = PARTITION_COUNT;
let columnsLeft = COLUMN_COUNT;
let decorLeft = DECOR_COUNT;
const spaces = Array.from({ length: FLOOR_COUNT }, (_, floor) => {
const rooms = roomGrid(floor);
const segments = wallSegments(rooms);
const floorsRemaining = FLOOR_COUNT - floor;
const openingCount = Math.ceil(openingsLeft / floorsRemaining);
const partitionCount = Math.ceil(partitionsLeft / floorsRemaining);
const columnCount = Math.ceil(columnsLeft / floorsRemaining);
const decorCount = Math.ceil(decorLeft / floorsRemaining);
openingsLeft -= openingCount;
partitionsLeft -= partitionCount;
columnsLeft -= columnCount;
decorLeft -= decorCount;
return {
id: `perf-floor-${floor + 1}`,
title: `Performance floor ${floor + 1}`,
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: { fill_mode: 'glow', show_borders: true, show_names: true },
rooms,
walls: segments.map((wall, index) => ({
key: `perf-wall-${floor}-${index}`, cm: 15, a: wall.a, b: wall.b,
})),
openings: makeOpenings(floor, segments, openingCount),
partitions: makePartitions(floor, rooms, partitionCount),
wall_columns: makeColumns(floor, rooms, columnCount),
decor: makeDecor(floor, decorCount),
};
});
const runtime = makeRuntime(spaces);
return {
config: { spaces, markers: [], settings: { glow_radius_cm: 300 } },
...runtime,
counts: LARGE_HOUSE_COUNTS,
};
};
+181
View File
@@ -0,0 +1,181 @@
/** Deterministic fictional scenes for HP-QA-01 golden-image coverage. */
const round = (value) => Number(value.toFixed(6));
const uniqueEdges = (rooms) => {
const edges = new Map();
for (const room of rooms) {
room.poly.forEach((a, index) => {
const b = room.poly[(index + 1) % room.poly.length];
const forward = `${a.join(',')}/${b.join(',')}`;
const reverse = `${b.join(',')}/${a.join(',')}`;
if (!edges.has(reverse) && !edges.has(forward)) edges.set(forward, { a, b });
});
}
return [...edges.values()];
};
const wallsFor = (prefix, rooms, thickness) => uniqueEdges(rooms).map((edge, index) => ({
key: `${prefix}-wall-${index}`,
a: edge.a,
b: edge.b,
cm: typeof thickness === 'function' ? thickness(edge, index) : thickness,
}));
const geometryRooms = [
{ id: 'geo-nw', name: 'NW', area: 'golden_geo_nw', poly: [[0.06, 0.08], [0.48, 0.08], [0.48, 0.48], [0.06, 0.48]] },
{ id: 'geo-ne', name: 'NE', area: 'golden_geo_ne', poly: [[0.48, 0.08], [0.94, 0.08], [0.94, 0.48], [0.48, 0.48]] },
{ id: 'geo-sw', name: 'SW', area: 'golden_geo_sw', poly: [[0.06, 0.48], [0.48, 0.48], [0.48, 0.92], [0.06, 0.92]] },
{ id: 'geo-se', name: 'SE', area: 'golden_geo_se', poly: [[0.48, 0.48], [0.94, 0.48], [0.94, 0.92], [0.48, 0.92]] },
{ id: 'geo-nested', name: 'Nested', area: 'golden_geo_nested',
poly: [[0.72, 0.14], [0.84, 0.26], [0.72, 0.38], [0.60, 0.26]] },
];
const lightingRooms = [
{ id: 'light-left', name: 'Light source room', area: 'golden_light_left',
poly: [[0.07, 0.10], [0.50, 0.10], [0.50, 0.88], [0.07, 0.88]] },
{ id: 'light-right', name: 'Receiving room', area: 'golden_light_right',
poly: [[0.50, 0.10], [0.93, 0.10], [0.93, 0.88], [0.50, 0.88]] },
];
const geometrySpace = {
id: 'golden-geometry',
title: 'Geometry matrix',
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: {
fill_mode: 'none', show_borders: true, show_names: true,
room_color: '#2d8fce', room_opacity: 0.16,
},
rooms: geometryRooms,
walls: wallsFor('geo', geometryRooms, (edge, index) => {
const vertical = Math.abs(edge.a[0] - edge.b[0]) < 1e-9;
if (vertical && Math.abs(edge.a[0] - 0.48) < 1e-9) return 25;
return index % 4 === 0 ? 10 : 15;
}),
open_spans: [{ a: [0.48, 0.15], b: [0.48, 0.27] }],
openings: [
{ id: 'geo-window', type: 'window', x: 0.26, y: 0.08, angle: 0, length: 0.12 },
{ id: 'geo-door', type: 'door', x: 0.48, y: 0.37, angle: 90, length: 0.12 },
{ id: 'geo-gate', type: 'gate', x: 0.72, y: 0.92, angle: 0, length: 0.2 },
{ id: 'geo-diagonal-window', type: 'window', x: 0.78, y: 0.20, angle: 45, length: 0.08 },
],
partitions: [
{ id: 'geo-partition-h', a: [0.14, 0.68], b: [0.40, 0.68], cm: 12 },
{ id: 'geo-partition-v', a: [0.75, 0.56], b: [0.75, 0.82], cm: 20 },
],
wall_columns: [
{ id: 'geo-column-square', shape: 'square', center: [0.63, 0.67], cm: 35, angle: 30 },
{ id: 'geo-column-circle', shape: 'circle', center: [0.86, 0.72], cm: 40 },
],
decor: [
{ id: 'geo-axis-h', kind: 'line', x1: 0.04, y1: 0.5, x2: 0.96, y2: 0.5,
color: '#5d6a73', opacity: 0.35, width_cm: 0.8, line_style: 'dashed' },
],
};
const lightingSpace = {
id: 'golden-lighting',
title: 'Lighting matrix',
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: {
fill_mode: 'glow', show_borders: true, show_names: true,
north_deg: 0, sun_rays: true, bg_mode: 'static',
},
rooms: lightingRooms,
walls: wallsFor('light', lightingRooms, (edge) => (
Math.abs(edge.a[0] - 0.5) < 1e-9 && Math.abs(edge.b[0] - 0.5) < 1e-9 ? 25 : 15
)),
openings: [
{ id: 'light-window', type: 'window', x: 0.27, y: 0.10, angle: 0, length: 0.14 },
{ id: 'light-door', type: 'door', x: 0.50, y: 0.54, angle: 90, length: 0.15 },
{ id: 'light-gate', type: 'gate', x: 0.74, y: 0.88, angle: 0, length: 0.22 },
],
partitions: [
{ id: 'light-partition', a: [0.70, 0.22], b: [0.70, 0.70], cm: 18 },
],
wall_columns: [
{ id: 'light-column', shape: 'circle', center: [0.38, 0.64], cm: 45 },
],
decor: [],
};
const runtime = () => {
const devices = {};
const entities = {};
const states = {
'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 180, elevation: 24 },
},
};
const layout = {};
const areas = Object.fromEntries(
[...geometryRooms, ...lightingRooms].map((room) => [room.area, { area_id: room.area, name: room.name }]),
);
const add = (id, domain, area, x, y, state, attributes = {}) => {
const entityId = `${domain}.${id.replaceAll('-', '_')}`;
devices[id] = {
id, name: `Golden ${id}`, model: `GOLDEN-${id.toUpperCase()}`, area_id: area,
identifiers: [['houseplan_golden', id]], config_entries: ['golden_entry'],
entry_type: null, via_device_id: null, disabled_by: null,
};
entities[entityId] = {
entity_id: entityId, device_id: id, platform: 'houseplan_golden',
config_entry_id: 'golden_entry', disabled_by: null,
};
states[entityId] = { entity_id: entityId, state, attributes: { friendly_name: devices[id].name, ...attributes } };
layout[id] = { s: 'golden-lighting', x: round(x), y: round(y) };
};
add('golden-light-one', 'light', 'golden_light_left', 0.20, 0.34, 'on', { rgb_color: [255, 196, 112] });
add('golden-light-two', 'light', 'golden_light_left', 0.35, 0.72, 'on', { color_temp_kelvin: 2700 });
add('golden-light-three', 'light', 'golden_light_right', 0.82, 0.30, 'off');
add('golden-presence', 'binary_sensor', 'golden_light_right', 0.82, 0.62, 'on', { device_class: 'occupancy' });
add('golden-climate', 'climate', 'golden_light_right', 0.60, 0.28, 'heat', {
current_temperature: 22.4, temperature: 23, hvac_action: 'heating',
});
add('golden-left-temperature', 'sensor', 'golden_light_left', 0.19, 0.54, '17', {
device_class: 'temperature', unit_of_measurement: '°C',
});
add('golden-right-temperature', 'sensor', 'golden_light_right', 0.81, 0.48, '29', {
device_class: 'temperature', unit_of_measurement: '°C',
});
add('golden-left-linkquality', 'sensor', 'golden_light_left', 0.34, 0.54, '35', {
unit_of_measurement: 'lqi',
});
add('golden-right-linkquality', 'sensor', 'golden_light_right', 0.66, 0.70, '190', {
unit_of_measurement: 'lqi',
});
return { devices, entities, states, layout, areas };
};
export const VISUAL_MATRIX_COUNTS = Object.freeze({
spaces: 2,
rooms: geometryRooms.length + lightingRooms.length,
openings: geometrySpace.openings.length + lightingSpace.openings.length,
partitions: geometrySpace.partitions.length + lightingSpace.partitions.length,
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
});
export const makeVisualMatrixFixture = () => ({
config: {
spaces: [structuredClone(geometrySpace), structuredClone(lightingSpace)],
markers: [],
settings: {
glow_radius_cm: 360,
north_deg: 0,
sun_rays: true,
bg_mode: 'static',
fill_colors: {
glow_base: { c: '#1b2530', a: 0.78 },
glow_light: { c: '#ffd27b', a: 0.70 },
wall_fill: { c: '#d7d9dc', a: 1 },
},
},
},
...runtime(),
counts: VISUAL_MATRIX_COUNTS,
});
+50
View File
@@ -0,0 +1,50 @@
# HP-QA-01 golden images
This layer catches visual regressions that DOM smokes cannot: wall seams and
end caps, thick opening tunnels, Glow/sun clipping, hover contours, editor
chrome, the open contextual tray at wide/medium/narrow widths in English and
Russian (selection, tool options, group and palette), long dialog
titles/footers, mobile clipping, themes and zoom/remount.
## Safety contract
- A build fingerprint embedded by Rollup must match `src/`, Rollup/TypeScript
configuration and locked package inputs; stale committed
demo bundles fail before the first screenshot.
- Chromium, viewport, locale, timezone, colour profile, font rendering,
animations and caret are controlled by the runner.
- `capture` writes only to ignored `artifacts/golden/`; it never changes a
baseline and never claims a missing baseline passed. Any scenario runtime
error makes capture fail, including the initial no-baseline CI run.
- `verify` requires every image plus a matching matrix manifest and fails on
missing/different/error scenarios, browser mismatch or a baseline whose hash
no longer matches the reviewed manifest.
- `accept` requires `--reviewed`, a complete candidate report and current
source fingerprint. It validates the whole set before copying anything and
is the only command allowed to update baselines.
## Workflow
Build and copy the exact current source first:
```bash
npm run build
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
npm run golden:capture
```
Review `artifacts/golden/actual/` and, when existing references are present,
`artifacts/golden/diff/`. If every image is intentional:
```bash
npm run golden:accept -- --reviewed
npm run golden:verify
```
Never accept images merely to make CI green. A matrix/framing change increments
`GOLDEN_MATRIX_VERSION`; a normal rendering fix does not. The first canonical
Linux baseline was reviewed and accepted during the v1.60.3-beta.1 gate.
Future updates must still use the `golden-images` artifact produced by the Linux
CI job as the review set: desktop font rasterisation can differ from the CI
environment even with the same pinned Chromium. Pass its unpacked root via
`--from=...` when accepting it locally.
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
import { GOLDEN_BASELINE_MANIFEST } from './policy.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const reviewed = process.argv.includes('--reviewed');
const fromArg = process.argv.find((arg) => arg.startsWith('--from='));
const from = resolve(fromArg ? fromArg.slice('--from='.length) : resolve(ROOT, 'artifacts/golden'));
if (!reviewed) throw new Error('refusing to replace baselines without explicit --reviewed');
const reportPath = resolve(from, 'golden-report.json');
if (!existsSync(reportPath)) throw new Error(`candidate report not found: ${reportPath}`);
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
if (report.matrixVersion !== GOLDEN_MATRIX_VERSION)
throw new Error(`candidate matrix ${report.matrixVersion} != current ${GOLDEN_MATRIX_VERSION}`);
if (report.buildFingerprint !== sourceFingerprint(ROOT))
throw new Error('candidate screenshots were not captured from the current frontend source');
if (typeof report.chromium !== 'string' || !report.chromium)
throw new Error('candidate report does not identify its Chromium build');
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
const byId = new Map(report.results.map((result) => [result.id, result]));
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
mkdirSync(baselineRoot, { recursive: true });
const hashes = {};
const candidates = [];
for (const scenario of GOLDEN_SCENARIOS) {
const result = byId.get(scenario.id);
const candidate = resolve(from, 'actual', `${scenario.id}.png`);
if (result?.error || !['missing-baseline', 'passed', 'different'].includes(result?.status))
throw new Error(`review candidate has an invalid run status: ${scenario.id} (${result?.status || 'missing'})`);
if (!result?.actualSha256 || !existsSync(candidate))
throw new Error(`review candidate missing: ${scenario.id}`);
const bytes = readFileSync(candidate);
const digest = createHash('sha256').update(bytes).digest('hex');
if (digest !== result.actualSha256) throw new Error(`candidate changed after capture: ${scenario.id}`);
candidates.push({ scenario, candidate });
hashes[scenario.id] = digest;
}
// Validate the complete set first: a broken report must never leave a half-
// updated baseline directory behind.
for (const { scenario, candidate } of candidates)
copyFileSync(candidate, resolve(baselineRoot, `${scenario.id}.png`));
writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify({
schema: 1,
matrixVersion: GOLDEN_MATRIX_VERSION,
acceptedAt: new Date().toISOString(),
sourceFingerprint: report.buildFingerprint,
chromium: report.chromium,
scenarios: hashes,
}, null, 2)}\n`, 'utf8');
console.log(`Accepted ${GOLDEN_SCENARIOS.length} reviewed golden baselines.`);
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,35 @@
{
"schema": 1,
"matrixVersion": 3,
"acceptedAt": "2026-08-09T12:30:01.505Z",
"sourceFingerprint": "f1969cf05ab483dd1bf24d32d967f931f1e3c9ce37cd3f61d4b851f1eae417ee",
"chromium": "151.0.7922.34",
"scenarios": {
"geometry-view-dark-fit": "fbce77f97180cd7d50a997c74d3fa1874ee3be2515a4c09b36dd4e792ebc66df",
"geometry-view-light-fit": "a425fa6d77081bb35d2fb81e469962402c6a3c86a8113ff957f28548908d0816",
"geometry-plan-editor-dark": "86d64305e4da13436aef14fe96b2c9eb5978d1d32a0bba2318005421a2fda161",
"geometry-devices-editor-dark": "93417ab7363ca50e65c9d6f0b876f4d5a6a367a8fabdf42306b244e6511693f1",
"geometry-decor-editor-dark": "25d341052e30e7acea13c17897e8bd0f1f08235342b949c02afd3db5b9936c64",
"tray-wide-selection-en": "c7350c3661f17f46d22b2c977524fde9fba1ebf9d6e10f021482f0d1caeffa8a",
"tray-wide-tool-ru": "5472a302ad7e7b70235089164a5c4558183504519d95280e8ff30883e304cb71",
"tray-medium-group-en": "59480e04ff5ea0f2cfb31b88c1c753ce8eeac785596ca21eac48dd99ec5e0eb6",
"tray-medium-selection-ru": "805c23560bc340582f9b951e910f39245ab62b6cd980d175c8d9e3b7e1ef1519",
"tray-narrow-palette-en": "e8dede0d90ebc32e1e294ba3c39621df918aaf7b3183f71aed9b60014eee51db",
"tray-narrow-tool-ru": "8889d58aca90daadf0bedd6a7c08afb2604c086a453f9435f32d29607aa49e42",
"geometry-diagonal-45-opening-dark": "fbce77f97180cd7d50a997c74d3fa1874ee3be2515a4c09b36dd4e792ebc66df",
"openings-thick-wall-dark": "5d9ac6add9a5709318180f4554ac62c1c7e2862bd016be7901a2bd450e021f0c",
"openings-hidden-view-dark": "799e791830ab63a1c445b8c77650a577d8e881daa1ff47d1dcf4e238eb1cff16",
"lighting-glow-sun-dark": "638ab9fa6856f91581453c732e4059b2a7af47b20d6a58b45a47497876142ea1",
"lighting-fill-light-axis-split-dark": "acdbebe845ede66dbbe9de979aad93ba78dc2779d1ec533b238a9bea0d047d1c",
"lighting-fill-temp-axis-split-dark": "2348b7c6a8594468664755e2fea1b49126e604192599be0a3965f24a0c2d2765",
"lighting-fill-lqi-axis-split-dark": "584adcfb5541b1dddc814c2674bfce8002566bbb667b10501b7ff49fedef6f9c",
"hover-over-glow-dark": "3ca354fa94922a3e38ce15886250ae88630a1e9356e4ac07b74b425f063839d7",
"hover-nested-room-dark": "472f630768f60c3168f64744af891323a64cf71f36f943fc5a56b4f0494efa70",
"large-house-zoom-040-dark": "69593f75f1965daf0a12777fde923ae25c99b42089a73a04cf523b3ea5ce4f0d",
"large-house-zoom-250-dark": "21a1a0408c913f477913ae9c0339417ae7aaad7baad8a890eb558f6e36a5a597",
"large-house-warm-remount-dark": "f3eb077934701cf11bc828da6a68b55346d499a68c323988938b88aca5f5d9fe",
"device-dialog-desktop-en": "c2e1c2864f13b9e27e0d56e3eb7a7b245feab0d543f5533a7b3c4aa34ab2f3eb",
"device-dialog-mobile-ru": "5445eb576636362d402f66aa80c3816e01ca7ee75f4ffc86708ff3a43bbfb4a5",
"decor-color-popover-mobile-ru": "eab3c5c76b27050dbdbc1627371a16916ea30473934de48842c6ab94a045f9db"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

+257
View File
@@ -0,0 +1,257 @@
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
import { makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
const themeVars = {
dark: {
'--primary-color': '#3ea6ff', '--primary-text-color': '#e6e7eb',
'--secondary-text-color': '#9aa4ad', '--card-background-color': '#202126',
'--ha-card-background': '#202126', '--divider-color': '#3a3d45',
},
light: {
'--primary-color': '#0b73b8', '--primary-text-color': '#202124',
'--secondary-text-color': '#5f6368', '--card-background-color': '#ffffff',
'--ha-card-background': '#ffffff', '--divider-color': '#d7d9de',
},
};
async function stableEnvironment(page, scenario) {
await page.setViewportSize(scenario.viewport);
await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: scenario.theme });
await page.evaluate(({ variables, theme }) => {
let style = document.getElementById('hp-golden-stability');
if (!style) {
style = document.createElement('style');
style.id = 'hp-golden-stability';
style.textContent = `
*, *::before, *::after {
animation: none !important;
transition: none !important;
caret-color: transparent !important;
scroll-behavior: auto !important;
}
html, body { width: 100%; min-height: 100%; overflow: hidden; }
body { background: var(--hp-golden-page-bg) !important;
font-family: Arial, sans-serif !important; }
#host { width: min(100%, 1120px) !important; margin: 0 auto !important;
padding: 8px !important; box-sizing: border-box !important; }
`;
document.head.appendChild(style);
}
for (const [name, value] of Object.entries(variables))
document.documentElement.style.setProperty(name, value);
document.documentElement.style.setProperty(
'--hp-golden-page-bg', theme === 'light' ? '#eef1f4' : '#11151b',
);
document.documentElement.style.colorScheme = theme;
}, { variables: themeVars[scenario.theme] || themeVars.dark, theme: scenario.theme });
}
export async function prepareGoldenScenario(page, scenario) {
await stableEnvironment(page, scenario);
const fixture = fixtureFor(scenario.fixture);
if (scenario.deviceName && fixture.devices?.[scenario.deviceId])
fixture.devices[scenario.deviceId].name = scenario.deviceName;
if (scenario.fillMode) {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
space.settings = { ...(space.settings || {}), fill_mode: scenario.fillMode };
}
if (scenario.hideOpenings) {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
space.settings = { ...(space.settings || {}), hide_openings: true };
}
return 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) => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error(`golden scenario timed out: ${scenario.id}`);
await wait(15);
}
};
window.__goldenCard?.remove?.();
window.__card?.remove?.();
localStorage.clear();
const host = document.getElementById('host');
const cardConfig = {
type: 'custom:houseplan-card', title: `Golden ${scenario.id}`, icon_size: 3.4,
language: scenario.language || 'en',
};
const hassFor = () => ({
language: scenario.language || 'en', locale: { language: scenario.language || 'en' },
user: { id: 'golden', name: 'Golden fixture', is_admin: true },
devices: fixture.devices || {}, entities: fixture.entities || {},
areas: fixture.areas || {}, states: fixture.states || {},
floors: {
one: { floor_id: 'one', name: 'One', level: 0 },
two: { floor_id: 'two', name: 'Two', level: 1 },
three: { floor_id: 'three', name: 'Three', level: 2 },
},
callWS: async (message) => {
if (message.type === 'houseplan/config/get')
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
if (message.type === 'houseplan/layout/get')
return { layout: structuredClone(fixture.layout || {}), rev: 1 };
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices || {});
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities || {});
if (message.type === 'config_entries/get')
return [{ entry_id: 'golden_entry', domain: 'houseplan_golden', title: 'Golden fixture' }];
if (message.type === 'manifest/list')
return [{ domain: 'houseplan_golden', name: 'House Plan Golden' }];
return { ok: true };
},
callService: async () => undefined,
connection: { subscribeEvents: async () => () => undefined, subscribeMessage: async () => () => undefined },
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
});
const mount = async () => {
const card = document.createElement('houseplan-card');
card.setConfig(cardConfig);
host.replaceChildren(card);
card.hass = hassFor();
await until(() => card._loadOk && card._model?.length === fixture.config.spaces.length);
await card.updateComplete;
const expectedDevices = Object.keys(fixture.devices || {}).length;
if (expectedDevices) await until(() => card._devices?.length >= expectedDevices);
await until(() => card._booting === false);
await frame();
return card;
};
let card = await mount();
if (scenario.warmRemount) {
card.remove();
await wait(0);
card = await mount();
}
window.__goldenCard = card;
if (scenario.space && card._space !== scenario.space) {
card._pickSpace(scenario.space);
await card.updateComplete;
}
if (scenario.mode) {
card._setMode(scenario.mode);
await card.updateComplete;
}
if (Number.isFinite(scenario.zoom)) {
card._applyView(scenario.zoom, 500, 500);
card.requestUpdate();
await card.updateComplete;
}
if (scenario.editorTray) {
let expectedKind = '';
if (scenario.editorTray === 'plan-selection') {
card._physicalSel = { kind: 'partition', id: 'geo-partition-h' };
expectedKind = 'selection';
} else if (scenario.editorTray === 'plan-tool') {
card._physicalSel = null;
card._tool = 'draw';
expectedKind = 'tool';
} else if (scenario.editorTray === 'decor-selection') {
card._decorTool = 'select';
card._decorSel = 'geo-axis-h';
expectedKind = 'selection';
} else if (scenario.editorTray === 'decor-tool') {
card._decorSel = null;
card._decorTool = 'line';
expectedKind = 'tool';
} else if (scenario.editorTray === 'furniture-palette') {
card._decorSel = null;
card._furnPalette = null;
card._editorSecondary.openPalette();
card._decorTool = 'furniture';
expectedKind = 'palette';
} else if (scenario.editorTray === 'group') {
const group = {
id: 'golden-group', label: 'Arrange', icon: 'mdi:shape-outline', items: [
{ id: 'align', label: 'Align', icon: 'mdi:format-align-center', role: 'command', invoke: () => undefined },
{ id: 'distribute', label: 'Distribute', icon: 'mdi:format-horizontal-align-center', role: 'command', invoke: () => undefined },
],
};
Object.defineProperty(card, '_editorToolbarGroups', {
configurable: true,
get: () => [group],
});
card.requestUpdate();
await card.updateComplete;
card._editorSecondary.toggleGroup(card._editorToolbarGroups, group.id);
expectedKind = 'group';
} else {
throw new Error(`unknown golden editor tray: ${scenario.editorTray}`);
}
card.requestUpdate();
await card.updateComplete;
await frame();
const tray = card.renderRoot.querySelector(
`.editor-secondary-host.open .editor-secondary.kind-${expectedKind}`,
);
if (!tray) throw new Error(`golden editor tray did not open: ${scenario.editorTray}`);
}
if (scenario.hoverRoom) {
const room = card._spaceModel().rooms.find((item) => item.id === scenario.hoverRoom);
if (!room) throw new Error(`golden hover room missing: ${scenario.hoverRoom}`);
card._hoverRoom = { space: card._space, room };
card.requestUpdate();
await card.updateComplete;
}
if (scenario.dialog === 'device') {
card._setMode('devices');
await card.updateComplete;
const device = card._devices.find((item) => item.id === scenario.deviceId);
if (!device) throw new Error(`golden device missing: ${scenario.deviceId}`);
card._openMarkerDialog(device);
await card.updateComplete;
if (scenario.focusDialogClose) {
const dialog = card.renderRoot.querySelector('hp-dialog');
await dialog?.updateComplete;
dialog?.renderRoot?.querySelector('.close')?.focus();
}
} else if (scenario.dialog === 'decor-color') {
card._setMode('decor');
card._decorTool = 'select';
await card.updateComplete;
const shape = card._decorList.find((item) => item.kind === 'line');
if (!shape) throw new Error('golden decor line missing');
card._decorShapeDbl(new MouseEvent('dblclick'), shape);
await card.updateComplete;
const dialog = card.renderRoot.querySelector('hp-dialog');
const picker = dialog?.querySelector('hp-color-opacity');
await picker?.updateComplete;
const trigger = picker?.renderRoot?.querySelector('.trigger');
if (!trigger) throw new Error('golden decor color trigger missing');
trigger.click();
await picker.updateComplete;
}
await document.fonts?.ready;
await frame();
return {
space: card._space,
mode: card._mode,
devices: card._devices.length,
dialog: !!card.renderRoot.querySelector('hp-dialog'),
editorTray: card.renderRoot.querySelector('.editor-secondary-host.open .editor-secondary')
?.className || '',
};
}, { fixture, scenario });
}
export async function goldenClip(page, capture) {
if (capture === 'page') return null;
return page.evaluate(() => {
const card = window.__goldenCard;
const target = card?.renderRoot?.querySelector('.stage');
if (!target) throw new Error('golden stage capture target missing');
const rect = target.getBoundingClientRect();
const pad = 2;
const x = Math.max(0, Math.floor(rect.left - pad));
const y = Math.max(0, Math.floor(rect.top - pad));
const right = Math.min(window.innerWidth, Math.ceil(rect.right + pad));
const bottom = Math.min(window.innerHeight, Math.ceil(rect.bottom + pad));
return { x, y, width: Math.max(1, right - x), height: Math.max(1, bottom - y) };
});
}
+73
View File
@@ -0,0 +1,73 @@
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 3;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'geometry-view-dark-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-view-light-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'geometry-decor-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
// HP-UX-11 visual contract: every adaptive width is captured in both
// languages while the six materially different tray contents are open.
{ id: 'tray-wide-selection-en', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
editorTray: 'plan-selection', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, ...page },
{ id: 'tray-wide-tool-ru', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
editorTray: 'plan-tool', language: 'ru', theme: 'dark',
viewport: { width: 1180, height: 900 }, ...page },
{ id: 'tray-medium-group-en', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
editorTray: 'group', language: 'en', theme: 'dark',
viewport: { width: 760, height: 820 }, ...page },
{ id: 'tray-medium-selection-ru', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
editorTray: 'decor-selection', language: 'ru', theme: 'dark',
viewport: { width: 760, height: 820 }, ...page },
{ id: 'tray-narrow-palette-en', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
editorTray: 'furniture-palette', language: 'en', theme: 'dark',
viewport: { width: 390, height: 760 }, ...page },
{ id: 'tray-narrow-tool-ru', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
editorTray: 'decor-tool', language: 'ru', theme: 'dark',
viewport: { width: 390, height: 760 }, ...page },
{ id: 'geometry-diagonal-45-opening-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'openings-thick-wall-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'none', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'openings-hidden-view-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'none', hideOpenings: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-glow-sun-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-fill-light-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'light', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-fill-temp-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'temp', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-fill-lqi-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'lqi', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'hover-over-glow-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
hoverRoom: 'light-right', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'hover-nested-room-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
hoverRoom: 'geo-nested', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'large-house-zoom-040-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
zoom: 0.4, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
{ id: 'large-house-zoom-250-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
zoom: 2.5, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
{ id: 'large-house-warm-remount-dark', fixture: 'large', space: 'perf-floor-2', mode: 'view',
warmRemount: true, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
{ id: 'device-dialog-desktop-en', fixture: 'visual', space: 'golden-lighting',
dialog: 'device', deviceId: 'golden-climate',
deviceName: 'Living room climate controller with an intentionally long title',
focusDialogClose: true, language: 'en', theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'device-dialog-mobile-ru', fixture: 'visual', space: 'golden-lighting',
dialog: 'device', deviceId: 'golden-climate',
deviceName: 'Контроллер климата гостиной с намеренно очень длинным названием',
language: 'ru', theme: 'dark', viewport: { width: 390, height: 760 }, ...page },
{ id: 'decor-color-popover-mobile-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'decor-color', language: 'ru', theme: 'dark',
viewport: { width: 390, height: 760 }, ...page },
]);
+13
View File
@@ -0,0 +1,13 @@
export const GOLDEN_BASELINE_MANIFEST = 'baseline-manifest.json';
export const assertGoldenInvocation = (mode, scenarioFilter = '') => {
if (!['capture', 'verify'].includes(mode)) throw new Error(`unknown golden mode: ${mode}`);
if (mode === 'verify' && scenarioFilter)
throw new Error('golden verify must run the complete matrix; use capture for a diagnostic --scenario run');
};
export const goldenRunFailed = (mode, manifestValid, results) => {
if (results.some((result) => result.status === 'error')) return true;
return mode === 'verify'
&& (!manifestValid || results.some((result) => result.status !== 'passed'));
};
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { launch } from '../serve.mjs';
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
import { goldenClip, prepareGoldenScenario } from './harness.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
import { assertGoldenInvocation, GOLDEN_BASELINE_MANIFEST, goldenRunFailed } from './policy.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice(7) || 'capture';
const scenarioFilter = process.argv.find((arg) => arg.startsWith('--scenario='))?.slice(11) || '';
assertGoldenInvocation(mode, scenarioFilter);
const scenarios = scenarioFilter
? GOLDEN_SCENARIOS.filter((scenario) => scenario.id === scenarioFilter)
: GOLDEN_SCENARIOS;
if (!scenarios.length) throw new Error(`unknown golden scenario: ${scenarioFilter}`);
const artifactRoot = resolve(ROOT, 'artifacts/golden');
const actualRoot = resolve(artifactRoot, 'actual');
const diffRoot = resolve(artifactRoot, 'diff');
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
mkdirSync(actualRoot, { recursive: true });
mkdirSync(diffRoot, { recursive: true });
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
async function comparePng(page, actual, baseline, threshold) {
return page.evaluate(async ({ actual64, baseline64, threshold }) => {
const decode = async (base64) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
};
const [actualImage, baselineImage] = await Promise.all([decode(actual64), decode(baseline64)]);
if (actualImage.width !== baselineImage.width || actualImage.height !== baselineImage.height) {
return {
dimensionsMatch: false,
actualSize: [actualImage.width, actualImage.height],
baselineSize: [baselineImage.width, baselineImage.height],
differingPixels: null,
diffRatio: 1,
maxObservedDelta: 255,
passed: false,
diffPngBase64: null,
};
}
const width = actualImage.width;
const height = actualImage.height;
const canvas = document.createElement('canvas');
const baselineCanvas = document.createElement('canvas');
const diffCanvas = document.createElement('canvas');
for (const item of [canvas, baselineCanvas, diffCanvas]) {
item.width = width;
item.height = height;
}
const context = canvas.getContext('2d', { willReadFrequently: true });
const baselineContext = baselineCanvas.getContext('2d', { willReadFrequently: true });
const diffContext = diffCanvas.getContext('2d');
context.drawImage(actualImage, 0, 0);
baselineContext.drawImage(baselineImage, 0, 0);
const actualData = context.getImageData(0, 0, width, height).data;
const baselineData = baselineContext.getImageData(0, 0, width, height).data;
const diff = diffContext.createImageData(width, height);
let differingPixels = 0;
let maxObservedDelta = 0;
for (let offset = 0; offset < actualData.length; offset += 4) {
let delta = 0;
for (let channel = 0; channel < 4; channel++)
delta = Math.max(delta, Math.abs(actualData[offset + channel] - baselineData[offset + channel]));
maxObservedDelta = Math.max(maxObservedDelta, delta);
if (delta > threshold.maxChannelDelta) {
differingPixels++;
diff.data[offset] = 255;
diff.data[offset + 1] = 0;
diff.data[offset + 2] = 180;
diff.data[offset + 3] = 255;
} else {
const gray = Math.round((actualData[offset] + actualData[offset + 1] + actualData[offset + 2]) / 9);
diff.data[offset] = gray;
diff.data[offset + 1] = gray;
diff.data[offset + 2] = gray;
diff.data[offset + 3] = 90;
}
}
diffContext.putImageData(diff, 0, 0);
const diffRatio = differingPixels / (width * height);
return {
dimensionsMatch: true,
actualSize: [width, height],
baselineSize: [width, height],
differingPixels,
diffRatio,
maxObservedDelta,
passed: diffRatio <= threshold.maxDiffRatio,
diffPngBase64: differingPixels
? diffCanvas.toDataURL('image/png').slice('data:image/png;base64,'.length)
: null,
};
}, {
actual64: actual.toString('base64'),
baseline64: baseline.toString('base64'),
threshold,
});
}
let baselineManifest = null;
const baselineManifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST);
if (existsSync(baselineManifestPath)) {
try { baselineManifest = JSON.parse(readFileSync(baselineManifestPath, 'utf8')); }
catch { baselineManifest = { invalid: true }; }
}
const { page, browser } = await launch(
{ width: 1000, height: 900 },
1,
['--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text'],
{ locale: 'en-US', timezoneId: 'UTC', colorScheme: 'dark', reducedMotion: 'reduce' },
);
const chromium = await browser.version();
const results = [];
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
let buildFingerprint = null;
try {
buildFingerprint = await assertFreshDemoBundle(page, ROOT);
for (const scenario of scenarios) {
const result = {
id: scenario.id,
threshold: scenario.threshold,
status: 'error',
actualSha256: null,
};
try {
pageErrors.length = 0;
result.runtime = await prepareGoldenScenario(page, scenario);
if (pageErrors.length) throw new Error(`browser exception: ${pageErrors.join(' | ')}`);
const clip = await goldenClip(page, scenario.capture);
const actual = await page.screenshot({
...(clip ? { clip } : {}),
animations: 'disabled',
caret: 'hide',
scale: 'css',
});
const actualPath = resolve(actualRoot, `${scenario.id}.png`);
writeFileSync(actualPath, actual);
result.actualSha256 = sha256(actual);
result.actual = actualPath;
const baselinePath = resolve(baselineRoot, `${scenario.id}.png`);
result.baseline = baselinePath;
if (!existsSync(baselinePath)) {
result.status = 'missing-baseline';
} else {
const baseline = readFileSync(baselinePath);
const expectedBaselineSha256 = baselineManifest?.scenarios?.[scenario.id];
result.baselineSha256 = sha256(baseline);
if (!expectedBaselineSha256 || result.baselineSha256 !== expectedBaselineSha256) {
result.status = 'invalid-baseline';
result.error = expectedBaselineSha256
? 'baseline PNG does not match its reviewed manifest hash'
: 'baseline PNG is not listed in the reviewed manifest';
results.push(result);
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
continue;
}
const comparison = await comparePng(page, actual, baseline, scenario.threshold);
Object.assign(result, comparison);
result.status = comparison.passed ? 'passed' : 'different';
if (comparison.diffPngBase64) {
const diffPath = resolve(diffRoot, `${scenario.id}.png`);
writeFileSync(diffPath, Buffer.from(comparison.diffPngBase64, 'base64'));
result.diff = diffPath;
}
delete result.diffPngBase64;
}
} catch (error) {
result.error = error instanceof Error ? error.message : String(error);
}
results.push(result);
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
}
} finally {
await browser.close();
}
const manifestValid = !!baselineManifest
&& !baselineManifest.invalid
&& baselineManifest.matrixVersion === GOLDEN_MATRIX_VERSION
&& baselineManifest.chromium === chromium
&& GOLDEN_SCENARIOS.every((scenario) => typeof baselineManifest.scenarios?.[scenario.id] === 'string');
const report = {
schema: 1,
mode,
generatedAt: new Date().toISOString(),
matrixVersion: GOLDEN_MATRIX_VERSION,
buildFingerprint,
chromium,
baselineManifest: baselineManifest ? {
present: true,
valid: manifestValid,
matrixVersion: baselineManifest.matrixVersion ?? null,
chromium: baselineManifest.chromium ?? null,
} : { present: false, valid: false, matrixVersion: null, chromium: null },
results,
};
writeFileSync(resolve(artifactRoot, 'golden-report.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
if (goldenRunFailed(mode, manifestValid, results)) process.exitCode = 1;
+81
View File
@@ -0,0 +1,81 @@
# Large-house performance gate
`benchmark_large_house.mjs` exercises the deterministic `large-house-v1`
fixture. The fixture has 60 rooms, 200 devices, 100 openings, 60 partitions,
40 columns and 500 decor objects on three floors.
The runner records seven measured samples after one discarded warm-up. With
this intentionally small CI sample, the nearest-rank `p95` is the observed
maximum; reports keep the conventional field name but should be read as a
high-tail guard rather than a population estimate:
- model readiness and first stable render;
- space switch, HA state update, pan/zoom and opening the settings dialog;
- a shared-wall room-resize preview which is cancelled before persistence;
- a twelve-switch navigation cycle;
- Long Tasks for every measured window;
- heap growth after four additional navigation rounds with forced GC;
- hot-cache size and growth after the same warmed cycles.
Every report is tied to the source fingerprint embedded by Rollup. A stale
bundle is a hard failure.
## CI contract
The `performance` job checks out the candidate and its base SHA, builds both,
and runs them sequentially with the same Node.js 22 process family, pinned
Playwright Chromium and hosted runner. `compare.mjs` then applies two limits:
1. a relative regression allowance against the base-SHA report;
2. an absolute safety ceiling from `budgets.json`.
The tighter limit wins. The absolute values are catastrophic safety ceilings,
not normal-performance targets; the base-relative comparison catches smaller
regressions. Small fast operations receive an absolute noise
allowance so normal scheduler jitter does not become a false regression. Heap,
Long Tasks, warmed-cache growth and the expected rendered-device count are
gated separately. Long-Task maximum/count/total checks use the same
relative-plus-absolute policy as timings. Both raw reports and the comparison are always uploaded as
the `large-house-performance` artifact, and the table is written to the GitHub
job summary.
This base-vs-candidate design intentionally does not compare timings captured
on different machines or different Chromium builds. A runtime/profile mismatch
fails closed.
## Local diagnostics
Build and copy a fresh demo bundle first, then run:
```bash
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
```
A local report is diagnostic only; it cannot replace the CI comparison.
To reproduce the comparison against another checkout using one harness and one
browser installation:
```bash
npm run benchmark:large-house -- --target-root=../base --samples=7 --output=artifacts/performance/baseline.json
npm run benchmark:large-house -- --target-root=. --samples=7 --output=artifacts/performance/candidate.json
npm run benchmark:compare
```
## Changing budgets
Budget changes require an explicit review of recent CI artifacts and a written
rationale in the change. Do not loosen a threshold merely to make a single red
run pass. A new fixture profile gets a new profile id instead of silently
changing the meaning of `large-house-v1`.
The `cleanFloor` entry ceiling is 160: the reviewed fixture currently warms
120 deterministic room/physical-body entries, and the extra 40 slots allow a
legitimate fixture extension without weakening the separate zero-growth gate.
The absolute switch-cycle/Long-Task ceilings include roughly 20–30% headroom
over the paired 2026-08-09 Ubuntu run where the unchanged base and candidate
both reached about 5.3 s / 2.45 s / 22 tasks / 9.9 s total under runner load.
The same-runner relative checks remain tighter for an actual candidate-only
regression; this prevents an overloaded but symmetric runner from turning an
absolute safety ceiling into a flaky code-regression signal.
+87
View File
@@ -0,0 +1,87 @@
{
"schema": 1,
"profile": "large-house-v1",
"minimumSamples": 7,
"timings": {
"modelReadyMs": {
"stat": "median",
"maxRegressionRatio": 0.3,
"noiseAllowanceMs": 200,
"hardMaxMs": 2500
},
"firstStableRenderMs": {
"stat": "median",
"maxRegressionRatio": 0.3,
"noiseAllowanceMs": 250,
"hardMaxMs": 3000
},
"spaceSwitchMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 75,
"hardMaxMs": 1500
},
"stateUpdateMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 75,
"hardMaxMs": 1000
},
"resizePreviewMs": {
"stat": "median",
"maxRegressionRatio": 0.4,
"noiseAllowanceMs": 150,
"hardMaxMs": 2000
},
"panZoomMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 60,
"hardMaxMs": 500
},
"settingsDialogMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 100,
"hardMaxMs": 1000
},
"switchCycleMs": {
"stat": "median",
"maxRegressionRatio": 0.35,
"noiseAllowanceMs": 250,
"hardMaxMs": 7000
}
},
"longTasks": {
"maxSingleMs": 3000,
"maxSingleRegressionRatio": 0.3,
"maxSingleNoiseAllowanceMs": 250,
"maxCountP95": 30,
"maxCountRegressionRatio": 0.35,
"countNoiseAllowance": 3,
"maxTotalP95Ms": 12000,
"maxTotalRegressionRatio": 0.3,
"noiseAllowanceMs": 150
},
"heap": {
"required": true,
"hardMaxGrowthBytes": 67108864,
"maxRegressionRatio": 0.75,
"noiseAllowanceBytes": 16777216
},
"cacheEntries": {
"cleanFloor": 160,
"glowClip": 200,
"wallUnion": 1,
"openingTunnel": 1,
"openingWallIndex": 1
},
"cacheGrowth": {
"cleanFloor": 0,
"glowClip": 0,
"wallUnion": 0,
"openingTunnel": 0,
"openingWallIndex": 0
},
"renderedDevices": 200
}
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { evaluatePerformanceBudget, performanceSummaryMarkdown } from './evaluate.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const candidatePath = resolve(valueArg('candidate') ?? 'artifacts/performance/candidate.json');
const baselinePath = resolve(valueArg('baseline') ?? 'artifacts/performance/baseline.json');
const budgetsPath = resolve(valueArg('budgets') ?? 'demo/performance/budgets.json');
const outputPath = resolve(valueArg('output') ?? 'artifacts/performance/comparison.json');
const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'));
const evaluation = evaluatePerformanceBudget({
candidate: readJson(candidatePath),
baseline: readJson(baselinePath),
budgets: readJson(budgetsPath),
});
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, `${JSON.stringify(evaluation, null, 2)}\n`, 'utf8');
const markdown = performanceSummaryMarkdown(evaluation);
process.stdout.write(markdown);
if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown, 'utf8');
if (!evaluation.pass) {
console.error(`Performance budget failed: ${evaluation.failures.map((item) => item.id).join(', ')}`);
process.exitCode = 1;
}
+200
View File
@@ -0,0 +1,200 @@
const percentile = (values, p) => {
if (!values.length) return null;
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * p) - 1))];
};
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
const round = (value, digits = 2) => finite(value) ? Number(value.toFixed(digits)) : value;
export const summarizeTimings = (rows, metricNames) => Object.fromEntries(
metricNames.map((metric) => {
const values = rows.map((row) => row[metric]).filter(finite);
return [metric, {
median: percentile(values, 0.5),
p95: percentile(values, 0.95),
min: values.length ? Math.min(...values) : null,
max: values.length ? Math.max(...values) : null,
}];
}),
);
export const summarizeLongTasks = (rows) => {
const totals = [];
const counts = [];
const singles = [];
for (const row of rows) {
const windows = Object.values(row.longTasks ?? {});
totals.push(windows.reduce((sum, item) => sum + (item?.totalMs ?? 0), 0));
counts.push(windows.reduce((sum, item) => sum + (item?.count ?? 0), 0));
singles.push(...windows.map((item) => item?.maxMs ?? 0));
}
return {
maxSingleMs: round(singles.length ? Math.max(...singles) : 0),
countP95: percentile(counts, 0.95) ?? 0,
totalP95Ms: round(percentile(totals, 0.95) ?? 0),
};
};
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const requireReport = (report, budgets, label) => {
if (!report || report.schema !== 2) throw new Error(`${label}: unsupported report schema`);
if (report.profile !== budgets.profile) throw new Error(`${label}: unexpected benchmark profile`);
if (!Array.isArray(report.rows) || report.rows.length < budgets.minimumSamples) {
throw new Error(`${label}: expected at least ${budgets.minimumSamples} measured samples`);
}
};
const relativeLimit = (baseline, ratio, allowance) => Math.max(
baseline * (1 + ratio),
baseline + allowance,
);
const makeCheck = (id, actual, limit, details = {}) => ({
id,
actual: round(actual),
limit: round(limit),
pass: finite(actual) && actual <= limit,
...details,
});
/**
* Evaluate a candidate report against both stable absolute ceilings and a
* report captured from the base SHA on the same runner.
*/
export const evaluatePerformanceBudget = ({ candidate, baseline, budgets }) => {
requireReport(candidate, budgets, 'candidate');
requireReport(baseline, budgets, 'baseline');
if (!sameJson(candidate.fixture, baseline.fixture)) throw new Error('fixture mismatch');
for (const key of ['node', 'chromium', 'platform', 'arch']) {
if (candidate.runtime?.[key] !== baseline.runtime?.[key]) {
throw new Error(`runtime mismatch for ${key}`);
}
}
const checks = [];
for (const [metric, budget] of Object.entries(budgets.timings)) {
const stat = budget.stat ?? 'median';
const actual = candidate.summary?.[metric]?.[stat];
const base = baseline.summary?.[metric]?.[stat];
if (!finite(actual) || !finite(base)) throw new Error(`missing ${stat} for ${metric}`);
const regressionLimit = relativeLimit(base, budget.maxRegressionRatio, budget.noiseAllowanceMs);
checks.push(makeCheck(
`timing.${metric}.${stat}`,
actual,
Math.min(budget.hardMaxMs, regressionLimit),
{ baseline: round(base), hardLimit: budget.hardMaxMs, regressionLimit: round(regressionLimit) },
));
}
const candidateLong = candidate.longTasks ?? summarizeLongTasks(candidate.rows);
const baselineLong = baseline.longTasks ?? summarizeLongTasks(baseline.rows);
const longTasksAvailable = candidate.rows.every((row) => {
const windows = Object.values(row.longTasks ?? {});
return windows.length > 0 && windows.every((item) => item?.supported === true);
});
checks.push({ id: 'longTask.available', actual: longTasksAvailable ? 1 : 0, limit: 1, pass: longTasksAvailable });
const singleRegressionLimit = relativeLimit(
baselineLong.maxSingleMs,
budgets.longTasks.maxSingleRegressionRatio,
budgets.longTasks.maxSingleNoiseAllowanceMs,
);
checks.push(makeCheck(
'longTask.maxSingleMs',
candidateLong.maxSingleMs,
Math.min(budgets.longTasks.maxSingleMs, singleRegressionLimit),
{ baseline: baselineLong.maxSingleMs, hardLimit: budgets.longTasks.maxSingleMs },
));
const countRegressionLimit = relativeLimit(
baselineLong.countP95,
budgets.longTasks.maxCountRegressionRatio,
budgets.longTasks.countNoiseAllowance,
);
checks.push(makeCheck(
'longTask.countP95',
candidateLong.countP95,
Math.min(budgets.longTasks.maxCountP95, countRegressionLimit),
{ baseline: baselineLong.countP95, hardLimit: budgets.longTasks.maxCountP95 },
));
const longRegressionLimit = relativeLimit(
baselineLong.totalP95Ms,
budgets.longTasks.maxTotalRegressionRatio,
budgets.longTasks.noiseAllowanceMs,
);
checks.push(makeCheck(
'longTask.totalP95Ms',
candidateLong.totalP95Ms,
Math.min(budgets.longTasks.maxTotalP95Ms, longRegressionLimit),
{ baseline: baselineLong.totalP95Ms, hardLimit: budgets.longTasks.maxTotalP95Ms },
));
const candidateHeap = candidate.rows
.map((row) => row.heapGrowthBytes)
.filter(finite)
.map((value) => Math.max(0, value));
const baselineHeap = baseline.rows
.map((row) => row.heapGrowthBytes)
.filter(finite)
.map((value) => Math.max(0, value));
const preciseGc = candidate.rows.every((row) => row.preciseGc === true);
checks.push({
id: 'heap.preciseGc', actual: preciseGc ? 1 : 0, limit: budgets.heap.required ? 1 : 0,
pass: !budgets.heap.required || preciseGc,
});
if (budgets.heap.required && (!candidateHeap.length || !baselineHeap.length)) {
checks.push({ id: 'heap.available', actual: candidateHeap.length, limit: 1, pass: false });
} else if (candidateHeap.length && baselineHeap.length) {
const actual = percentile(candidateHeap, 0.95);
const base = percentile(baselineHeap, 0.95);
const regressionLimit = relativeLimit(base, budgets.heap.maxRegressionRatio, budgets.heap.noiseAllowanceBytes);
checks.push(makeCheck(
'heap.growthP95Bytes',
actual,
Math.min(budgets.heap.hardMaxGrowthBytes, regressionLimit),
{ baseline: base, hardLimit: budgets.heap.hardMaxGrowthBytes },
));
}
for (const [cache, limit] of Object.entries(budgets.cacheEntries)) {
const actual = Math.max(...candidate.rows.map((row) => row.cacheEntries?.[cache] ?? Number.POSITIVE_INFINITY));
checks.push(makeCheck(`cache.entries.${cache}`, actual, limit));
}
for (const [cache, limit] of Object.entries(budgets.cacheGrowth)) {
const actual = Math.max(...candidate.rows.map((row) => row.cacheGrowth?.[cache] ?? Number.POSITIVE_INFINITY));
checks.push(makeCheck(`cache.growth.${cache}`, actual, limit));
}
const renderedDevices = Math.min(...candidate.rows.map((row) => row.renderedDevices ?? -1));
checks.push({
id: 'renderedDevices',
actual: renderedDevices,
limit: budgets.renderedDevices,
pass: renderedDevices === budgets.renderedDevices,
});
const failures = checks.filter((check) => !check.pass);
return {
schema: 1,
profile: budgets.profile,
pass: failures.length === 0,
candidateFingerprint: candidate.buildFingerprint,
baselineFingerprint: baseline.buildFingerprint,
checks,
failures,
};
};
export const performanceSummaryMarkdown = (evaluation) => {
const icon = evaluation.pass ? '✅' : '❌';
const lines = [
`### ${icon} House Plan large-house performance`,
'',
'| Check | Candidate | Limit | Base |',
'|---|---:|---:|---:|',
];
for (const check of evaluation.checks) {
lines.push(`| ${check.pass ? '✅' : '❌'} ${check.id} | ${check.actual} | ${check.limit} | ${check.baseline ?? '—'} |`);
}
return `${lines.join('\n')}\n`;
};
+12 -4
View File
@@ -40,16 +40,24 @@ export async function finish(browser, out) {
}
}
export async function launch(viewport = { width: 820, height: 760 }, scale = 1) {
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const page = await (await browser.newContext({ viewport, deviceScaleFactor: scale })).newPage();
export async function launch(
viewport = { width: 820, height: 760 },
scale = 1,
browserArgs = [],
contextOptions = {},
serveRoot = ROOT,
) {
const browser = await chromium.launch({ args: ['--no-sandbox', ...browserArgs] });
const page = await (await browser.newContext({
viewport, deviceScaleFactor: scale, ...contextOptions,
})).newPage();
// audit T1: an exception inside the card used to be logged and ignored
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.message); });
await page.route('**/*', (r) => {
const u = new URL(r.request().url());
let p = decodeURIComponent(u.pathname);
if (p === '/') p = '/demo.html';
const f = ROOT + p;
const f = serveRoot + p;
existsSync(f)
? r.fulfill({ status: 200, headers: { 'content-type': CT[p.slice(p.lastIndexOf('.'))] || 'application/octet-stream' }, body: readFileSync(f) })
: r.fulfill({ status: 404, body: 'nf' });
+35 -24
View File
@@ -25,16 +25,23 @@ const restore = () => page.evaluate((s) => {
return c.updateComplete && true;
}, snap);
const mode = (m) => page.evaluate((m) => {
const mode = async (m) => {
await page.evaluate((m) => {
const c = window.__card;
c._setMode(m); c.requestUpdate();
return c.updateComplete && true;
}, m);
}, m);
await page.waitForTimeout(220); // editor chrome transition owns stage geometry
};
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
const q = (sel) => page.evaluate((s) => window.__card.renderRoot.querySelectorAll(s).length, sel);
const spaceCfg = () => page.evaluate(() => {
const sp = window.__card._serverCfg.spaces.find((s) => s.id === 'f1');
return { x: sp.plan_x ?? null, y: sp.plan_y ?? null, k: sp.plan_scale ?? null };
return {
x: sp.plan_x ?? null, y: sp.plan_y ?? null,
sx: sp.plan_scale_x ?? null, sy: sp.plan_scale_y ?? null,
legacy: sp.plan_scale ?? null, angle: sp.plan_angle ?? null,
};
});
/** The image rect AS RENDERED (attributes of the <image> in the stage svg). */
const imageRect = () => page.evaluate(() => {
@@ -84,28 +91,24 @@ const tool = (t) => page.evaluate((t) => {
await mode('decor');
await settle();
// the frame is on screen the moment the backdrop editor opens…
check('frame_in_backdrop_editor', await q('.bdframe'), 1);
check('four_finger_handles', await q('.bdframe .bdhandle'), 4);
check('frame_hugs_the_picture', await frameRect(), BASE);
// Every Background session starts predictably on Select. The image frame is
// exclusive to its own tool and therefore cannot steal a Select/pan gesture.
check('no_frame_until_picture_tool', await q('.bdframe'), 0);
check('no_handles_until_picture_tool', await q('.bdframe .bdhandle'), 0);
check('no_picture_frame_until_picture_tool', await frameRect(), null);
check('untouched_picture_is_where_it_always_was', await imageRect(), BASE);
// …and the picture has a tool of its own, offered because f1 HAS a picture
check('the_picture_has_a_tool', await q('.decorbar .btn.dtool'), 8);
// …which is ARMED the moment the editor opens (owner 2026-08-05: «не
// получается двигать картинку-подложку в режиме редактора подложки» — the
// frame promised a draggable picture while the promise needed a tool nobody
// had found). The editor is named after the picture; it opens on it.
check('picture_tool_armed_on_open', await page.evaluate(() => window.__card._decorTool), 'backdrop');
check('grab_cursor', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), true);
// select still exists and still leaves the body to the pan (smoke_pan_any_zoom)
await tool('select');
await settle();
check('select_tool_armed_on_open', await page.evaluate(() => window.__card._decorTool), 'select');
check('no_grab_cursor_under_select', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), false);
await tool('backdrop');
await settle();
check('frame_still_there_under_its_own_tool', await q('.bdframe'), 1);
check('four_corners_plus_rotate_under_picture_tool', await q('.bdframe .bdhandle'), 5);
check('frame_hugs_the_picture_under_picture_tool', await frameRect(), BASE);
check('grab_cursor_under_picture_tool', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), true);
// a drawing tool owns the drag instead — no frame competing with it
await tool('rect');
await settle();
@@ -145,7 +148,8 @@ check('badge_gone_after_release', (await badges()).length, 0);
// corner lands on a lattice node within one step of where the finger let go
check('plan_x_saved', near(cfg.x, im.x / 1000, 1e-6), true);
check('plan_y_saved', near(cfg.y, (im.y - BASE.y) / 1000, 1e-6), true);
check('scale_untouched_by_a_move', cfg.k, 1);
check('scale_untouched_by_a_move', near(cfg.sx, 1) && near(cfg.sy, 1), true);
check('legacy_scale_not_written', cfg.legacy, null);
check('size_untouched_by_a_move', [im.w, im.h], [BASE.w, BASE.h]);
check('corner_lands_on_a_grid_node', onGrid(im.x) && onGrid(im.y), true);
// …at the node NEAREST the finger: 102 → 100 (24 steps), 152 → 150 (36).
@@ -201,9 +205,10 @@ await settle();
check('scaled_down', im.w < BASE.w, true);
check('uniform_no_stretch', near(im.w / im.h, BASE.w / BASE.h, 1e-9), true);
const cfg = await spaceCfg();
check('plan_scale_saved', near(cfg.k, im.w / BASE.w, 1e-5), true);
check('scaled_width_is_on_the_grid',
Math.abs((im.x + im.w) / PITCH - Math.round((im.x + im.w) / PITCH)) < 1e-6, true);
check('axis_scales_saved', near(cfg.sx, im.w / BASE.w, 1e-5)
&& near(cfg.sy, im.h / BASE.h, 1e-5), true);
check('proportional_scale_keeps_axes_equal', near(cfg.sx, cfg.sy, 1e-6), true);
check('scaled_width_is_on_the_grid', onGrid(im.x + im.w), true);
// …and the badge states the picture's REAL size through cell_cm
check('badge_matches_the_new_size', await page.evaluate(() => {
const c = window.__card;
@@ -217,12 +222,15 @@ await settle();
}
// ---------- 5) the reset button ------------------------------------------
check('reset_button_offered_once_moved', await q('.decorbar .bdreset'), 1);
check('reset_button_offered_once_moved', await q('.editor-secondary .bdreset'), 1);
check('reset_button_absent_from_primary_bar', await q('.decorbar .bdreset'), 0);
await page.evaluate(() => { const c = window.__card; c._bdReset?.(); c.requestUpdate(); return c.updateComplete; });
await settle();
check('reset_puts_the_picture_back', await imageRect(), BASE);
check('reset_clears_the_fields', await spaceCfg(), { x: null, y: null, k: null });
check('reset_button_gone_again', await q('.decorbar .bdreset'), 0);
check('reset_clears_the_fields', await spaceCfg(), {
x: null, y: null, sx: null, sy: null, legacy: null, angle: null,
});
check('reset_button_gone_again', await q('.editor-secondary .bdreset'), 0);
// ---------- 5b) the SELECT tool still pans right over the picture ---------
// The body of the picture is most of the screen; claiming it outside its own
@@ -272,9 +280,11 @@ const editorWhite = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const navSettled = () => new Promise((resolve) => setTimeout(resolve, 220));
const probe = async (mode) => {
c._setMode(mode);
await upd();
await navSettled();
const stage = sr().querySelector('.stage');
const paper = sr().querySelector('.stage svg .hp-paper');
return {
@@ -290,6 +300,7 @@ const editorWhite = await page.evaluate(async () => {
const decor = await probe('decor');
c._setMode('view');
await upd();
await navSettled();
const viewStage = sr().querySelector('.stage');
// View with a backdrop must NOT force white — theme/card colour stays
const viewForcedWhite = getComputedStyle(viewStage).backgroundColor === 'rgb(255, 255, 255)'
+13 -2
View File
@@ -13,6 +13,15 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
return getComputedStyle(sr().querySelector('.stage')).backgroundColor;
};
const waitStageBg = async (accept) => {
const deadline = performance.now() + 700;
let value = await stageBg();
while (!accept(value) && performance.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 25));
value = await stageBg();
}
return value;
};
const rgb = (hex) => `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
const themeBg = await stageBg(); // theme default from the stylesheet
@@ -81,10 +90,10 @@ const res = await page.evaluate(async () => {
// 8) editors keep their own canvas (inline bg only in view/kiosk)
c._setMode('decor');
const editorBg = await stageBg();
const editorBg = await waitStageBg((value) => value !== rgb('#0a2a4a'));
out.editorUnpainted = editorBg !== rgb('#0a2a4a');
c._setMode('view');
out.viewPaintedAgain = (await stageBg()) === rgb('#0a2a4a');
out.viewPaintedAgain = (await waitStageBg((value) => value === rgb('#0a2a4a'))) === rgb('#0a2a4a');
// 9) kiosk instance respects the setting
const k = document.createElement('houseplan-card');
@@ -181,9 +190,11 @@ const paper = await page.evaluate(async () => {
// editors keep the paper too (their canvas ignores bg_color anyway)
c._setMode('decor');
await upd();
await new Promise((resolve) => setTimeout(resolve, 220));
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
c._setMode('view');
await upd();
await new Promise((resolve) => setTimeout(resolve, 220));
// daynight night: the paper dims via the brightness filter ONLY, never alpha
c._serverCfg.settings.bg_mode = 'daynight';
c._serverCfg.settings.north_deg = 0;
+11
View File
@@ -1,5 +1,7 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
// Reproduce the narrow wall-tablet/mobile dialog from the field report.
await page.setViewportSize({ width: 390, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
@@ -12,6 +14,15 @@ const res = await page.evaluate(async () => {
const dev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('light.') || e.startsWith('switch.')));
out.hasDev = !!dev;
c._infoCard = dev; await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(resolve));
out.infoUsesWideShell = sr().querySelector('hp-dialog')?.hasAttribute('wide') === true;
const footer = sr().querySelector('.infofooter');
const footerBox = footer?.getBoundingClientRect();
const footerButtons = [...(footer?.querySelectorAll('button') || [])];
out.footerActionsContained = !!footerBox && footerButtons.length >= 2 && footerButtons.every((button) => {
const rect = button.getBoundingClientRect();
return rect.left >= footerBox.left - 1 && rect.right <= footerBox.right + 1;
});
// 1) блок сущностей идёт ПЕРВЫМ, до модели/ссылок
const body = sr().querySelector('hp-dialog .body');
out.entListFirst = body.firstElementChild?.classList.contains('entlist')
+3 -2
View File
@@ -62,13 +62,14 @@ const res = await page.evaluate(async () => {
return out;
});
// зафиксировано прогоном на v1.45.0 и сверено с кодом.
// scansPerUpdate = 2: один обход у areaClimateMap, один у buildDevices. Важно
// scansPerUpdate = 1: active-registry projection is shared by the climate map
// and buildDevices. Важно
// не само число, а что оно не растёт вместе с числом комнат.
checkAll(res, {
scansOnRerender: 0,
roomCount: 44,
scansSameWith44Rooms: true,
scansPerUpdate: 2,
scansPerUpdate: 1,
tempBefore: 22.4,
tempAfter: 33.3,
climateIsMap: true,
+37
View File
@@ -57,6 +57,43 @@ const res = await page.evaluate(async () => {
c._clickDevice(new MouseEvent('click'), dev2());
const last = calls.at(-1);
out.lockFiltered = JSON.stringify(last) === JSON.stringify(['homeassistant', 'turn_on', [lights[0]]]);
// Opening and saving must not rewrite configuration that runtime cannot
// currently execute: YAML-only domains and duplicates are still user data.
const losslessControls = [lights[0], 'input_boolean.yaml_only', lights[0], 'sensor.vendor_option'];
cfg.controls = [...losslessControls];
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
c._openMarkerDialog(dev2()); await c.updateComplete;
out.dialogKeepsUnknownAndDuplicateControls = JSON.stringify(c._markerDialog?.controls)
=== JSON.stringify(losslessControls);
await c._saveMarker(); await c.updateComplete;
const savedLossless = c._serverCfg.markers.find((m) => m.name === 'Выключатель')?.controls;
out.saveKeepsUnknownAndDuplicateControls = JSON.stringify(savedLossless)
=== JSON.stringify(losslessControls);
// Legacy configs could store a standalone entity as its own external
// control. That self-reference must neither become a light source/group nor
// disappear behind ambiguous dialog UI. Its normal Toggle action still
// operates the entity, and an ON hood/fan still has working-state yellow.
const own = Object.keys(c.hass.states).find((eid) => eid.startsWith('switch.'));
out.hasStandaloneSwitch = !!own;
const self = own && {
id: 'self-control-regression', name: 'Hood', icon: 'mdi:fan',
space: c._space, area: room.area, entities: [own], primary: own,
bindingKind: 'entity', bindingRef: own, tapAction: 'toggle',
marker: { id: 'self-control-regression', binding: `entity:${own}`, controls: [own] },
};
if (self) {
await setSt({ [own]: 'off' });
c._clickDevice(new MouseEvent('click'), self);
out.selfControlUsesDirectToggle = JSON.stringify(calls.at(-1))
=== JSON.stringify(['homeassistant', 'toggle', own]);
c._openMarkerDialog(self); await c.updateComplete;
out.selfControlAbsentFromDialog = c._markerDialog?.controls.length === 0;
c._markerDialog = null;
await setSt({ [own]: 'on' });
out.selfSwitchStillShowsWorkingState = c._stateClass(self).includes('on');
}
return out;
});
checkAll(res);
+9 -1
View File
@@ -181,7 +181,15 @@ const out = await page.evaluate(async () => {
const valve = { id: 'v', primary: 'valve.water', entities: ['valve.water'], marker: null };
const clsOfState = (d, states) => {
const saved = c.hass;
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
const entities = Object.fromEntries(Object.keys(states).map((entity_id) => [
entity_id,
{ entity_id, platform: 'demo', disabled_by: null },
]));
c.hass = {
...c.hass,
entities: { ...c.hass.entities, ...entities },
states: { ...c.hass.states, ...states },
};
const r = c._stateClass(d);
c.hass = saved;
return r;
+9 -1
View File
@@ -45,7 +45,15 @@ const out = await page.evaluate(async () => {
// =======================================================================
const withStates = (states, fn) => {
const saved = c.hass;
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
const entities = Object.fromEntries(Object.keys(states).map((entity_id) => [
entity_id,
{ entity_id, platform: 'demo', disabled_by: null },
]));
c.hass = {
...c.hass,
entities: { ...c.hass.entities, ...entities },
states: { ...c.hass.states, ...states },
};
const r = fn();
c.hass = saved;
return r;
+128 -4
View File
@@ -11,6 +11,24 @@ const res = await page.evaluate(async () => {
tabs[2].click(); await c.updateComplete;
out.decorMode = c._mode === 'decor';
out.decorBar = !!sr().querySelector('.editbar.decorbar');
// The compact colour/opacity control must escape the clipped editor/dialog
// surface through the browser top layer and remain inside the viewport.
c._decorTool = 'line'; c.requestUpdate(); await c.updateComplete;
const picker = sr().querySelector('.editor-secondary hp-color-opacity');
out.transientStyleLivesInContextTray = !!picker
&& !sr().querySelector('.decorbar hp-color-opacity');
const trigger = picker?.shadowRoot?.querySelector('.trigger');
trigger?.click();
await picker?.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const popup = picker?.shadowRoot?.querySelector('.picker');
const popupRect = popup?.getBoundingClientRect();
out.colorPickerUsesTopLayer = !!popup?.matches(':popover-open');
out.colorPickerInsideViewport = !!popupRect
&& popupRect.left >= 0 && popupRect.top >= 0
&& popupRect.right <= innerWidth && popupRect.bottom <= innerHeight;
trigger?.click();
await picker?.updateComplete;
// seven tools (the six drawing ones + «Мебель», docs/FURNITURE.md) plus
// «Картинка-подложка», which f1 offers because it HAS a picture
// (docs/BACKDROP.md §2); a hand-drawn space still shows seven
@@ -48,6 +66,9 @@ const res = await page.evaluate(async () => {
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
c._decorCommitDraft();
out.degenerateSkipped = c._decorList.length === 3;
c._decorDraft = { kind: 'rect', a: [g, g], b: [g * 5, g], pid: 1 };
c._decorCommitDraft();
out.flatRectSkipped = c._decorList.length === 3;
// 3b) живая плашка размера, пока фигуру ещё тянут (владелец 2026-08-04:
// «в редакторе подложки у линий писать длину, как при рисовании комнат в
// редакторе плана»). Та же .measurelabel, тот же formatLength/cell_cm.
@@ -76,10 +97,11 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
out.obliqueBadge = txt() === fmt(g * 5) + ' · 53.1°';
out.obliqueNotGreen = !!label() && !label().classList.contains('on45');
// прямоугольник и овал: длины у них нет, есть габарит «Ш × В»
// прямоугольник показывает габарит и чистую площадь
c._decorDraft = { kind: 'rect', a: [g * 4, g * 20], b: [g * 12, g * 26], pid: 3 };
await c.updateComplete;
out.rectBadgeIsSize = txt() === fmt(g * 8) + ' × ' + fmt(g * 6);
out.rectBadgeHasSizeAndArea = txt().startsWith(fmt(g * 8) + ' × ' + fmt(g * 6) + ' · ')
&& /(?:m²|ft²)$/.test(txt());
// отпустили — плашки нет
c._decorCommitDraft(); await c.updateComplete;
out.badgeGoneAfterRelease = !label();
@@ -172,6 +194,29 @@ const res = await page.evaluate(async () => {
out.selectStillGrabs = !!c._decorMove && c._decorMove.id === 'dcprobe';
out.selectMakesNoDraft = !c._decorDraft;
c._decorMove = null;
const selectionTray = sr().querySelector('.editor-secondary-host.open .editor-secondary.kind-selection');
out.selectionActionsUseContextTray = !!selectionTray
&& !!selectionTray.querySelector('ha-icon[icon="mdi:tune"]')
&& !!selectionTray.querySelector('ha-icon[icon="mdi:delete-outline"]')
&& !sr().querySelector('.decorbar .danger');
const selectionAction = selectionTray?.querySelector('button');
selectionAction?.focus();
selectionAction?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Delete', bubbles: true, composed: true, cancelable: true,
}));
await c.updateComplete;
out.deleteDoesNotFallThroughContextTray = c._decorSel === 'dcprobe'
&& c._decorList.some((shape) => shape.id === 'dcprobe');
const staleContextId = c._editorSecondaryContextId;
let staleInvokes = 0;
const staleInvoke = () => c._runEditorContext(staleContextId, () => { staleInvokes += 1; });
const otherShape = c._decorList.find((shape) => shape.id !== 'dcprobe');
c._decorSel = otherShape?.id || null;
c._decorShapeDialog = null;
c.requestUpdate(); await c.updateComplete;
staleInvoke();
out.staleContextActionIsIgnored = staleInvokes === 0 && c._decorShapeDialog === null;
c._decorSel = 'dcprobe'; c.requestUpdate(); await c.updateComplete;
// Double click in Select is the universal properties gesture for every
// object, not only text. It must go through the rendered listener so this
// also pins the event wiring, then persist the common colour/width fields.
@@ -181,19 +226,98 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
out.doubleClickOpensObjectDialog = c._decorShapeDialog?.id === 'dcprobe'
&& !!sr().querySelector('hp-dialog .dfill') === false;
c._decorShapeDialog = { ...c._decorShapeDialog, color: '#123456', width: 6.5 };
out.lineStyleDefaultsSolid = c._decorShapeDialog?.lineStyle === 'solid'
&& sr().querySelectorAll('input[name="decor-line-style"]').length === 2
&& !c._decorList.find((x) => x.id === 'dcprobe')?.line_style;
const angleInput = sr().querySelector('hp-dialog input[min="-180"][max="180"]');
if (angleInput) {
angleInput.value = '12.3456';
angleInput.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await c.updateComplete;
}
out.angleInputKeepsWorkingPrecision = c._decorShapeDialog?.angle === '12.3456'
&& sr().querySelector('hp-dialog input[min="-180"][max="180"]')?.value === '12.3456';
out.lineStyleAbsentFromToolbar = !sr().querySelector('.editbar input[name="decor-line-style"]');
c._decorShapeDialog = {
...c._decorShapeDialog, color: '#123456', widthCm: 6.5, lineStyle: 'dashed',
};
c._decorSaveShape(); await c.updateComplete;
const editedProbe = c._decorList.find((x) => x.id === 'dcprobe');
out.objectDialogSavesStyle = editedProbe?.color === '#123456'
&& editedProbe?.width === 6.5 && c._decorShapeDialog === null;
&& editedProbe?.width_cm === 6.5 && editedProbe?.width === undefined
&& editedProbe?.line_style === 'dashed'
&& c._decorShapeDialog === null;
out.dashedLineRendered = !!probe()?.getAttribute('stroke-dasharray');
c._undoGeometry(); await c.updateComplete;
const undoSolid = !c._decorList.find((x) => x.id === 'dcprobe')?.line_style;
c._redoGeometry(); await c.updateComplete;
out.lineStyleUndoRedo = undoSolid
&& c._decorList.find((x) => x.id === 'dcprobe')?.line_style === 'dashed';
const selectHit = sr().querySelector('.decorlayer line.dselecthit');
const dashedPaint = probe();
let gapPicked = null;
if (selectHit && dashedPaint) {
const sw = Number(dashedPaint.getAttribute('stroke-width')) || 1;
const gapPoint = dashedPaint.getPointAtLength(Math.min(dashedPaint.getTotalLength() * 0.75, sw * 5.5));
const screenPoint = new DOMPoint(gapPoint.x, gapPoint.y).matrixTransform(dashedPaint.getScreenCTM());
gapPicked = sr().elementFromPoint(screenPoint.x, screenPoint.y);
}
out.dashedLineSelectableAcrossGaps = gapPicked === selectHit
&& selectHit?.classList.contains('dshape')
&& selectHit?.dataset.hp === 'decor'
&& selectHit?.dataset.id === 'dcprobe';
// Erase gets a constant screen-space target around hairlines. Exercise the
// browser's actual SVG hit-test 6 px away from a sub-pixel painted stroke;
// dispatching directly to the proxy would only prove that its listener exists.
c._replaceDecor('dcprobe', { width_cm: 0.1, width: undefined });
c._decorTool = 'erase'; await c.updateComplete;
const paintedProbe = sr().querySelector('.decorlayer line.dshape:not(.derasehit)');
const eraseHit = sr().querySelector('.decorlayer line.derasehit');
const matrix = paintedProbe?.getScreenCTM();
let picked = null;
if (paintedProbe && eraseHit && matrix) {
const p1 = new DOMPoint(+paintedProbe.getAttribute('x1'), +paintedProbe.getAttribute('y1')).matrixTransform(matrix);
const p2 = new DOMPoint(+paintedProbe.getAttribute('x2'), +paintedProbe.getAttribute('y2')).matrixTransform(matrix);
const dx = p2.x - p1.x, dy = p2.y - p1.y, len = Math.hypot(dx, dy) || 1;
picked = sr().elementFromPoint((p1.x + p2.x) / 2 - (dy / len) * 6,
(p1.y + p2.y) / 2 + (dx / len) * 6);
}
out.eraseHairlineHasWideHitTarget = picked === eraseHit
&& getComputedStyle(eraseHit).strokeWidth === '16px'
&& getComputedStyle(eraseHit).pointerEvents === 'stroke';
picked?.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, cancelable: true, pointerId: 45, button: 0, isPrimary: true,
}));
await c.updateComplete;
out.eraseWideHitOpensConfirmation = c._decorEraseConfirm?.id === 'dcprobe';
c._decorEraseConfirm = null;
c._decorSel = null;
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
c._decorTool = 'select'; await c.updateComplete;
out.decorRestored = c._decorList.length === decorBefore.length;
// Switching modes during a live move cancels the transaction and restores
// its start instead of leaving mutated geometry with a forgotten pointer.
const liveShape = c._decorList[0];
const liveBefore = JSON.parse(JSON.stringify(liveShape));
const liveSnapshot = c._geometrySnapshot();
c._decorMove = {
id: liveShape.id, start: [0, 0], orig: liveBefore, pid: 99,
moved: true, before: liveSnapshot,
};
c._curSpaceCfg.decor = c._decorList.map((shape) => shape.id === liveShape.id
? { ...shape, x: (shape.x || 0) + 0.2 }
: shape);
c._setMode('view'); await c.updateComplete;
const restoredLive = c._decorList.find((shape) => shape.id === liveShape.id);
out.modeSwitchCancelsLiveDecor = !c._decorMove
&& JSON.stringify(restoredLive) === JSON.stringify(liveBefore);
c._setMode('decor'); await c.updateComplete;
// 6) erase удаляет
const n0 = c._decorList.length;
c._decorTool = 'erase';
c._decorShapeDown({ stopPropagation() {}, preventDefault() {}, target: null, pointerId: 1 }, c._decorList[0]);
out.eraseAsksConfirmation = c._decorList.length === n0 && !!c._decorEraseConfirm;
c._confirmDecorErase();
out.eraseWorks = c._decorList.length === n0 - 1;
// 7) Esc-лестница: инструмент → select → выход
c._decorTool = 'line'; c._decorSel = null; await c.updateComplete;
+17 -8
View File
@@ -1,6 +1,7 @@
// Текстовый блок декора (правки владельца 2026-08-04):
// 1) выбора размера шрифта БОЛЬШЕ НЕТ — размер задаётся углами блока, а
// сохранённый старый `size` читается как начальный масштаб;
// 1) выбора размера шрифта БОЛЬШЕ НЕТ — размер задаётся углами блока и
// после явной правки хранится физически в `size_cm`; старый `size`
// до этого продолжает читаться без визуальной миграции;
// 2) многострочный текст: перевод строки сохраняется и рисуется, блок
// выравнивается по центру;
// 3) у выделенного блока есть угловые ручки (масштаб) и ручка поворота
@@ -117,42 +118,49 @@ const res = await page.evaluate(async () => {
const far = toScreen(rcx + hitR * 0.7, rcy); // > knobR, < hitR
const hitEl = sr().elementFromPoint(far.clientX, far.clientY);
out.fingerZoneStillCatchesTheHandle = !!hitEl?.classList?.contains?.('dthandle');
// линия выделена — рамки текста нет (это рамка ТЕКСТОВОГО блока)
// линия использует общую рамку, но только с двумя ручками концов
c._curSpaceCfg.decor = [...c._decorList, { id: 'dline', kind: 'line',
x1: 0.1, y1: 0.8, x2: 0.4, y2: 0.8, color: '#000000', width: 3 }];
c._cfgEpoch++; c._decorSel = 'dline'; c.requestUpdate();
await c.updateComplete; await sleep(40); await c.updateComplete;
out.noFrameOnALine = !sr().querySelector('.dtframe');
const lineFrame = sr().querySelector('.dtframe.dtlineframe');
out.lineGetsEndpointFrame = !!lineFrame
&& lineFrame.querySelectorAll('.dthandle.dtendpoint').length === 2
&& !lineFrame.querySelector('.dtrot');
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
// тянем угол: масштаб растёт пропорционально расстоянию от якоря
// тянем угол: физический размер растёт пропорционально расстоянию от якоря
const anchor = () => {
const sh = c._decorList.find((x) => x.id === 'dtm');
return [sh.x * 1000, sh.y * 1000];
};
const [ax, ay2] = anchor();
const mediumSizeCm0 = c._decorTextSizeCm(c._decorList.find((x) => x.id === 'dtm'));
const corner = frame()?.querySelector('.dthandle.dt-nwse') || stageEl();
ev('pointerdown', corner, ax + 40, ay2);
out.dragStarted = !!c._dtDrag && c._dtDrag.kind === 'scale';
ev('pointermove', stageEl(), ax + 80, ay2);
await c.updateComplete;
const scaled = c._decorList.find((x) => x.id === 'dtm');
out.cornerScales = Math.abs((scaled.scale || 0) - 2) < 0.01;
out.cornerStoresPhysicalSize = scaled.scale === undefined && scaled.size === undefined
&& Math.abs((scaled.size_cm || 0) - mediumSizeCm0 * 2) < 0.01;
out.fontFollowsScale = +attr('dtm', 'font-size') === 40;
ev('pointerup', stageEl(), ax + 80, ay2);
out.dragEnded = !c._dtDrag;
// старый `size` заменяется масштабом, который он означал (одна правда)
// старый `size` становится физическим `size_cm` только после явной правки
c._decorSel = 'dtl'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const [lx, ly] = [c._decorList.find((x) => x.id === 'dtl').x * 1000,
c._decorList.find((x) => x.id === 'dtl').y * 1000];
const legacySizeCm0 = c._decorTextSizeCm(c._decorList.find((x) => x.id === 'dtl'));
ev('pointerdown', frame()?.querySelector('.dthandle.dt-nwse') || stageEl(), lx + 50, ly);
ev('pointermove', stageEl(), lx + 100, ly);
await c.updateComplete;
const grown = c._decorList.find((x) => x.id === 'dtl');
out.legacySizeBecomesScale = grown.size === undefined && Math.abs((grown.scale || 0) - 3) < 0.01;
out.legacySizeBecomesPhysical = grown.size === undefined && grown.scale === undefined
&& Math.abs((grown.size_cm || 0) - legacySizeCm0 * 2) < 0.01;
ev('pointerup', stageEl(), lx + 100, ly);
// ручка поворота: шаг 5°, Shift — мимо шага
@@ -189,6 +197,7 @@ const res = await page.evaluate(async () => {
const before = c._decorList.length;
const target = c._decorList.find((x) => x.id === 'dtm');
out.labelIsLiveUnderTextTool = !!el('dtm') && getComputedStyle(el('dtm')).pointerEvents !== 'none';
out.chromiumTextUsesAtomicBoundingBox = getComputedStyle(el('dtm')).pointerEvents === 'bounding-box';
ev('pointerdown', el('dtm') || stageEl(), target.x * 1000, target.y * 1000);
await c.updateComplete;
out.textToolEditsTheLabel = c._decorTextDialog?.id === 'dtm'
+60
View File
@@ -0,0 +1,60 @@
// Shared face parity for the same live light on the full plan, the marker
// editor preview and houseplan-space-card.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1100, height: 900 }, 1);
const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const face = (node) => {
if (!node) return null;
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') || '',
value: node.querySelector('.valtext')?.textContent?.trim() || '',
temp: node.querySelector('.tval')?.textContent?.trim() || '',
hum: node.querySelector('.hval')?.textContent?.trim() || '',
scale: node.style.getPropertyValue('--dev-scale'),
rippleScale: node.style.getPropertyValue('--ripple-scale'),
rippleColor: node.style.getPropertyValue('--ripple-color'),
};
};
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
const planFace = face(sr().querySelector('.dev[data-id="d_light1"]'));
c._setMode('devices');
const device = c._devices.find((item) => item.id === 'd_light1');
c._openMarkerDialog(device);
await c.updateComplete;
const preview = sr().querySelector('hp-device-preview');
await preview?.updateComplete;
const previewFace = face(preview?.renderRoot?.querySelector('.dev'));
const providerShown = /demo/i.test(preview?.renderRoot?.querySelector('.previewfacts')?.textContent || '');
await customElements.whenDefined('houseplan-space-card');
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = c.hass;
document.body.appendChild(card);
const started = Date.now();
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - started < 6000) await wait(60);
await card.updateComplete;
const staticNode = card.renderRoot.querySelector('.dev[data-id="d_light1"]');
const staticFace = face(staticNode);
return {
allFacesPresent: !!planFace && !!previewFace && !!staticFace,
planPreviewEqual: JSON.stringify(planFace) === JSON.stringify(previewFace),
planStaticEqual: JSON.stringify(planFace) === JSON.stringify(staticFace),
providerShown,
staticBindingHook: staticNode?.getAttribute('data-binding-status') === 'active',
};
});
checkAll(res);
await finish(browser, res);
+70
View File
@@ -0,0 +1,70 @@
// HA-disabled lifecycle: saved marker -> forced hidden -> service ghost and
// blocked Show -> same marker restored after reactivation, without config writes.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 900 }, 1);
const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const paint = async () => { c.requestUpdate(); await c.updateComplete; };
c._serverCfg = {
...c._serverCfg,
markers: [
...(c._serverCfg.markers || []).filter((marker) => marker.id !== 'd_light1'),
{ id: 'd_light1', binding: 'device:d_light1' },
],
};
c._cfgEpoch++;
c._regSignature = '';
c._maybeRebuildDevices();
c._setMode('view');
await paint();
const configBefore = JSON.stringify(c._serverCfg);
const layoutBefore = JSON.stringify(c._layout);
const visibleBefore = !!sr().querySelector('.dev[data-id="d_light1"]');
window.__setRegistryDisabled('device', 'd_light1', 'user');
c.hass = window.__mkHass();
await wait(220);
await paint();
const hiddenInView = !sr().querySelector('.dev[data-id="d_light1"]');
c._showHidden = true;
c._setMode('devices');
c._regSignature = '';
c._maybeRebuildDevices();
await paint();
const ghost = sr().querySelector('.dev[data-id="d_light1"][data-binding-status="ha-disabled"]');
const disabledDevice = c._devices.find((device) => device.id === 'd_light1');
c._openMarkerDialog(disabledDevice);
await paint();
const hiddenBeforeShow = c._markerDialog?.hideFromPlan;
c._toggleMarkerDialogVisibility();
await paint();
const showBlocked = /disabled|deactiv/i.test(c._toast)
&& c._markerDialog?.hideFromPlan === hiddenBeforeShow
&& sr().querySelector('.toast')?.getAttribute('role') === 'alert';
c._markerDialog = null;
window.__setRegistryDisabled('device', 'd_light1', null);
c.hass = window.__mkHass();
await wait(220);
c._showHidden = false;
c._setMode('view');
await paint();
return {
visibleBefore,
hiddenInView,
labelledGhost: !!ghost,
showBlocked,
restored: !!sr().querySelector('.dev[data-id="d_light1"]'),
configUnchanged: JSON.stringify(c._serverCfg) === configBefore,
layoutUnchanged: JSON.stringify(c._layout) === layoutBefore,
};
});
checkAll(res);
await finish(browser, res);
+5 -2
View File
@@ -16,11 +16,14 @@ const res = await page.evaluate(async () => {
c._tool = 'draw';
await upd();
out.fieldDefault = c._drawWallFieldValue === '15' || c._drawWallFieldValue === '15.0';
out.fieldInBar = !!sr().querySelector('.editbar .drawwall input');
const field = sr().querySelector('.editor-secondary.kind-tool .drawwall input');
out.fieldInContextTray = !!field;
out.fieldAbsentFromPrimaryBar = !sr().querySelector('.editbar .drawwall input');
out.fieldTrayIsStageOverlay = field?.closest('.editor-secondary-host')?.closest('.stage')
=== sr().querySelector('.stage');
const wallsButton = sr().querySelector('.editbar button.on');
out.drawButtonNamedRoomOutline = ['Room outline', 'Контур комнаты']
.includes((wallsButton?.textContent || '').trim());
out.fieldImmediatelyAfterWalls = wallsButton?.nextElementSibling?.matches('label.drawwall') === true;
// draw a small room away from existing rooms (garden/demo f1 may have rooms)
// use empty space if available, else clear rooms on a copy path
+69
View File
@@ -26,6 +26,75 @@ out.pinchZoomsInPlanEditor = await page.evaluate(() => {
return zoomed && c._path.length === 0;
});
// A child control may stop pointer events before the stage sees the first
// finger. The card-level capture guard must still classify the sequence as a
// pinch and swallow WebKit's synthetic click before it reaches that control.
out.pinchCannotMisclickInteractiveChild = await page.evaluate(async () => {
const c = window.__card;
c._setMode('view');
await c.updateComplete;
const stage = c._stageEl;
const probe = document.createElement('button');
let clicks = 0;
probe.addEventListener('pointerdown', (ev) => ev.stopPropagation());
probe.addEventListener('pointermove', (ev) => ev.stopPropagation());
probe.addEventListener('pointerup', (ev) => ev.stopPropagation());
probe.addEventListener('click', () => clicks++);
stage.appendChild(probe);
const pointer = (type, id, target, x = id === 51 ? 300 : 400) => target.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, pointerId: id, pointerType: 'touch',
clientX: x, clientY: 300,
}));
c._resetZoom();
const zoom0 = c._zoom;
pointer('pointerdown', 51, probe);
pointer('pointerdown', 52, stage);
pointer('pointermove', 51, probe, 250);
pointer('pointermove', 52, stage, 450);
const pinchZoomed = c._zoom > zoom0 * 1.5;
pointer('pointerup', 51, probe);
pointer('pointerup', 52, stage);
probe.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }));
const gestureClickBlocked = clicks === 0;
await new Promise((resolve) => setTimeout(resolve, 520));
probe.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }));
probe.remove();
const ordinaryTapStillWorks = clicks === 1;
c._setMode('plan');
await c.updateComplete;
return pinchZoomed && gestureClickBlocked && ordinaryTapStillWorks;
});
// Robot-map calibration owns the gesture surface. The card-level capture
// guard must still suppress a two-finger misclick, but must not seed or apply
// the plan's pinch zoom underneath the calibration overlay.
out.pinchDoesNotZoomBelowVacuumFit = await page.evaluate(async () => {
const c = window.__card;
c._setMode('view');
await c.updateComplete;
const stage = c._stageEl;
const previousFit = c._vacFit;
c._vacFit = { markerId: 'smoke', source: 'smoke', mapId: 'smoke',
p: { ox: 0, oy: 0, k: 1, rot: 0, mir: false }, drag: null };
c._resetZoom();
const zoom0 = c._zoom;
const pointer = (type, id, x) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, pointerId: id, pointerType: 'touch',
clientX: x, clientY: 300,
}));
pointer('pointerdown', 61, 300);
pointer('pointerdown', 62, 400);
pointer('pointermove', 61, 250);
pointer('pointermove', 62, 450);
const stayed = c._zoom === zoom0 && c._pinchStart === null && c._pointers.size === 0;
pointer('pointerup', 61, 250);
pointer('pointerup', 62, 450);
c._vacFit = previousFit;
c._setMode('plan');
await c.updateComplete;
return stayed;
});
// --- пан одним пальцем в редакторе, точка не рисуется --------------------
out.panWorksAndDoesNotDraw = await page.evaluate(async () => {
const c = window.__card;
+186 -1
View File
@@ -15,24 +15,209 @@ const res = await page.evaluate(async () => {
out.planActive = c._mode === 'plan' && tabs()[0].classList.contains('active');
out.planBar = !!sr().querySelector('.editbar');
out.planBarClose = !!sr().querySelector('.editbar .barclose');
const barClose = sr().querySelector('.editbar .barclose');
const barCloseIcon = barClose?.querySelector('ha-icon');
const barCloseRect = barClose?.getBoundingClientRect();
const barCloseIconRect = barCloseIcon?.getBoundingClientRect();
out.planBarCloseIconIsCentered = !!barCloseRect && !!barCloseIconRect
&& Math.abs((barCloseIconRect.left + barCloseIconRect.width / 2)
- (barCloseRect.left + barCloseRect.width / 2)) <= 1
&& Math.abs((barCloseIconRect.top + barCloseIconRect.height / 2)
- (barCloseRect.top + barCloseRect.height / 2)) <= 1;
out.tabCross = !!tabs()[0].querySelector('.closex');
// 3) повторный клик по активной вкладке — ничего
tabs()[0].click(); await c.updateComplete;
out.reclickNoop = c._mode === 'plan';
// Let the View -> editor grid transition finish first. A real user cannot
// switch editors in the same zero-layout frame, and competing WAAPI/CSS
// height animations in that frame are intentionally suppressed.
await new Promise((resolve) => setTimeout(resolve, 220));
// 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();
const closeBeforeContext = sr().querySelector('.editbar .barclose').getBoundingClientRect();
const hdrBeforeContext = c._hdrH;
c._tool = 'draw'; c.requestUpdate(); await c.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 40));
const stageWithContext = sr().querySelector('.stage').getBoundingClientRect();
const closeWithContext = sr().querySelector('.editbar .barclose').getBoundingClientRect();
const secondary = sr().querySelector('.editor-secondary-host.open .editor-secondary');
out.contextTrayLivesInStage = !!secondary
&& secondary.closest('.stage') === sr().querySelector('.stage')
&& !secondary.closest('.editorchrome');
out.contextDoesNotResizeStage = Math.abs(stageWithContext.top - stageBeforeContext.top) <= 1
&& Math.abs(stageWithContext.height - stageBeforeContext.height) <= 1
&& c._hdrH === hdrBeforeContext;
out.contextDoesNotMoveClose = Math.abs(closeWithContext.left - closeBeforeContext.left) <= 1
&& Math.abs(closeWithContext.top - closeBeforeContext.top) <= 1;
out.closeHasPinnedEndCap = !!sr().querySelector('.editbar > .editbar-end > .barclose');
// No current tools are grouped automatically. Inject one declarative group
// to pin the generic launcher/keyboard/restore contract for future editors.
let groupInvokes = 0;
Object.defineProperty(c, '_editorToolbarGroups', { configurable: true, get: () => [{
id: 'smoke-group', label: 'Smoke group', icon: 'mdi:dots-grid', items: [{
id: 'smoke-command', label: 'Smoke command', icon: 'mdi:check',
role: 'command', invoke: () => { groupInvokes += 1; },
}],
}] });
c.requestUpdate(); await c.updateComplete;
const groupLauncher = sr().querySelector('[data-editor-group="smoke-group"]');
groupLauncher?.focus();
groupLauncher?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowDown', bubbles: true, composed: true, cancelable: true,
}));
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(resolve));
out.groupUsesSharedTray = !!sr().querySelector('.editor-secondary.kind-group')
&& sr().querySelectorAll('.editor-secondary-host').length === 1;
out.groupArrowDownMovesFocus = sr().activeElement?.classList?.contains('editor-group-item') === true;
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(resolve));
out.groupEscapeRestoresToolContext = c._editorSecondary.hasOpenGroup === false
&& !!sr().querySelector('.editor-secondary.kind-tool')
&& sr().activeElement === groupLauncher;
groupLauncher?.click(); await c.updateComplete;
sr().querySelector('.editor-group-item')?.click(); await c.updateComplete;
out.groupCommandClosesBeforeInvoke = groupInvokes === 1
&& c._editorSecondary.hasOpenGroup === false
&& !!sr().querySelector('.editor-secondary.kind-tool');
groupLauncher?.click(); await c.updateComplete;
const outsideTool = document.createElement('button');
const unrelatedTool = document.createElement('button');
let outsideInvokes = 0;
let unrelatedInvokes = 0;
outsideTool.addEventListener('click', () => outsideInvokes++);
unrelatedTool.addEventListener('click', () => unrelatedInvokes++);
document.body.append(outsideTool, unrelatedTool);
outsideTool.dispatchEvent(new PointerEvent('pointerdown', {
pointerId: 301, pointerType: 'mouse', button: 0,
bubbles: true, composed: true, cancelable: true,
}));
outsideTool.dispatchEvent(new MouseEvent('click', {
button: 0, bubbles: true, composed: true, cancelable: true,
}));
unrelatedTool.click();
await c.updateComplete;
out.groupOutsideDismissIsConsumed = c._editorSecondary.hasOpenGroup === false
&& outsideInvokes === 0 && c._tool === 'draw';
out.dismissTailDoesNotEatUnrelatedClick = unrelatedInvokes === 1;
outsideTool.remove();
unrelatedTool.remove();
delete c._editorToolbarGroups;
c._tool = 'select'; c.requestUpdate(); await c.updateComplete;
// 4) прямое переключение План → Устройства
// Deliberately make the outgoing bar taller: the editor transition must
// interpolate real content height, not merely fade two equal-height rows.
const planBarBeforeSwap = sr().querySelector('.editorchrome .editbar');
planBarBeforeSwap.style.minHeight = `${planBarBeforeSwap.getBoundingClientRect().height + 36}px`;
tabs()[1].click(); await c.updateComplete;
out.directSwitch = c._mode === 'devices';
out.devBar = !!sr().querySelector('.editbar.devbar');
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 3; // add/show-all/rules (v1.33.2: Reset removed)
const swapChrome = sr().querySelector('.editorchrome');
const swapInner = swapChrome.querySelector('.editorchrome-inner');
out.editorSwapAnimatesHeight = swapChrome.getAnimations()
.some((animation) => animation.id === 'hp-editor-height-swap');
out.editorSwapAnimatesContent = swapInner.getAnimations()
.some((animation) => animation.id === 'hp-editor-content-swap');
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
// 6) крестик на панели → Просмотр
sr().querySelector('.editbar .barclose').click(); await c.updateComplete;
out.barCloseWorks = c._mode === 'view' && !sr().querySelector('.editbar');
const chrome = sr().querySelector('.editorchrome');
out.barCloseStarts = c._mode === 'view' && !chrome.classList.contains('open')
&& chrome.getAttribute('aria-hidden') === 'true';
// The last bar deliberately remains mounted while its grid row collapses.
await new Promise((resolve) => setTimeout(resolve, 220));
out.barCloseWorks = c._mode === 'view'
&& getComputedStyle(chrome).visibility === 'hidden'
&& chrome.getBoundingClientRect().height < 1;
// 7) крестик в самой вкладке → Просмотр (и не переключает режим)
tabs()[1].click(); await c.updateComplete;
tabs()[1].querySelector('.closex').click(); await c.updateComplete;
out.tabCrossWorks = c._mode === 'view';
// Layout-independent editor history: physical KeyZ works when `key` is the
// Russian «я», while an input keeps its native browser history.
tabs()[0].click(); await c.updateComplete;
c._tool = 'draw'; c.requestUpdate(); await c.updateComplete;
const realUndo = c._undoGeometry;
const realRedo = c._redoGeometry;
let undoCalls = 0;
let redoCalls = 0;
c._undoGeometry = () => { undoCalls += 1; };
c._redoGeometry = () => { redoCalls += 1; };
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'я', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.layoutIndependentUndo = undoCalls === 1;
const editorInput = sr().querySelector('.editor-secondary input');
editorInput.dispatchEvent(new KeyboardEvent('keydown', {
key: 'я', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.inputKeepsNativeUndo = undoCalls === 1;
// QWERTZ swaps the physical Y/Z codes. The labelled key wins when it is a
// Latin letter; otherwise both isZ/isY became true and Redo ran first.
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyY', ctrlKey: true, bubbles: true, composed: true,
}));
out.qwertzCtrlZIsUndo = undoCalls === 2 && redoCalls === 0;
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'y', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.qwertzCtrlYIsRedo = undoCalls === 2 && redoCalls === 1;
// On AZERTY the physical KeyZ carries W: Ctrl+W must remain the browser's
// close-tab shortcut, never an editor Undo.
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'w', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.azertyCtrlWIsNotUndo = undoCalls === 2 && redoCalls === 1;
c._undoGeometry = realUndo;
c._redoGeometry = realRedo;
// Ctrl+click closes a valid draft without adding the click as a new point;
// the same gesture must refuse a draft with fewer than two existing edges.
const savedPath = c._path;
const savedRoomDialog = c._roomDialog;
const savedNameSel = c._nameSel;
const savedAreaSel = c._areaSel;
const ctrlClick = () => {
let prevented = false;
c._markupClick({
ctrlKey: true, metaKey: false, clientX: 0, clientY: 0,
preventDefault() { prevented = true; }, composedPath() { return []; },
});
return prevented;
};
c._tool = 'draw';
c._roomDialog = false;
c._path = [[-120, -120], [-80, -120]];
const shortBefore = JSON.stringify(c._path);
out.ctrlCloseRequiresTwoEdges = ctrlClick()
&& !c._roomDialog && JSON.stringify(c._path) === shortBefore;
c._path = [[-120, -120], [-80, -120], [-80, -80]];
out.ctrlCloseValidContour = ctrlClick() && c._roomDialog
&& c._path.length === 4
&& c._path[0][0] === c._path[3][0] && c._path[0][1] === c._path[3][1];
await c.updateComplete;
const roomDialog = sr().querySelector('hp-dialog.roomdialog');
const roomBody = roomDialog?.querySelector('.body');
out.roomDialogUsesMediumWidth = roomDialog?.wide === true
&& roomDialog.hasAttribute('wide');
out.roomDialogHasNoHorizontalScroll = !!roomBody
&& roomBody.scrollWidth <= roomBody.clientWidth + 1;
c._nameSel = 'No-area room';
c._areaSel = '';
c.requestUpdate();
await c.updateComplete;
const noAreaSave = sr().querySelector('hp-dialog.roomdialog .room-save');
out.noAreaUsesRegularSave = !!noAreaSave && !noAreaSave.disabled
&& sr().querySelectorAll('hp-dialog.roomdialog .room-save').length === 1;
c._path = savedPath;
c._roomDialog = savedRoomDialog;
c._nameSel = savedNameSel;
c._areaSel = savedAreaSel;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
+98
View File
@@ -0,0 +1,98 @@
// Browser contract for saved room drafts, independent partitions and columns.
// This deliberately exercises the card's editor transactions rather than
// injecting finished records: create, drag, edit and Delete must all pass
// through the same paths a pointer/keyboard gesture uses.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 820 }, 1);
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const PITCH = 1000 / 240;
window.confirm = () => true;
c._serverCfg = {
spaces: [{ id: 'physical', title: 'Physical', cell_cm: 5,
view_box: [0, 0, 1, 1], rooms: [] }],
markers: [], settings: {},
};
c._space = 'physical';
c._mode = 'plan';
c._modelCache = null;
// 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(); };
await c.updateComplete;
c._tool = 'partition';
c._drawWallField = '15';
c._partitionClick([100, 100], false);
c._partitionClick([300, 100], false);
let sp = c._serverCfg.spaces[0];
o.partitionCreated = sp.partitions?.length === 1 && sp.partitions[0].cm === 15;
c._tool = 'column';
c._drawWallField = '30';
c._columnClick([200, 200]);
sp = c._serverCfg.spaces[0];
o.columnCreatedSquare = sp.wall_columns?.length === 1
&& sp.wall_columns[0].shape === 'square' && sp.wall_columns[0].cm === 30;
c._tool = 'draw';
c._drawWallField = '12';
c._path = [[100, 300], [200, 300]];
c._activeDraftId = null;
c._draftSegmentCms = [];
c._persistActiveDraftSegment();
sp = c._serverCfg.spaces[0];
o.draftPersistedImmediately = sp.room_drafts?.length === 1
&& sp.room_drafts[0].segments[0].cm === 12;
// Endpoint-to-endpoint join is one atomic record: the active id survives
// and segment thickness follows geometric order.
sp.room_drafts = [
{ id: 'join-a', points: [[0.1, 0.4], [0.3, 0.4]], segments: [{ cm: 10 }] },
{ id: 'join-b', points: [[0.3, 0.4], [0.5, 0.4]], segments: [{ cm: 20 }] },
];
c._modelCache = null;
c._activeDraftId = 'join-a';
c._path = [[100, 400], [300, 400]];
c._draftSegmentCms = [10];
const join = c._draftEndAt([300, 400], 'join-a');
if (join) c._mergeDraftEndpoint(join);
sp = c._serverCfg.spaces[0];
o.draftEndpointJoinIsAtomic = sp.room_drafts?.length === 1
&& sp.room_drafts[0].id === 'join-a'
&& JSON.stringify(sp.room_drafts[0].segments.map((segment) => segment.cm)) === '[10,20]';
const part = c._spaceModel().partitions[0];
c._physicalDrag = {
pid: 71, kind: 'partition', id: part.id, start: [...part.a],
startClient: [0, 0], before: c._geometrySnapshot(), moved: true,
base: JSON.parse(JSON.stringify(part)), delta: [PITCH, PITCH],
};
c._physicalUp(new PointerEvent('pointerup', { pointerId: 71, bubbles: true }));
sp = c._serverCfg.spaces[0];
o.partitionDragIsRigidAndGridBound = Math.abs(sp.partitions[0].a[0] - (0.1 + PITCH / 1000)) < 1e-9
&& Math.abs((sp.partitions[0].b[0] - sp.partitions[0].a[0]) - 0.2) < 1e-9
&& Math.abs(sp.partitions[0].a[1] - sp.partitions[0].b[1]) < 1e-9;
c._physicalSel = { kind: 'partition', id: sp.partitions[0].id };
c._deletePhysicalSelection();
o.deleteRemovesPartition = !c._serverCfg.spaces[0].partitions;
const draftId = c._serverCfg.spaces[0].room_drafts[0].id;
c._physicalSel = { kind: 'draft', id: draftId, segment: 0 };
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);
o.invalidThicknessCreatesNothing = !c._serverCfg.spaces[0].partitions;
return o;
});
await finish(browser, checkAll(out));
+49 -2
View File
@@ -72,8 +72,38 @@ const res = await page.evaluate(async () => {
out.washerIsInThePalette = !!item('washer');
const prevD = item('sofa')?.querySelector('svg.furnprev path')?.getAttribute('d') || '';
out.previewIsDrawn = prevD.length > 10;
const previewEl = item('sofa')?.querySelector('svg.furnprev');
const previewRect = previewEl?.getBoundingClientRect();
const itemRect = item('sofa')?.getBoundingClientRect();
out.previewStaysInsideItsButton = !!previewRect && !!itemRect
&& getComputedStyle(previewEl).position !== 'absolute'
&& previewRect.width <= 42 && previewRect.height <= 42
&& previewRect.left >= itemRect.left - 1 && previewRect.right <= itemRect.right + 1
&& previewRect.top >= itemRect.top - 1 && previewRect.bottom <= itemRect.bottom + 1;
out.namesAreLocalised = /\S/.test(item('sofa')?.querySelector('span')?.textContent || '');
// An unarmed explicit palette closes on the first press anywhere outside
// the card, consumes only that press/click pair, and returns to Select.
const outside = document.createElement('button');
const unrelated = document.createElement('button');
let outsideClicks = 0, unrelatedClicks = 0;
outside.addEventListener('click', () => outsideClicks++);
unrelated.addEventListener('click', () => unrelatedClicks++);
document.body.append(outside, unrelated);
outside.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, cancelable: true, pointerId: 71, pointerType: 'mouse',
}));
outside.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }));
unrelated.click();
await c.updateComplete;
out.paletteOutsideDismissIsConsumed = c._decorTool === 'select' && !pal() && outsideClicks === 0;
out.paletteDismissTailLeavesUnrelatedClick = unrelatedClicks === 1;
outside.remove(); unrelated.remove();
[...sr().querySelectorAll('.decorbar .dtool')]
.find((b) => /мебел|furnitur/i.test(b.textContent || ''))?.click();
await c.updateComplete;
out.paletteReopensAfterDismiss = c._decorTool === 'furniture' && !!pal();
// ================= 2. размер по умолчанию и поля =========================
pick('sofa'); await c.updateComplete;
out.pickArmsTheSymbol = c._furnPalette?.symbol === 'sofa';
@@ -205,8 +235,12 @@ const res = await page.evaluate(async () => {
ev('pointermove', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
await c.updateComplete;
const snapped = sofaNow();
out.sizeSnapsToTheGrid = near(((snapped.w * 1000) / PITCH) % 1, 0, 1e-6)
&& near(((snapped.h * 1000) / PITCH) % 1, 0, 1e-6);
const wCells = (snapped.w * 1000) / PITCH;
const hCells = (snapped.h * 1000) / PITCH;
// Normalised config is rounded to six decimals, so a mathematical cell 27
// may read back as 26.9999999. Compare with the nearest integer, not `% 1`.
out.sizeSnapsToTheGrid = near(wCells, Math.round(wCells), 1e-4)
&& near(hCells, Math.round(hCells), 1e-4);
ev('pointerup', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
await c.updateComplete;
out.cornerDragEnded = !c._dtDrag;
@@ -246,6 +280,13 @@ const res = await page.evaluate(async () => {
&& near(after.w, stored.w, 1e-12) && near(after.h, stored.h, 1e-12)
&& near(after.x, stored.x, 1e-12) && near(after.y, stored.y, 1e-12);
out.stillRenderedAfterRebuild = !!el(sofaId);
c._decorTool = 'select';
c._decorShapeDbl({ preventDefault() {}, stopPropagation() {} }, after);
await c.updateComplete;
out.objectDialogSelectsStoredSymbol = sr().querySelector(
'.body select.namein option:checked',
)?.value === stored.symbol;
c._decorShapeDialog = null;
// ================= 8. инертность и удаление ==============================
c._decorTool = 'furniture'; c._furnPalette = null; await c.updateComplete;
@@ -254,9 +295,15 @@ const res = await page.evaluate(async () => {
ev('pointerdown', stageEl(), 320, 320); // символ НЕ выбран
await c.updateComplete;
out.noSymbolNoStamp = c._decorList.length === n0;
out.unarmedCanvasDismissReturnsToSelect = c._decorTool === 'select' && !pal();
out.unarmedCanvasDismissLeavesNoClickSuppression = c._suppressClick === false;
c._decorTool = 'erase'; await c.updateComplete;
ev('pointerdown', el(sofaId), 300, 300);
await c.updateComplete;
// Eraser is intentionally two-step now; the generic decor smoke verifies
// the dialog itself, while this furniture scenario confirms the action.
c._confirmDecorErase();
await c.updateComplete;
out.eraseRemovesIt = !!sofaId && !c._decorList.some((s) => s.id === sofaId);
// а в режиме просмотра мебель видна и не кликается
c._setMode('view'); await c.updateComplete; await sleep(30);
+6 -3
View File
@@ -21,8 +21,11 @@ const res = await page.evaluate(async () => {
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
settings: { ...(s.settings||{}), show_borders: true, fill_mode: 'light' } })) };
c.requestUpdate(); await c.updateComplete;
const styles = [...sr().querySelectorAll('.room.styled')].map((r) => r.getAttribute('style') || '');
out.customFillUsed = styles.some((st) => st.includes('#ff00ff') && st.includes('0.500'));
const styledRooms = [...sr().querySelectorAll('.room.styled')];
out.customFillUsed = styledRooms.some((room) => (
room.style.getPropertyValue('--room-fill').trim() === '#ff00ff'
&& Math.abs(Number(room.style.getPropertyValue('--room-fill-op')) - 0.5) < 1e-9
));
// 4) show_lqi=false у пространства скрывает LQI-бейджи
out.lqiBefore = sr().querySelectorAll('.dev .lqi').length;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
@@ -42,7 +45,7 @@ const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[
check('bundleVersionFound', typeof BUNDLE_VERSION === 'string' && BUNDLE_VERSION.length > 0);
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"rows": 16, // 11 цветов (включая wall_fill) + радиус свечения + фон + погода (docs/SUN.md)
"rows": 15, // 11 цветов (включая wall_fill) + радиус свечения + фон
// + «Оптимизировать планы» (docs/CANVAS.md §9)
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Walls", "Stage background", "Sun", "Plan maintenance", "About"],
"aboutVersion": `Houseplan Card v${BUNDLE_VERSION}`, // та же константа, что в баннере
+9 -1
View File
@@ -2,7 +2,8 @@
// 1. every element placed or dragged by hand lands on a node — devices, room
// labels, decor, room vertices, resize handles; an opening lands ON ITS
// WALL at a whole number of steps along it; positional off-grid placement
// is not supported, including while Shift is held;
// is not supported. Shift keeps the room-outline point on the grid while
// additionally constraining its wall to the nearest 45° direction;
// 2. the explicit «Оптимизировать планы» action moves a deliberately
// detuned plan onto the grid through one atomic config+layout commit,
// tells the truth about how much it moves, and is idempotent.
@@ -146,6 +147,13 @@ const out = await page.evaluate(async () => {
c._tool = 'draw'; c._path = [];
c._markupClick(at(220 + OFF, 220 + OFF, 'click'));
o.roomVertexOnANode = c._path.length === 1 && onGridR(c._path[0][0]) && onGridR(c._path[0][1]);
c._path = [[200, 200]];
c._markupClick(at(240 + OFF, 310 + OFF, 'click', { shiftKey: true }));
const locked = c._path[1];
const ldx = Math.abs(locked[0] - 200), ldy = Math.abs(locked[1] - 200);
o.shiftLocksRoomWallTo45 = c._path.length === 2
&& (ldx < 1e-7 || ldy < 1e-7 || Math.abs(ldx - ldy) < 1e-7)
&& onGridR(locked[0]) && onGridR(locked[1]);
c._path = []; c._tool = 'draw';
// ---- 2) «Оптимизировать планы» -----------------------------------------
+43 -6
View File
@@ -49,11 +49,11 @@ Object.assign(out, await page.evaluate(async () => {
// тумблер локальный: конфиг не трогается
o.toggleIsLocal = c._serverCfg.settings.show_all === undefined;
// --- галка в диалоге у авто-устройства, кнопки «Удалить» нет -----------
// --- авто-устройство тоже можно полностью удалить с плана --------------
const ghost = c._devices.find((x) => x.id === d.id);
c._openMarkerDialog(ghost); await c.updateComplete;
o.checkboxOn = c._markerDialog?.hideFromPlan === true;
o.noDeleteForAuto = !sr().querySelector('hp-dialog .btn.danger');
o.deleteForAuto = !!sr().querySelector('hp-dialog .btn.danger');
// снимаем галку и сохраняем — маркер остаётся с hidden:false (анти-повтор)
c._markerDialog = { ...c._markerDialog, hideFromPlan: false };
await c._saveMarker(); await c.updateComplete;
@@ -62,13 +62,50 @@ Object.assign(out, await page.evaluate(async () => {
const back = c._devices.find((x) => x.id === d.id) || c._devices.find((x) => x.bindingRef === d.bindingRef);
o.deviceVisibleAgain = !!back && !back.hidden;
// --- у виртуального кнопка «Удалить» есть -------------------------------
// --- у виртуального кнопка «Удалить» есть; удаляется только выбранный ---
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'Тест', binding: 'virtual' };
c._markerDialog = { ...c._markerDialog, name: 'delete-one-a', binding: 'virtual' };
await c._saveMarker(); await c.updateComplete;
const virt = c._devices.find((x) => x.virtual);
c._openMarkerDialog(virt); await c.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 2));
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'delete-one-b', binding: 'virtual' };
await c._saveMarker(); await c.updateComplete;
const virtualA = c._devices.find((x) => x.virtual && x.name === 'delete-one-a');
const virtualB = c._devices.find((x) => x.virtual && x.name === 'delete-one-b');
c._openMarkerDialog(virtualA); await c.updateComplete;
o.deleteForVirtual = !!sr().querySelector('hp-dialog .btn.danger');
const savedConfirm = window.confirm;
window.confirm = () => true;
await c._deleteMarker(); await c.updateComplete;
window.confirm = savedConfirm;
o.deleteOnlySelectedVirtual = !!virtualA && !!virtualB
&& !(c._serverCfg.markers || []).some((m) => m.id === virtualA.id)
&& (c._serverCfg.markers || []).some((m) => m.id === virtualB.id);
// A re-added HA binding replaces its tombstone and receives a fresh layout
// position instead of inheriting the deleted marker's last coordinates.
const readd = c._devices.find((x) => x.bindingKind === 'device' && !x.hidden);
const oldPos = { s: readd.space, x: 0.913, y: 0.917 };
c._layout = { ...c._layout, [readd.id]: oldPos };
c._openMarkerDialog(readd); await c.updateComplete;
window.confirm = () => true;
await c._deleteMarker(); await c.updateComplete;
window.confirm = savedConfirm;
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = {
...c._markerDialog,
bindingMode: 'ha',
binding: `device:${readd.bindingRef}`,
name: readd.name,
};
await c._saveMarker(); await c.updateComplete;
const rebound = (c._serverCfg.markers || [])
.filter((m) => m.binding === `device:${readd.bindingRef}`);
const fresh = c._layout[readd.id];
o.readdReplacesTombstone = rebound.length === 1 && rebound[0].removed !== true;
o.readdUsesFreshPosition = !!fresh
&& (Math.abs(fresh.x - oldPos.x) > 1e-6 || Math.abs(fresh.y - oldPos.y) > 1e-6);
c._markerDialog = null; c._setMode('view');
return o;
}));
+13 -6
View File
@@ -83,7 +83,7 @@ const res = await page.evaluate(async () => {
// Every editor still draws them. The dash deliberately reaches the physical
// centreline there, even if show_borders is off; only View hides it.
await mode('plan');
c._tool = 'openwall';
c._tool = 'boundary';
await upd();
out.openWallsVisibleInPlanEditor = n('.openwalls .openwall') > 0;
await mode('devices');
@@ -98,7 +98,7 @@ const res = await page.evaluate(async () => {
await upd();
const rows = [...sr().querySelectorAll('hp-dialog .srcrow')].map((r) => r.textContent.trim());
out.dialogOffersHideDecor = rows.some((t) => /декорат|decorative/i.test(t));
out.dialogOffersHideOpenings = rows.some((t) => /проём|проем|doors and windows/i.test(t));
out.dialogOffersHideOpenings = rows.some((t) => /проём|проем|openings|doors and windows/i.test(t));
out.dialogReadsOff = c._spaceDialog.hideDecor === false && c._spaceDialog.hideOpenings === false;
c._spaceDialog = { ...c._spaceDialog, hideDecor: true, hideOpenings: true };
await c._saveSpaceDialog();
@@ -165,12 +165,18 @@ checkAll({
resizeBeadIsAQuarter: rad.rszQuarter,
});
// ---- 7) the backdrop editor opens ON the picture tool ---------------------
// ---- 7) every Background session starts on Select; image has its own tool --
const armed = await page.evaluate(async () => {
const c = window.__card;
c._setMode('view'); await c.updateComplete;
c._setMode('decor'); await c.updateComplete;
const o = { tool: c._decorTool, movable: c._bdMovable, hasPicture: !!c._curSpaceCfg.plan_url };
const o = {
startsOnSelect: c._decorTool === 'select',
inertUntilPictureTool: !c._bdMovable,
hasPicture: !!c._curSpaceCfg.plan_url,
};
c._decorTool = 'backdrop'; c.requestUpdate(); await c.updateComplete;
o.movable = c._bdMovable;
// and the picture really moves under it: press inside the body, drag, release
const r = c._bdRect;
const before = { x: c._curSpaceCfg.plan_x ?? 0, y: c._curSpaceCfg.plan_y ?? 0 };
@@ -194,8 +200,9 @@ const armed = await page.evaluate(async () => {
return o;
});
checkAll({
backdropToolArmed: armed.tool === 'backdrop',
pictureMovableAtOnce: armed.movable,
backgroundStartsOnSelect: armed.startsOnSelect,
pictureInertUntilOwnTool: armed.inertUntilPictureTool,
pictureMovableUnderOwnTool: armed.movable,
dragMovedThePicture: armed.moved,
});
+1 -1
View File
@@ -61,7 +61,7 @@ out.cancelWhole = (await S()).rooms.length===5; // 4 базовых + rg
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28));
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625));
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5));
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._saveRoomNoArea();});
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._areaSel=''; c._saveRoom();});
s = await S();
out.splitRooms = s.rooms.length===6; // 4 базовых + rg разрезанная надвое
out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room');
+3 -1
View File
@@ -12,7 +12,9 @@ const st = () => page.evaluate(() => {
active: sr.querySelector('.modetab.active')?.textContent.trim(),
editBtns: sr.querySelectorAll('.head .btn:not(.zb)').length,
gears: sr.querySelectorAll('.tabedit').length,
markupBar: !!sr.querySelector('.editbar'),
// The last bar stays mounted for the exit animation; only an open chrome
// is an active/visible editor bar.
markupBar: !!sr.querySelector('.editorchrome.open .editbar'),
// hpsettle is the transient post-boot grace (AUD-1552-02), not a mode class
stageClass: sr.querySelector('.stage').className.replace(/ ?\bhpsettle\b/, ''),
};
+2 -1
View File
@@ -1,4 +1,4 @@
// Opening drag rulers (owner 2026-08-03): while a door/window is dragged along
// Opening drag rulers (owner 2026-08-03): while a door/window/gate is dragged along
// a wall, a measure badge sits on EACH shoulder (wall end -> opening edge,
// along the wall), and when the opening's center hits the wall's center a
// perpendicular dashed tick appears and the center magnet-snaps (Shift cannot
@@ -20,6 +20,7 @@ await page.evaluate(() => {
return c.updateComplete && true;
});
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
await page.waitForTimeout(220); // editor chrome transition changes stage coordinates
await settle();
const screenPt = (x, y) => page.evaluate(([x, y]) => {
+113
View File
@@ -0,0 +1,113 @@
/**
* Room-coloured tunnels in thick walls: door/window/gate, outer and shared
* walls, a hard split on the wall axis, hide/show policies and editor opacity.
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const result = await page.evaluate(async () => {
const c = window.__card;
const root = () => c.shadowRoot || c.renderRoot;
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
const update = async () => {
c._cfgEpoch++;
c.requestUpdate();
await c.updateComplete;
};
const pitch = 1 / 240;
const wallKey = (a, b) => {
const q = (v) => Math.round(v / pitch) * pitch;
let dx = b[0] - a[0], dy = b[1] - a[1];
const len = Math.hypot(dx, dy) || 1;
dx /= len; dy /= len;
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;
angle = Math.round(angle * 1800) / 1800;
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
};
const wall = (a, b, cm = 20) => ({ key: wallKey(a, b), a, b, cm });
sp.rooms = [
{ id: 'tunnel-left', name: 'Left', area: 'tunnel_left_area',
poly: [[0.1, 0.2], [0.5, 0.2], [0.5, 0.7], [0.1, 0.7]],
settings: { fill_mode: 'light' } },
{ id: 'tunnel-right', name: 'Right', area: 'tunnel_right_area',
poly: [[0.5, 0.2], [0.9, 0.2], [0.9, 0.7], [0.5, 0.7]],
settings: {} },
];
sp.walls = [
wall([0.1, 0.2], [0.5, 0.2]),
wall([0.5, 0.2], [0.5, 0.7]),
wall([0.5, 0.7], [0.9, 0.7]),
];
sp.openings = [
{ id: 'tunnel-window', type: 'window', x: 0.3, y: 0.2, angle: 0, length: 0.1 },
{ id: 'tunnel-door', type: 'door', x: 0.5, y: 0.45, angle: 90, length: 0.1 },
{ id: 'tunnel-gate', type: 'gate', x: 0.7, y: 0.7, angle: 0, length: 0.16 },
];
sp.settings = { ...(sp.settings || {}), show_borders: true, hide_openings: false, fill_mode: 'glow' };
c._serverCfg.settings = {
...(c._serverCfg.settings || {}),
fill_colors: {
...(c._serverCfg.settings?.fill_colors || {}),
light_none: { c: '#112233', a: 0.21 },
glow_base: { c: '#445566', a: 0.42 },
},
};
c._setMode('view');
await update();
const tunnels = () => [...root().querySelectorAll('[data-hp="opening-tunnel"]')];
const byId = (id) => root().querySelector(`[data-hp="opening-tunnel"][data-id="${id}"]`);
const out = {};
out.allThreeTypes = tunnels().length === 3;
out.windowRepeatsColdRoom = byId('tunnel-window')?.getAttribute('fill') === '#112233'
&& (root().querySelector('[data-hp="room"][data-id="tunnel-left"]')?.getAttribute('style') || '')
.includes('--room-fill:#112233');
out.gateRepeatsHotRoom = byId('tunnel-gate')?.getAttribute('fill') === '#445566'
&& (root().querySelector('[data-hp="room"][data-id="tunnel-right"]')?.getAttribute('style') || '')
.includes('--room-fill:#445566');
const shared = byId('tunnel-door');
const stops = [...(shared?.querySelectorAll('stop') || [])].map((s) => ({
offset: s.getAttribute('offset'), color: s.getAttribute('stop-color'),
opacity: s.getAttribute('stop-opacity'),
}));
out.sharedUsesOneHardGradient = !!shared?.querySelector('linearGradient') && stops.length === 4;
out.sharedStopsOnWallAxis = stops[1]?.offset === '50.000000%'
&& stops[2]?.offset === '50.000000%';
out.sharedCarriesBothRooms = new Set(stops.map((s) => s.color)).has('#112233')
&& new Set(stops.map((s) => s.color)).has('#445566');
const svgEl = root().querySelector('.stage svg');
const tunnelGroup = root().querySelector('.opening-tunnels');
const wallGroup = root().querySelector('.wallbodies');
out.layerBeforeWall = !!svgEl && !!tunnelGroup && !!wallGroup
&& !!(tunnelGroup.compareDocumentPosition(wallGroup) & Node.DOCUMENT_POSITION_FOLLOWING);
sp.settings.hide_openings = true;
await update();
out.hideSymbolKeepsTunnel = root().querySelectorAll('[data-hp="opening"]').length === 0
&& tunnels().length === 3;
sp.settings.show_borders = false;
await update();
out.hiddenBordersKeepTunnel = root().querySelectorAll('.wallbodies').length === 0
&& tunnels().length === 3;
c._setMode('decor');
await update();
out.backdropUsesSingleGroupOpacity = tunnels().length === 3
&& getComputedStyle(root().querySelector('.opening-tunnels')).opacity === '0.35'
&& byId('tunnel-window')?.getAttribute('fill-opacity') === '0.21';
c._setMode('plan');
await update();
out.planGestureCannotLeaveStalePatch = tunnels().length === 0;
return out;
});
checkAll(result);
await finish(browser, result);
+19 -19
View File
@@ -4,17 +4,21 @@ const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
c._setMode('plan'); c._tool = 'boundary'; await c.updateComplete;
// Shared wall r1|r2 at x=550, y from 140..460. Two-click open full shared stretch.
c._openWallClick([550, 140]);
c._openWallClick([550, 460]);
c._boundaryClick([550, 140]);
c._boundaryClick([550, 460]);
await c.updateComplete;
const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1');
const r2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
out.linked = (r1.open_to || []).includes('r2') && (r2.open_to || []).includes('r1');
out.hasSpans = Array.isArray(c._curSpaceCfg.open_spans) && c._curSpaceCfg.open_spans.length > 0;
out.dashes = sr().querySelectorAll('.openwall').length > 0;
out.hotClass = !!sr().querySelector('.openwalls.hot');
const boundaryButtons = [...sr().querySelectorAll('.editbar button')]
.filter((b) => /Boundary|Граница/.test(b.textContent || ''));
out.oneBoundaryTool = boundaryButtons.length === 1;
out.boundaryToolUsesReviewedIcon = boundaryButtons[0]?.querySelector('ha-icon')
?.getAttribute('icon') === 'mdi:border-style';
// View: trimmed outlines + dash
c._setMode('view'); await c.updateComplete;
out.noedge = sr().querySelectorAll('.room.noedge').length >= 2;
@@ -35,19 +39,16 @@ const res = await page.evaluate(async () => {
return Math.abs(x1 - 550) < 0.5 && Math.abs(x2 - 550) < 0.5
&& Math.min(y1, y2) < midY && Math.max(y1, y2) > midY;
});
c._tool = 'openwall';
c._openWallClick([550, 300]); await c.updateComplete;
out.openToolCannotClose = (c._curSpaceCfg.open_spans || []).length > 0;
c._tool = 'closewall'; await c.updateComplete;
// Closing is a separate, single-purpose action (UX-02).
c._closeWallClick([550, 300]); await c.updateComplete;
c._tool = 'boundary';
c._boundaryClick([550, 300]); await c.updateComplete;
out.restoredBySameTool = !(c._curSpaceCfg.open_spans || []).length;
out.toggledOff = !(c._curSpaceCfg.rooms.find((r) => r.id === 'r1').open_to || []).includes('r2');
out.dashesGone = sr().querySelectorAll('.openwall').length === 0;
c._closeWallClick([100, 100]);
c._boundaryClick([100, 100]);
out.missToast = !!c._toast;
// reopen for glow
c._openWallClick([550, 140]);
c._openWallClick([550, 460]);
c._boundaryClick([550, 140]);
c._boundaryClick([550, 460]);
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
@@ -64,20 +65,19 @@ const res = await page.evaluate(async () => {
rr2.open_to = [...(rr2.open_to || []), 'r3'];
rr3.open_to = [...(rr3.open_to || []), 'r2'];
// legacy open_to without spans still expands on read for cuts — force spans for r2-r3
c._openWallClick = c._openWallClick.bind(c);
c._setMode('plan'); c._tool = 'openwall';
c._setMode('plan'); c._tool = 'boundary';
// shared r2|r3 at y=460, x 550..960
c._openWallClick([700, 460]);
c._openWallClick([900, 460]);
c._boundaryClick([700, 460]);
c._boundaryClick([900, 460]);
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
const clip2 = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
out.transitive = clip2 ? clip2.querySelectorAll('path').length >= 3 : false;
} else out.transitive = 'no r3';
// outer wall refuses open
c._setMode('plan'); c._tool = 'openwall';
c._setMode('plan'); c._tool = 'boundary';
c._toast = null;
c._openWallClick([40, 300]);
c._boundaryClick([40, 300]);
out.outerToast = !!(c._toast && String(c._toast).length);
return out;
});
+71 -16
View File
@@ -5,34 +5,89 @@ const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const stage = () => sr().querySelector('.stage');
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
c._setMode('plan'); c._tool = 'boundary'; await c.updateComplete;
const H = 1000;
// 1) idle: crosshair (draw-like), no preview
// A pure touch tap has no hover/pointermove. P1 must still be visible, and a
// pen uses the same coarse tolerances as touch.
c._boundaryPointerType = 'touch';
c._cursorPt = null;
c._boundaryClick([550, 200]); await c.updateComplete;
out.touchTapShowsAnchor = !!c._openWallAnchor && !!sr().querySelector('.boundary-point');
c._openWallAnchor = null;
c._boundaryPointerType = 'pen';
out.penUsesCoarseTolerance = Math.abs(c._boundaryTolerances().hit - c._cssPxToRender(22)) < 1e-6;
c._boundaryPointerType = 'mouse';
// The semantic hit width remains exactly 12 CSS px at both view scales.
const sp = c._curSpaceCfg;
const originalWalls = sp.walls;
delete sp.walls;
c._cfgEpoch++; c._boundaryTargetMemo = null;
const originalView = c._view ? { ...c._view } : null;
const baseView = c._viewOr(c._baseVb());
const atCssOffset = (px) => c._boundaryTargetAt([
550 + px * c._cssPxToRender(1), 300,
]).kind;
const fineAtBase = [atCssOffset(11), atCssOffset(13)];
c._view = {
x: baseView.x + baseView.w / 4, y: baseView.y + baseView.h / 4,
w: baseView.w / 2, h: baseView.h / 2,
};
const fineAtZoom = [atCssOffset(11), atCssOffset(13)];
out.cssPixelToleranceSurvivesZoom = fineAtBase[0] === 'shared' && fineAtBase[1] === 'none'
&& fineAtZoom[0] === 'shared' && fineAtZoom[1] === 'none';
c._view = originalView;
if (originalWalls) sp.walls = originalWalls;
else delete sp.walls;
c._cfgEpoch++; c._boundaryTargetMemo = null;
// Independent masonry is neutral away from room boundaries, but blocks a
// shared boundary directly below it.
const originalPartitions = sp.partitions;
sp.partitions = [...(originalPartitions || []),
{ id: 'smoke-center-partition', a: [0.2, 0.3], b: [0.4, 0.3], cm: 15 }];
c._cfgEpoch++; c._modelCache = null; c._boundaryTargetMemo = null;
out.barePartitionIsNeutral = c._boundaryTargetAt([300, 300]).kind === 'none';
c._toast = '';
c._boundaryClick([300, 300]);
out.barePartitionUsesBoundaryMessage = c._toast === c._t('toast.openwall_pick');
sp.partitions = [...(originalPartitions || []),
{ id: 'smoke-boundary-partition', a: [0.55, 0.14], b: [0.55, 0.46], cm: 15 }];
c._cfgEpoch++; c._modelCache = null; c._boundaryTargetMemo = null;
out.partitionAboveBoundaryBlocks = c._boundaryTargetAt([550, 300]).kind === 'blocked';
if (originalPartitions) sp.partitions = originalPartitions;
else delete sp.partitions;
c._cfgEpoch++; c._modelCache = null; c._boundaryTargetMemo = null;
// 1) idle: neutral until a semantic target is under the pointer
c._cursorPt = null; c.requestUpdate(); await c.updateComplete;
out.idleCursor = getComputedStyle(stage()).cursor === 'crosshair';
out.idleCursor = getComputedStyle(stage()).cursor === 'default';
out.noPreview = !sr().querySelector('.openwall-preview');
// 2) hover shared solid wall: still NO full-edge preview (click target must stay clear)
// 2) solid shared wall: local anchor marker, never a full-edge preview
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
out.hoverNoFullEdge = !sr().querySelector('.openwall-preview');
out.hoverLocalMarker = !!sr().querySelector('.boundary-point') && !sr().querySelector('.openwall-preview');
out.hoverCursor = getComputedStyle(stage()).cursor === 'crosshair';
// 3) Opening and closing are separate tools: openwall does not advertise a
// close action, while closewall highlights the exact virtual stretch.
c._openWallClick([550, 140]);
c._openWallClick([550, 460]);
// 3) The same tool predicts the full body that one click will restore.
c._boundaryClick([550, 140]);
c._boundaryClick([550, 460]);
await c.updateComplete;
c._cursorPt = [550, 300]; c.requestUpdate(); await c.updateComplete;
out.openToolDoesNotOfferClose = !sr().querySelector('.openwall-preview');
c._tool = 'closewall'; c.requestUpdate(); await c.updateComplete;
const prev = sr().querySelector('.openwall-preview');
out.closeToolWillClose = prev?.classList.contains('willclose') === true;
out.sameToolWillRestore = prev?.classList.contains('boundary-restore') === true;
out.restoreCursor = getComputedStyle(stage()).cursor === 'pointer';
const planClosedOpenSpan = c._planClosedOpenSpan.bind(c);
let previewPlans = 0;
c._planClosedOpenSpan = (...args) => { previewPlans++; return planClosedOpenSpan(...args); };
c._boundaryPreviewMemo = null;
void c._boundaryPreview; void c._boundaryPreview; void c._boundaryPreview;
c._planClosedOpenSpan = planClosedOpenSpan;
out.restorePreviewIsMemoized = previewPlans === 1;
// 4) with anchor set: rubber-band preview appears
c._closeWallClick([550, 300]);
c._boundaryClick([550, 300]);
await c.updateComplete;
c._tool = 'openwall';
c._openWallClick([550, 200]);
c._boundaryClick([550, 300]);
out.samePlaceSecondTapDoesNotRearm = !c._openWallAnchor;
c._boundaryClick([550, 200]);
c._cursorPt = [550, 400]; c.requestUpdate(); await c.updateComplete;
const band = sr().querySelector('.openwall-preview');
out.anchorPreview = !!band && !band.classList.contains('willclose');
out.anchorPreview = !!band && band.classList.contains('boundary-range');
c._openWallAnchor = null;
return out;
});
+1 -1
View File
@@ -49,7 +49,7 @@ Object.assign(out, await page.evaluate(async () => {
};
await inMode('view', async () => { c._setMode('view'); });
for (const tool of ['draw', 'merge', 'split', 'resize', 'opening', 'openwall', 'closewall', 'delroom']) {
for (const tool of ['draw', 'merge', 'split', 'resize', 'opening', 'boundary', 'delroom']) {
await inMode('plan_' + tool, async () => { c._setMode('plan'); c._tool = tool; });
}
await inMode('devices', async () => { c._setMode('devices'); });
+17
View File
@@ -48,6 +48,23 @@ const rec = await page.evaluate(async () => {
out.noHpbootClass = !!stage && !stage.classList.contains('hpboot');
out.veilGone = !sr().querySelector('.bootveil');
// A detach during resume/nav motion kills their timers. The transient state
// must die with the timers, otherwise the same element comes back veiled or
// permanently clipped by nav-enter.
c._resumeSettling = true;
c._navMotion = 'enter';
c._slide = 'left';
c.requestUpdate(); await c.updateComplete;
c.remove();
out.detachClearsResumeAndNav = c._resumeSettling === false
&& c._navMotion === '' && c._slide === '';
wrap.appendChild(c);
await c.updateComplete;
const stageAfterMotionReattach = sr().querySelector('.stage');
out.reattachHasNoStuckMotionClasses = !!stageAfterMotionReattach
&& !stageAfterMotionReattach.classList.contains('hpnav')
&& !stageAfterMotionReattach.classList.contains('hpresume');
// A2: a second detach DURING the fade must not leave a zombie veil either
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const c2 = document.createElement('houseplan-card');
+10 -6
View File
@@ -21,12 +21,16 @@ const restore = () => page.evaluate((s) => {
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
}, snap);
const enter = (tool) => page.evaluate((t) => {
const c = window.__card;
if (!c._markup) c._setMode('plan');
c._tool = t; c._rszSel = null; c.requestUpdate();
return c.updateComplete && true;
}, tool);
const enter = async (tool) => {
const changed = await page.evaluate((t) => {
const c = window.__card;
const changed = !c._markup;
if (changed) c._setMode('plan');
c._tool = t; c._rszSel = null; c.requestUpdate();
return Promise.resolve(c.updateComplete).then(() => changed);
}, tool);
if (changed) await page.waitForTimeout(220);
};
const settle = () => page.evaluate(() => new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res))));
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const c = window.__card;
+11 -3
View File
@@ -34,8 +34,8 @@ const res = await page.evaluate(async () => {
// The two-click rubber band used to be below .wallbodies and disappeared
// exactly where it crossed the real T receiver.
c._tool = 'openwall';
c._openWallClick([550, 200]);
c._tool = 'boundary';
c._boundaryClick([550, 200]);
c._cursorPt = [550, 300];
await upd();
const bodyGroup = sr().querySelector('.wallbodies');
@@ -44,7 +44,7 @@ const res = await page.evaluate(async () => {
out.virtualPreviewAboveReal = !!bodyGroup && !!virtualGroup
&& !!(bodyGroup.compareDocumentPosition(virtualGroup) & Node.DOCUMENT_POSITION_FOLLOWING);
c._openWallClick([550, 300]);
c._boundaryClick([550, 300]);
c._cursorPt = null;
await upd();
out.partialSpanCreated = (sp().open_spans || []).length === 1;
@@ -58,6 +58,14 @@ const res = await page.evaluate(async () => {
const viewVirtual = sr().querySelector('.openwalls');
out.savedVirtualBelowRealInView = !!viewBody && !!viewVirtual
&& !!(viewVirtual.compareDocumentPosition(viewBody) & Node.DOCUMENT_POSITION_FOLLOWING);
c._setMode('decor');
await upd();
const decorBody = sr().querySelector('.wallbodies');
const decorVirtual = sr().querySelector('.openwalls');
out.realWallsFadeBehindDecor = !!decorBody
&& getComputedStyle(decorBody).opacity === '0.35';
out.virtualWallsFadeBehindDecor = !!decorVirtual
&& getComputedStyle(decorVirtual).opacity === '0.35';
c._setMode('plan');
await upd();
out.atomicThicknessBefore = c._intervalCm([550, 145, 550, 195]) === 20
+10 -6
View File
@@ -14,12 +14,16 @@ const restore = () => page.evaluate((s) => {
return c.updateComplete && true;
}, snap);
const enter = (tool) => page.evaluate((t) => {
const c = window.__card;
if (!c._markup) c._setMode('plan');
c._tool = t; c._rszSel = null; c.requestUpdate();
return c.updateComplete && true;
}, tool);
const enter = async (tool) => {
const changed = await page.evaluate((t) => {
const c = window.__card;
const changed = !c._markup;
if (changed) c._setMode('plan');
c._tool = t; c._rszSel = null; c.requestUpdate();
return Promise.resolve(c.updateComplete).then(() => changed);
}, tool);
if (changed) await page.waitForTimeout(220);
};
const handleCount = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.rszhandle').length);
const roomPolyN = (id) => page.evaluate((id) => {
+7 -2
View File
@@ -1,4 +1,4 @@
// Smoke: houseplan-space-card renders a static, non-interactive schematic + deep-link button.
// Smoke: houseplan-space-card renders a live, non-interactive schematic + deep-link button.
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
@@ -18,6 +18,7 @@ const res = await page.evaluate(async () => {
const stage = card.renderRoot.querySelector('.hp-static-stage');
const pe = stage ? getComputedStyle(stage).pointerEvents : null;
const markers = card.renderRoot.querySelectorAll('.hp-static-stage .devlayer .dev').length;
const litMarker = card.renderRoot.querySelector('.hp-static-stage .dev[data-id="d_light1"]');
const btn = card.renderRoot.querySelector('.hp-static-btn');
// deep-link: clicking the button pushes #space=<id>
@@ -35,6 +36,8 @@ const res = await page.evaluate(async () => {
return {
stagePointerEvents: pe,
markers,
litMarkerOn: !!litMarker?.classList.contains('on'),
sharedFacePresent: !!litMarker?.querySelector('ha-icon'),
hasButton: !!btn,
deepLink: pushed,
errorShown: !!errCard,
@@ -45,9 +48,11 @@ await browser.close();
const ok =
res.stagePointerEvents === 'none' &&
res.markers > 0 &&
res.litMarkerOn &&
res.sharedFacePresent &&
res.hasButton &&
typeof res.deepLink === 'string' && res.deepLink.includes('#space=') &&
res.errorShown;
console.log(JSON.stringify(res));
if (!ok) { console.error('FAIL space-card smoke'); process.exit(1); }
console.log('OK space-card: static (pointer-events:none), markers rendered, deep-link button, error card');
console.log('OK space-card: live shared marker face, pointer-events:none, deep-link button, error card');
+7 -2
View File
@@ -15,7 +15,12 @@ const res = await page.evaluate(async () => {
out.styled = sr().querySelectorAll('.room.styled').length;
out.labels = [...sr().querySelectorAll('.roomlabel')].map((l) => l.textContent.trim());
const liv = [...sr().querySelectorAll('.room.styled')][0];
out.livingStyle = liv.getAttribute('style');
out.livingStyle = {
stroke: liv.style.getPropertyValue('--room-stroke').trim(),
strokeOpacity: Number(liv.style.getPropertyValue('--room-stroke-op')),
fill: liv.style.getPropertyValue('--room-fill').trim(),
fillOpacity: Number(liv.style.getPropertyValue('--room-fill-op')),
};
// living: ceiling on → жёлтая; kitchen: нет light-сущностей → без заливки; bedroom light off → серая
const styles = [...sr().querySelectorAll('.room.styled')].map((r) => r.getAttribute('style'));
out.hasYellow = styles.some((s) => s.includes('#ffd45c'));
@@ -54,7 +59,7 @@ checkAll(res, {
"defaultLabels": 0,
"styled": 4,
"labels": ["Living room", "Kitchen", "Bedroom", "Hallway"],
"livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180",
"livingStyle": {"stroke": "#ff8800", "strokeOpacity": 0.8, "fill": "#ffd45c", "fillOpacity": 0.18},
"lqiFills": 0,
"atticSquare": true,
// fill_mode 'glow' — the default for NEW spaces since v1.54 (owner call).
+193
View File
@@ -0,0 +1,193 @@
// Always-static display contract across resolver, plan DOM, preview, static
// space card and live-vacuum overlays. The existing dynamic mode still reacts.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const card = window.__card;
const saved = card.hass;
const root = () => card.shadowRoot || card.renderRoot;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const update = async () => {
card.requestUpdate();
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const domFace = (node) => node && ({
classes: [...node.classList].filter((name) => [
'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') || '',
satellites: node.querySelectorAll('.tval,.hval,.lqi,.valtext').length,
scale: node.style.getPropertyValue('--dev-scale'),
angle: node.querySelector(':scope > ha-icon')?.style.transform || '',
});
const show = (device, states) => {
card.hass = {
...saved,
entities: {
...saved.entities,
...Object.fromEntries(Object.keys(states).map((entity_id) => [entity_id, {
entity_id, device_id: device.id, platform: 'demo', disabled_by: null,
}])),
},
states: { ...saved.states, ...states },
};
const presentation = card._devicePresentation(device);
card.hass = saved;
return {
classes: presentation.classes,
icon: presentation.icon,
status: presentation.visual.status,
activity: presentation.activity,
temp: presentation.tempText,
lqi: presentation.lqiText,
vacuumLive: presentation.vacuumLive,
};
};
const smoke = {
id: 'static-smoke', name: 'Smoke', icon: 'mdi:smoke-detector',
entities: ['binary_sensor.static_smoke'], primary: 'binary_sensor.static_smoke',
marker: {
binding: 'device:static-smoke', display: 'static_icon',
vacuum: { live: true },
},
};
const relay = {
// Reuse the harness registry entry: `_planHass` deliberately resolves
// against the acquired HA registry, so an invented entity would correctly
// be absent even if it were injected into `hass.states` for this frame.
id: 'd_kettle', name: 'Relay', icon: 'mdi:toggle-switch',
entities: ['switch.kettle'], primary: 'switch.kettle',
marker: { binding: 'device:d_kettle', display: 'badge' },
};
const smokeOn = show(smoke, {
'binary_sensor.static_smoke': {
state: 'on', attributes: { device_class: 'smoke', linkquality: 180 },
},
});
const smokeUnavailable = show(smoke, {
'binary_sensor.static_smoke': {
state: 'unavailable', attributes: { device_class: 'smoke', linkquality: 180 },
},
});
const relayOn = show(relay, {
'switch.kettle': { state: 'on', attributes: {} },
});
const markerId = 'static-vacuum';
card._serverCfg.markers.push({
id: markerId,
// Rebind the registered demo mower into f1. A made-up entity would be
// correctly absent from the authoritative HA registry and could not prove
// that the live-vacuum renderer itself was suppressed.
binding: 'device:d_mower',
space: 'f1',
area: 'living_room',
display: 'static_icon',
icon: 'mdi:robot-vacuum',
size: 1.4,
angle: 27,
vacuum: {
source: 'camera.static_robot_map',
live: true,
trail_mode: 'always',
calibration: { m1: [0.001, 0, 0, 0.001, 0, 0] },
},
});
card._layout[markerId] = { s: 'f1', x: 0.3, y: 0.3 };
card.hass = {
...saved,
states: {
...saved.states,
'vacuum.mower': {
entity_id: 'vacuum.mower', state: 'cleaning',
attributes: { friendly_name: 'Static robot' },
},
'camera.static_robot_map': {
entity_id: 'camera.static_robot_map', state: 'idle',
attributes: {
map_name: 'm1', vacuum_position: { x: 500, y: 500, a: 0 },
path: [[200, 200], [350, 350], [500, 500]],
},
},
},
};
card._regSignature = '';
card._cfgEpoch++;
card._setMode('view');
await update();
const planFace = domFace(root().querySelector(`.dev[data-id="${markerId}"]`));
const noPlanVacuumOverlay = !root().querySelector(`.vacpuck[data-mid="${markerId}"]`)
&& !root().querySelector('.vactrail');
card._setMode('devices');
const staticDevice = card._devices.find((item) => item.id === markerId);
card._openMarkerDialog(staticDevice);
await update();
const preview = root().querySelector('hp-device-preview');
await preview?.updateComplete;
const previewFace = domFace(preview?.renderRoot?.querySelector('.dev'));
const staticPreviewHasNoDemo = !preview?.renderRoot?.querySelector('.previewdemo');
const savedMarker = card._serverCfg.markers.find((marker) => marker.id === markerId);
savedMarker.display = 'badge';
card._markerDialog = null;
card._cfgEpoch++;
card._setMode('view');
card.hass = { ...card.hass };
await update();
const dynamicNode = root().querySelector(`.dev[data-id="${markerId}"]`);
const switchedBackToDynamic = dynamicNode?.classList.contains('on')
&& !dynamicNode?.classList.contains('static-icon');
const dynamicVacuumOverlayRestored = !!root().querySelector(`.vacpuck[data-mid="${markerId}"]`)
&& !!root().querySelector('.vactrail');
savedMarker.display = 'static_icon';
card._cfgEpoch++;
card.hass = { ...card.hass };
await update();
const returnedNode = root().querySelector(`.dev[data-id="${markerId}"]`);
const returnCreatesNoActivity = returnedNode?.classList.contains('static-icon')
&& ![...returnedNode.classList].some((name) => name.startsWith('activity-'));
const returnHidesVacuumOverlay = !root().querySelector(`.vacpuck[data-mid="${markerId}"]`)
&& !root().querySelector('.vactrail');
await customElements.whenDefined('houseplan-space-card');
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
staticCard.hass = card.hass;
document.body.appendChild(staticCard);
const started = Date.now();
while (!staticCard.renderRoot?.querySelector(`.dev[data-id="${markerId}"]`)
&& Date.now() - started < 6000) {
await wait(60);
}
await staticCard.updateComplete;
const staticCardFace = domFace(staticCard.renderRoot?.querySelector(`.dev[data-id="${markerId}"]`));
return {
staticAlarmNeutral: smokeOn.status === 'neutral'
&& smokeOn.activity === 'none'
&& smokeOn.classes.join(' ') === 'static-icon',
staticUnavailableSame: JSON.stringify(smokeOn) === JSON.stringify(smokeUnavailable),
staticSuppressesVacuum: smokeOn.vacuumLive === false,
liveSwitchStillDynamic: relayOn.status === 'working' && relayOn.classes.includes('on'),
planPreviewStaticParity: !!planFace && !!previewFace
&& planFace.classes.includes('static-icon')
&& previewFace.classes.includes('static-icon')
&& JSON.stringify(planFace) === JSON.stringify(previewFace),
planStaticCardParity: !!planFace && !!staticCardFace
&& planFace.classes.includes('static-icon')
&& staticCardFace.classes.includes('static-icon')
&& JSON.stringify(planFace) === JSON.stringify(staticCardFace),
noPlanVacuumOverlay,
staticPreviewHasNoDemo,
switchedBackToDynamic,
dynamicVacuumOverlayRestored,
returnCreatesNoActivity,
returnHidesVacuumOverlay,
};
});
await finish(browser, checkAll(out));
+12 -2
View File
@@ -4,7 +4,7 @@
// 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-карточке);
// 4) проём: data-hp="opening" + data-id + data-kind (door/window);
// 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;
// 7) «нет значения — нет атрибута»: у виртуального маркера нет data-entity,
@@ -75,14 +75,24 @@ const res = await page.evaluate(async () => {
c._curSpaceCfg.openings = [
{ id: 'op_d', type: 'door', x: 0.55, y: 0.3, angle: 90, length: 0.09 },
{ id: 'op_w', type: 'window', x: 0.3, y: 0.14, angle: 0, length: 0.12 },
// bottom wall of r1: the room is above it, so an exterior gate must open
// toward +Y. The two compact leaves therefore rotate +10° / -10°.
{ id: 'op_g', type: 'gate', x: 0.3, y: 0.58, angle: 0, length: 0.3 },
];
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
out.openingHookFindsBoth = qa('[data-hp="opening"]').length === 2;
out.openingHookFindsEveryType = qa('[data-hp="opening"]').length === 3;
const door = q('[data-hp="opening"][data-kind="door"]');
const win = q('[data-hp="opening"][data-kind="window"]');
const gate = q('[data-hp="opening"][data-kind="gate"]');
out.openingKindDoor = door?.getAttribute('data-id') === 'op_d';
out.openingKindWindow = win?.getAttribute('data-id') === 'op_w';
out.openingKindGate = gate?.getAttribute('data-id') === 'op_g';
out.openingKeepsItsClass = !!door && door.classList.contains('opening');
const gateLeaves = [...(gate?.querySelectorAll('.op-leaf') || [])];
out.gateHasTwoLeaves = gateLeaves.length === 2;
out.gateOpensOnlyTenDegreesOutward = gateLeaves[0]?.getAttribute('style')?.includes('rotate(10deg)')
&& gateLeaves[1]?.getAttribute('style')?.includes('rotate(-10deg)')
&& !gate?.querySelector('.op-arc');
// ============ 5. декор ============
c._curSpaceCfg.decor = [
+8 -10
View File
@@ -1,6 +1,6 @@
// Sun on the plan (docs/SUN.md): compass gating, window wedges on all four
// walls, direction follows north_deg, night is empty, wedges clip to rooms,
// day/night background vs static, per-space inheritance, cloud fading,
// day/night background vs static, per-space inheritance, weather independence,
// memoisation across hass ticks, editors stay clean.
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
@@ -124,19 +124,16 @@ const res = await page.evaluate(async () => {
delete sp.settings.north_deg;
touchCfg();
// 10) clouds: rain kills the wedges, cloudy only fades them
// 10) legacy weather settings and live weather states no longer participate
cfg().settings.weather_entity = 'weather.home';
touchCfg();
await setWeather('pouring');
await setSun(90, 5);
out.rainKillsRays = domPolys().length === 0;
const stopRain = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.rainDoesNotHideRays = domPolys().length > 0 && Math.abs(stopRain - 0.3) < 1e-6;
await setWeather('cloudy');
const stopCloudy = Number(sr().querySelector('.sunlayer ~ * stop, defs stop')?.getAttribute('stop-opacity')
|| sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.cloudyFades = domPolys().length > 0 && Math.abs(stopCloudy - 0.3 * 0.4) < 1e-6;
await setWeather('sunny');
const stopClear = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.clearFullAlpha = Math.abs(stopClear - 0.3) < 1e-6;
const stopCloudy = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.cloudsDoNotDimRays = domPolys().length > 0 && Math.abs(stopCloudy - 0.3) < 1e-6;
delete cfg().settings.weather_entity;
touchCfg();
@@ -185,7 +182,7 @@ const dlg = await page.evaluate(() => {
compassDragSets180: c._settingsDialog?.northDeg === 180,
dialogHasModeSelect: !!sr.querySelector('hp-dialog select.areasel'),
dialogHasRaysToggle: [...sr.querySelectorAll('hp-dialog .srcrow input[type=checkbox]')].length > 0,
dialogHasWeatherList: sr.querySelectorAll('#hp-weather-list option').length > 0,
dialogHasNoWeatherList: sr.querySelectorAll('#hp-weather-list option').length === 0,
dialogNumberMatches: sr.querySelector('.suncol input[type=number]')?.value === '180',
};
c._settingsDialog = null;
@@ -238,6 +235,7 @@ await page.evaluate(async () => {
c._setMode('plan'); c._tool = 'opening';
c.requestUpdate(); await c.updateComplete;
});
await page.waitForTimeout(220); // stage coordinates settle with editor chrome
await settle();
const [dx, dy] = await screenPt(960, 600);
const [, dty] = await screenPt(960, 700);
+21
View File
@@ -62,6 +62,27 @@ const res = await page.evaluate(async () => {
out.realStepNoSnapClass = !sr().querySelector('.stage.skysnap');
out.realStepStillGlides = comp() !== hexToRgb(target); // 45 s transition, not a jump
// A quick tab switch is not a suspended renderer: keep the current painted
// sky and hover in place. The old visibility handler cleared both on every
// return, producing a one-frame whole-plan flash even after two seconds.
const ownVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState');
const room = c._spaceModel().rooms[0];
c._hoverRoom = { space: c._space, room };
c.requestUpdate();
await c.updateComplete;
const skyBeforeQuickReturn = c._skyElev;
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
document.dispatchEvent(new Event('visibilitychange'));
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
document.dispatchEvent(new Event('visibilitychange'));
await c.updateComplete;
out.quickReturnKeepsSky = c._skyElev === skyBeforeQuickReturn;
out.quickReturnKeepsHover = c._hoverRoom?.room === room;
out.quickReturnHasNoSnapFrame = !stage().classList.contains('skysnap')
&& !stage().classList.contains('hpresume');
if (ownVisibilityState) Object.defineProperty(document, 'visibilityState', ownVisibilityState);
else delete document.visibilityState;
// the tab came back from the background: the sky catches up at once
await setSun(180, 25); // 12° of sun happened while we were not painting
out.returnFromHiddenSnaps = comp() === hexToRgb(stage().getAttribute('style').split('background:')[1]);
+3 -3
View File
@@ -182,17 +182,17 @@ const res = await page.evaluate(async (fadeMs) => {
await upd();
out.rimIsBackInViewMode = rimsOf().length === 2;
// ---- 5) a fully overcast sky takes the rim with the light --------------
// ---- 5) a legacy weather setting cannot hide the rim -------------------
cfg.settings = { ...cfg.settings, weather_entity: 'weather.home' };
c.hass = { ...c.hass, states: { ...c.hass.states,
'weather.home': { entity_id: 'weather.home', state: 'pouring', attributes: {} } } };
c._cfgEpoch++;
await upd();
out.noRimInTheRain = rimsOf().length === 0;
out.rimIgnoresRain = rimsOf().length === 2;
delete cfg.settings.weather_entity;
c._cfgEpoch++;
await upd();
out.rimIsBackInTheSun = rimsOf().length === 2;
out.rimStaysAfterLegacyCleanup = rimsOf().length === 2;
return out;
}, RAY_FADE_MS);
+4
View File
@@ -8,6 +8,10 @@ const out = await page.evaluate(async () => {
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
c.hass = { ...c.hass,
entities: { ...c.hass.entities,
'automation.evening': { entity_id: 'automation.evening', platform: 'automation', disabled_by: null },
'script.curtains': { entity_id: 'script.curtains', platform: 'script', disabled_by: null },
'scene.movie': { entity_id: 'scene.movie', platform: 'scene', disabled_by: null } },
states: { ...c.hass.states,
'automation.evening': { state: 'on', attributes: { friendly_name: 'Вечерний свет' } },
'script.curtains': { state: 'off', attributes: { friendly_name: 'Шторы' } },

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