mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a9122f41f | ||
|
|
25f43da1bd | ||
|
|
e7aca9c678 | ||
|
|
5ca4c7e5c5 | ||
|
|
e0f6746d7f | ||
|
|
d2bec266ed | ||
|
|
4868cc0786 | ||
|
|
e188f9d609 | ||
|
|
3ad4e9d803 | ||
|
|
7333223a55 | ||
|
|
de5e8129a1 | ||
|
|
b44d2fb958 | ||
|
|
ee87d3d00e | ||
|
|
a7d956a072 | ||
|
|
309bd59358 | ||
|
|
31cd4142ac | ||
|
|
cb2b064c6a | ||
|
|
e6579f1e55 | ||
|
|
1b37427f1d | ||
|
|
4c260f0ea0 | ||
|
|
159f4f43c0 | ||
|
|
99f7c3a4a9 | ||
|
|
f4e5ce6822 | ||
|
|
0ee80a6a52 | ||
|
|
1397a71f84 | ||
|
|
46f20fe4e1 | ||
|
|
1108b2bc14 | ||
|
|
2fd46493db | ||
|
|
df233905c5 | ||
|
|
85263d520f | ||
|
|
02ae7f9588 | ||
|
|
1dc03e1fb1 | ||
|
|
d21d532f10 | ||
|
|
50ba443492 | ||
|
|
aa01eaef01 | ||
|
|
93b60c5b8e | ||
|
|
eb17396006 | ||
|
|
0af50a74ae | ||
|
|
232c4807fd | ||
|
|
285d569102 | ||
|
|
22b588e116 | ||
|
|
1da1aba625 | ||
|
|
de53d530fa | ||
|
|
ade8daab16 | ||
|
|
05a2a838d6 | ||
|
|
f4ad843619 | ||
|
|
a7d58f0552 | ||
|
|
fd72330549 | ||
|
|
2c947f4f7a | ||
|
|
693601a8e0 | ||
|
|
c7fa9542ba | ||
|
|
478d2042b2 | ||
|
|
47ab60cddd | ||
|
|
79142d9334 | ||
|
|
bb4d4e1f6e | ||
|
|
b6675dc3a4 | ||
|
|
53e1c7163d | ||
|
|
ca66791440 | ||
|
|
db19753bbf |
@@ -4,11 +4,43 @@ on:
|
||||
types: [published]
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
jobs:
|
||||
build:
|
||||
# AUD-159B7-02: publishing a GitHub Release used to BE the gate — this
|
||||
# workflow only built and uploaded, so an asset shipped while both Validate
|
||||
# runs for the very same commit were red. The asset now waits for a green
|
||||
# Validate of the EXACT commit the tag points at, and is withheld otherwise.
|
||||
#
|
||||
# Needs a push with a token that has the `workflow` scope (the ordinary
|
||||
# Personal Access Token used for `git push` refuses workflow file updates).
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Require a green Validate for this exact commit
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# HEAD is the peeled commit even when TAG is annotated. Do not trust
|
||||
# target_commitish (it may be a branch name) or an event-context SHA.
|
||||
SHA=$(git rev-parse HEAD)
|
||||
echo "release tag: $TAG; exact commit: $SHA"
|
||||
node scripts/release-gate.mjs "$SHA"
|
||||
build:
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- run: npm ci && npm run build
|
||||
@@ -17,3 +49,29 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/houseplan-card.js
|
||||
hacs-discovery:
|
||||
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
|
||||
# sorting SemVer. A valid asset can therefore be invisible to beta users
|
||||
# (beta.10 appeared after beta.9). Keep the release asset, but
|
||||
# make that distribution failure impossible to miss in the release run.
|
||||
if: ${{ github.event.release.prerelease }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Verify the published tag is the prerelease HACS will discover
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const first = releases.find((r) => r.prerelease && !r.draft);
|
||||
const expected = context.payload.release.tag_name;
|
||||
if (first?.tag_name !== expected) {
|
||||
core.setFailed(
|
||||
`HACS prerelease discovery is stale: GitHub returns ${first?.tag_name ?? 'none'} before ${expected}. ` +
|
||||
`Use an rc/new version line or correct the release ordering before announcing the update.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ test-build/
|
||||
*.log
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv-backend/
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# AGENTS.md
|
||||
|
||||
House Plan is one HACS package with two parts plus a demo harness:
|
||||
|
||||
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to `dist/houseplan-card.js`.
|
||||
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
|
||||
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
|
||||
|
||||
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.
|
||||
|
||||
## 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`.
|
||||
- **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
|
||||
`C:\Users\Sergey\Downloads\dev\houseplan-dev` (workflow notes + often
|
||||
unpushed edits) is **not mounted** into managed Cloud Agent VMs. Do not
|
||||
expect to `ls` or diff that folder. To bring local work into the cloud
|
||||
agent: push a branch to GitHub and say its name, or run a **local** Cursor
|
||||
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`.
|
||||
@@ -27,10 +27,16 @@ right on your Lovelace dashboard.
|
||||
|
||||
**Feature highlights**
|
||||
|
||||
- ♾️ **An infinite canvas** — there is no "plan size" and no edge to run
|
||||
past: draw and place devices anywhere, pan at any zoom, zoom out to see
|
||||
everything, and let one tap fit the whole plan back on screen.
|
||||
- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
|
||||
virtual walls and a visual decor layer, all drawn with clicks; room resize
|
||||
by dragging walls, with live lengths and areas as you drag; smart
|
||||
alignment guides and a live ruler in real meters/feet.
|
||||
- 🖼 **A backdrop you can move and scale** — drag the floor-plan picture into
|
||||
place and pull a corner to size it, with its real size in metres shown as
|
||||
you drag, so the drawing and the photo of your plan finally line up.
|
||||
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
|
||||
whole groups of lights (works for dumb switches and stateless remotes too).
|
||||
- 🌒 **“Light sources” fill** — a dark house where every lit lamp casts a pool
|
||||
@@ -39,6 +45,9 @@ right on your Lovelace dashboard.
|
||||
the day (white noon → golden hour → deep night), while windows on exterior
|
||||
walls cast real wedges of sunlight into the rooms; optional cloud cover
|
||||
from a weather entity.
|
||||
- 🪟 **Curtains and blinds open on a tap** — one action opens, closes or
|
||||
stops a cover, and the icon itself morphs between open and closed while a
|
||||
soft ring pulses as it travels.
|
||||
- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
|
||||
comfort-range temperature fills, per-room signal heatmap.
|
||||
- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
|
||||
@@ -75,16 +84,26 @@ The integration consists of two parts that are installed together:
|
||||
|
||||
## How it differs from alternatives
|
||||
|
||||
A house plan in Home Assistant is usually built with `picture-elements`, `ha-floorplan` and similar solutions. There you have to write YAML by hand, calculate the coordinates of every icon, and edit the config again after every change. House Plan works differently:
|
||||
A house plan in Home Assistant is usually built with `picture-elements`,
|
||||
`ha-floorplan`, or newer GUI cards that draw walls and furniture in the
|
||||
dashboard. Those either lock you into YAML/SVG, or store the plan in the
|
||||
Lovelace card config. House Plan is a **shared live map** backed by a Home
|
||||
Assistant integration:
|
||||
|
||||
| | House Plan | Typical solutions (picture-elements / ha-floorplan) |
|
||||
|---|---|---|
|
||||
| **Setup** | Entirely through the UI, with the mouse | Manual YAML and code editing |
|
||||
| **Adding devices** | Automatic, by room | You type in every entity by hand |
|
||||
| **Icon coordinates** | Drag with the mouse | You count pixels and write them into the config |
|
||||
| **Room markup** | Built-in outline editor | You draw in an external SVG editor |
|
||||
| **Storage** | On the HA server (shared by all devices) | In the dashboard YAML |
|
||||
| **Zoom** | Smooth zoom, everything stays crisp (vector) | Usually a fixed image |
|
||||
| | House Plan | picture-elements / ha-floorplan | GUI draw cards (e.g. easy-floorplan) |
|
||||
|---|---|---|---|
|
||||
| **Setup** | Entirely through the UI, with the mouse | Manual YAML / Inkscape SVG | In-card drawing of walls & furniture |
|
||||
| **Adding devices** | Automatic, by HA **area** | You type every entity by hand | Place entities by hand on the drawing |
|
||||
| **Icon coordinates** | Drag with the mouse | Count pixels into YAML | Drag on the canvas |
|
||||
| **Room markup** | Built-in outline editor bound to areas | External SVG editor | Draw walls yourself (furniture CAD) |
|
||||
| **Storage** | On the HA server (`.storage`, shared, multi-client) | In the dashboard YAML | In the card / dashboard YAML |
|
||||
| **Overlays** | Glow, climate, LQI, sun, vacuums, kiosk | Whatever you script in SVG/CSS | Varies by card |
|
||||
| **Zoom** | Smooth vector zoom | Usually a fixed image | SVG / virtual canvas |
|
||||
|
||||
**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.
|
||||
|
||||
Key advantages in short:
|
||||
|
||||
@@ -179,7 +198,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
|
||||
|
||||

|
||||
|
||||
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 always square; an image keeps its own proportions and is centred inside it.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
@@ -271,6 +290,10 @@ turned the way it is mounted.
|
||||
|
||||

|
||||
|
||||
### Styling the plan with card-mod (advanced, unsupported)
|
||||
|
||||
The card ships finished and has no CSS field of its own — but if you already run [card-mod](https://github.com/thomasloven/lovelace-card-mod), every object on the plan now carries a stable hook you can aim at: `data-hp="device"` (plus `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. We promise not to rename them; we do not ship card-mod, do not support it, and are not responsible for what your CSS does to the card. The full table, the examples and the limits are in **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Uninstalling
|
||||
|
||||
+35
-12
@@ -6,6 +6,8 @@
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · **[Продуктовый аудит и план улучшений](docs/PRODUCT-IMPROVEMENT-PLAN.ru.md)**
|
||||
|
||||
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
|
||||
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
|
||||
своих местах: живые состояния, свет по клику, температура и влажность по
|
||||
@@ -25,6 +27,10 @@
|
||||
|
||||
**Главное**
|
||||
|
||||
- ♾️ **Бесконечный холст** — нет «размера плана» и нет края, за который
|
||||
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
|
||||
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
|
||||
план обратно в экран.
|
||||
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
|
||||
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
|
||||
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
|
||||
@@ -37,6 +43,9 @@
|
||||
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
|
||||
в комнаты настоящие клинья солнечного света; облачность — опционально, от
|
||||
weather-сущности.
|
||||
- 🪟 **Шторы открываются тапом** — одно действие открывает, закрывает или
|
||||
останавливает штору, а сам значок морфится между открытым и закрытым
|
||||
видом и мягко пульсирует кольцом, пока штора едет.
|
||||
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
|
||||
температурная заливка по комфортным границам.
|
||||
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
|
||||
@@ -74,16 +83,25 @@ House Plan показывает ваш умный дом так, как он в
|
||||
|
||||
## Чем отличается от аналогов
|
||||
|
||||
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan` и подобные решения. Там приходится вручную писать YAML, вычислять координаты каждой иконки и заново править конфиг при каждом изменении. House Plan устроен иначе:
|
||||
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan`
|
||||
или новые GUI-карточки, где стены и мебель рисуют прямо на дашборде. Там либо
|
||||
YAML/SVG, либо конфиг живёт в YAML карточки. House Plan — это **общий живой
|
||||
план** на серверной интеграции Home Assistant:
|
||||
|
||||
| | House Plan | Обычные решения (picture-elements / ha-floorplan) |
|
||||
|---|---|---|
|
||||
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML и правка кода |
|
||||
| **Добавление устройств** | Автоматически по комнатам | Каждую сущность вписываете руками |
|
||||
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели и пишете в конфиг |
|
||||
| **Разметка комнат** | Встроенный редактор контуров | Рисуете в стороннем редакторе SVG |
|
||||
| **Хранение** | На сервере HA (общее для всех устройств) | В YAML дашборда |
|
||||
| **Масштаб** | Плавный зум, всё остаётся чётким (вектор) | Обычно фиксированная картинка |
|
||||
| | House Plan | picture-elements / ha-floorplan | GUI-рисовалки (напр. easy-floorplan) |
|
||||
|---|---|---|---|
|
||||
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML / Inkscape SVG | Рисование стен и мебели в карточке |
|
||||
| **Добавление устройств** | Автоматически по **зоне** HA | Каждую сущность вписываете руками | Ставите сущности руками на чертёж |
|
||||
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели в YAML | Drag на холсте |
|
||||
| **Разметка комнат** | Встроенный редактор контуров, привязка к зонам | Сторонний SVG-редактор | Сами рисуете стены (мебельный CAD) |
|
||||
| **Хранение** | На сервере HA (`.storage`, общее, multi-client) | В YAML дашборда | В карточке / YAML дашборда |
|
||||
| **Оверлеи** | Glow, климат, LQI, солнце, пылесосы, киоск | Что пропишете в SVG/CSS | Зависит от карточки |
|
||||
| **Масштаб** | Плавный векторный зум | Обычно фиксированная картинка | SVG / виртуальный холст |
|
||||
|
||||
**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не
|
||||
редактор мебели. Если нужно с нуля нарисовать диваны и стены — лучше
|
||||
draw-карточка; если есть картинка плана и зоны в HA — House Plan держит один
|
||||
и тот же layout на всех планшетах.
|
||||
|
||||
Ключевые преимущества коротко:
|
||||
|
||||
@@ -97,6 +115,7 @@ House Plan показывает ваш умный дом так, как он в
|
||||
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
|
||||
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
|
||||
Полупрозрачный значок = недоступно. Тёмный = покой.
|
||||
- **Подложку можно двигать и масштабировать.** Картинка плана не прибита к холсту: в редакторе подложки её тянут за тело и за угол, а плашка показывает реальный размер в метрах — чертёж и фотография плана наконец совмещаются.
|
||||
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
|
||||
|
||||
---
|
||||
@@ -180,7 +199,7 @@ title: План дома
|
||||
|
||||

|
||||
|
||||
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст всегда квадратный; картинка сохраняет свои пропорции и центрируется внутри него.
|
||||
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка сохраняет свои пропорции, а подвинуть и отмасштабировать её можно в любой момент в редакторе подложки.
|
||||
|
||||

|
||||
|
||||
@@ -223,7 +242,7 @@ title: План дома
|
||||
|
||||
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
|
||||
|
||||
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) приходят с уже установленной галкой **«Скрыть устройство с плана»**. Дальше галка принадлежит вам — она есть в диалоге каждого устройства, включая виртуальные. Чтобы увидеть и вернуть скрытые, откройте редактор устройств и нажмите **«Показать скрытые»**: скрытые отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
|
||||
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти скрытые устройства, откройте редактор устройств и нажмите **«Показать скрытые»**: они отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
|
||||
|
||||
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
|
||||
|
||||
@@ -274,6 +293,10 @@ title: План дома
|
||||
|
||||

|
||||
|
||||
### Свои стили через card-mod (для продвинутых, без поддержки)
|
||||
|
||||
Карточка приезжает готовой, и поля для CSS у неё нет — но если у вас уже стоит [card-mod](https://github.com/thomasloven/lovelace-card-mod), у каждого объекта плана теперь есть стабильный «крючок», за который можно зацепиться: `data-hp="device"` (плюс `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. Мы обещаем их не переименовывать; сам card-mod мы не поставляем, не поддерживаем и за то, что ваш CSS сделает с карточкой, не отвечаем. Полная таблица, примеры и ограничения — в **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Удаление
|
||||
@@ -306,7 +329,7 @@ title: План дома
|
||||
|
||||
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
|
||||
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто (галка «Скрыть устройство с плана» — для мостов, сцен и прочих нефизических записей она ставится автоматически) — откройте редактор устройств, нажмите **«Показать скрытые»** и снимите галку в его диалоге.
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто автоматически — откройте редактор устройств, нажмите **«Показать скрытые»**, откройте его диалог, нажмите **«Показать»**, затем **«Сохранить»**.
|
||||
|
||||
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
|
||||
|
||||
|
||||
@@ -126,6 +126,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
cfg = stored.get("config")
|
||||
lay_stored = await data.store.async_load() or {}
|
||||
layout = lay_stored.get("layout") or {}
|
||||
lay_meta = {
|
||||
k: v for k, v in lay_stored.items()
|
||||
if k not in ("layout", "rev", "geom_pending")
|
||||
}
|
||||
pending = {
|
||||
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
|
||||
}
|
||||
@@ -134,14 +138,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
lay_rev = int(lay_stored.get("rev", 0))
|
||||
if merged != pending: # 1. the durable intent, before anything moves
|
||||
await data.store.async_save(
|
||||
{"layout": layout, "rev": lay_rev, "geom_pending": merged}
|
||||
{**lay_meta, "layout": layout, "rev": lay_rev, "geom_pending": merged}
|
||||
)
|
||||
rev = int(stored.get("rev", 0))
|
||||
if cfg and migrate_config(cfg): # 2. the config half
|
||||
rev += 1
|
||||
await data.config_store.async_save({"config": cfg, "rev": rev})
|
||||
migrate_layout(layout, merged) # 3. the layout half + intent cleared
|
||||
await data.store.async_save({"layout": layout, "rev": lay_rev + 1})
|
||||
await data.store.async_save({**lay_meta, "layout": layout, "rev": lay_rev + 1})
|
||||
_LOGGER.info(
|
||||
"House Plan: migrated %s space(s) to the square canvas", len(merged)
|
||||
)
|
||||
@@ -149,6 +153,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
# event must never see one migrated half and one old one
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
|
||||
|
||||
# Finish an explicit whole-plan optimization/undo interrupted between the
|
||||
# config and layout store writes. The target was persisted before either
|
||||
# visible half changed, so setup can always converge on the requested pair.
|
||||
optimize_revs: tuple[int, int] | None = None
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
lay_stored = await data.store.async_load() or {}
|
||||
pending = lay_stored.get("optimize_pending")
|
||||
if isinstance(pending, dict) and isinstance(pending.get("config"), dict) \
|
||||
and isinstance(pending.get("layout"), dict):
|
||||
target_config = pending["config"]
|
||||
target_layout = pending["layout"]
|
||||
config_rev = int(stored.get("rev", 0))
|
||||
layout_rev = int(lay_stored.get("rev", 0))
|
||||
if stored.get("config") != target_config:
|
||||
config_rev += 1
|
||||
await data.config_store.async_save({
|
||||
"config": target_config,
|
||||
"rev": config_rev,
|
||||
})
|
||||
if lay_stored.get("layout", {}) != target_layout:
|
||||
layout_rev += 1
|
||||
layout_meta = {
|
||||
k: v for k, v in lay_stored.items()
|
||||
if k not in ("layout", "rev", "optimize_pending", "optimize_backup")
|
||||
}
|
||||
if not pending.get("clear_backup") and "optimize_backup" in lay_stored:
|
||||
layout_meta["optimize_backup"] = lay_stored["optimize_backup"]
|
||||
await data.store.async_save({
|
||||
**layout_meta,
|
||||
"layout": target_layout,
|
||||
"rev": layout_rev,
|
||||
})
|
||||
optimize_revs = (config_rev, layout_rev)
|
||||
_LOGGER.warning("House Plan: completed an interrupted plan optimization")
|
||||
if optimize_revs is not None:
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]})
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
|
||||
# Scheduled collection of everything nobody ended up referencing.
|
||||
|
||||
@@ -24,5 +24,8 @@ def may_write(hass: HomeAssistant, user) -> bool:
|
||||
entry = get_entry(hass)
|
||||
if entry is None:
|
||||
return is_admin
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
# Default TRUE when the key is absent (audit P0-4, 2026-08-05): the card
|
||||
# UI has always been admin-gated, and an unset option must not open every
|
||||
# write WS/HTTP path to every authenticated household user.
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, True))
|
||||
return is_admin if admin_only else True
|
||||
|
||||
@@ -18,7 +18,7 @@ class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return self.async_create_entry(title="House Plan", data={}, options=user_input)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=False): bool}),
|
||||
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=True): bool}),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -32,7 +32,8 @@ class HouseplanOptionsFlow(config_entries.OptionsFlow):
|
||||
async def async_step_init(self, user_input=None):
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
current = self.config_entry.options.get(CONF_ADMIN_ONLY, False)
|
||||
# Match auth.may_write: missing key ⇒ admin-only (audit P0-4).
|
||||
current = self.config_entry.options.get(CONF_ADMIN_ONLY, True)
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=current): bool}),
|
||||
|
||||
@@ -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.56.0"
|
||||
VERSION = "1.59.2"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.56.0"
|
||||
"version": "1.59.2"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ rules:
|
||||
status: done
|
||||
config-flow-test-coverage:
|
||||
status: done
|
||||
comment: tests_backend/test_config_flow.py (runs in CI on Python 3.13).
|
||||
comment: tests_backend/test_ha_config_flow.py (runs in CI on Python 3.13).
|
||||
dependency-transparency:
|
||||
status: done
|
||||
comment: No external requirements.
|
||||
|
||||
@@ -68,6 +68,11 @@ MAX_ROOMS = 400
|
||||
MAX_MARKERS = 2000
|
||||
MAX_OPENINGS = 500
|
||||
MAX_DECOR = 1000
|
||||
MAX_WALLS = 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).
|
||||
MAX_OPEN_SPANS = 500
|
||||
MAX_LAYOUT = 5000
|
||||
# Inner limits (HP-1454-05). The outer collections were capped, the collections
|
||||
# INSIDE them were not: a 150 000-point polygon or a 100 000-entry known_devices
|
||||
@@ -95,11 +100,15 @@ _TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
|
||||
_TEXT_OR_NONE = vol.Any(None, _TEXT)
|
||||
_URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
|
||||
# Positions are normalised to the canvas (0..1). Allow generous slack for an
|
||||
# icon dragged past an edge, but not arbitrary magnitudes: any finite float
|
||||
# used to pass, and a single stored 1e100 stretched every client's view of the
|
||||
# space until the plan was invisible (HP-1500-03).
|
||||
_COORD = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
|
||||
# The canvas is UNBOUNDED (docs/CANVAS.md). Coordinates are still normalised —
|
||||
# 1.0 is still one canvas width — but there is no frame any more, so a plan may
|
||||
# legitimately live at 2.7 or -1.4. The range below is GARBAGE INSURANCE, not a
|
||||
# boundary: at the product's own scale (cell_cm=5, 240 cells across the unit
|
||||
# width) 5000 is about 60 km of plan, unreachable in a home, while a stored
|
||||
# 1e100 still cannot stretch every client's view until the plan is invisible
|
||||
# (HP-1500-03 / HP-1501-01). Widened from +/-4 on 2026-08-03.
|
||||
CANVAS_LIMIT = 5000.0
|
||||
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
@@ -107,19 +116,26 @@ POS_SCHEMA = vol.Schema(
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
# Geometry is normalised to the canvas (0..1). ±4 is generous slack for a
|
||||
# vertex nudged past an edge, not an envelope for arbitrary magnitudes: any
|
||||
# finite float used to pass here, and a single 1e100 room vertex stretched the
|
||||
# frame until the whole space was unviewable for every client (HP-1501-01) —
|
||||
# the exact failure HP-1500-03 closed for layout positions, one schema over.
|
||||
_GEOM = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
|
||||
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
|
||||
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
|
||||
# corruption (HP-1501-01, the room-geometry twin of HP-1500-03).
|
||||
_GEOM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
|
||||
# A SIZE is not a coordinate (HP-1502-01): SVG requires positive width/height,
|
||||
# and the clients divide by these. `view_box: [0,0,0,0]` passed the shared
|
||||
# validator and serialised into viewBox="0 0 0 0" — a blank plan on every
|
||||
# client. The floor is one thousandth of the canvas (1 render unit): far below
|
||||
# any real room, but keeps the maths finite.
|
||||
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=4.0))
|
||||
# any real room, but keeps the maths finite. The CEILING follows the canvas
|
||||
# (docs/CANVAS.md) — a room on an unbounded plane may legitimately be wider
|
||||
# than the old unit square — while staying strictly positive.
|
||||
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT))
|
||||
|
||||
# The backdrop's uniform scale (docs/BACKDROP.md). A MULTIPLIER, not a
|
||||
# coordinate: strictly positive, and bounded by what a person could mean —
|
||||
# a hundredth of the canvas is already a thumbnail, a hundred canvases is
|
||||
# already absurd. Mirrored by PLAN_SCALE_MIN/MAX in src/space-geometry.ts.
|
||||
PLAN_SCALE_MIN = 0.01
|
||||
PLAN_SCALE_MAX = 100.0
|
||||
|
||||
|
||||
def _view_box(value):
|
||||
@@ -131,6 +147,45 @@ def _view_box(value):
|
||||
|
||||
POINT = vol.All([_GEOM], vol.Length(min=2, max=2))
|
||||
|
||||
# A virtual stretch shorter than this is a click, not a span. Mirrors
|
||||
# OPEN_SPAN_MIN_UNITS in src/open-spans.ts (normalised units).
|
||||
OPEN_SPAN_MIN_LEN = 1e-3
|
||||
|
||||
|
||||
def _open_span(value):
|
||||
"""One virtual stretch: exactly two distinct finite points a/b.
|
||||
|
||||
AUD-159B6-03: the field used to ride on `extra=ALLOW_EXTRA` — any shape
|
||||
passed the backend and the card crashed reading `e.a[0]` on render, for
|
||||
every reader of that space. A degenerate span (a == b) is not a stretch
|
||||
either: it can never be hit, closed or drawn, so it is corruption, not data.
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid("open_span must be an object with a/b points")
|
||||
a = POINT(value.get("a"))
|
||||
b = POINT(value.get("b"))
|
||||
if abs(a[0] - b[0]) < OPEN_SPAN_MIN_LEN and abs(a[1] - b[1]) < OPEN_SPAN_MIN_LEN:
|
||||
raise vol.Invalid("open_span: a and b must differ")
|
||||
out = {k: v for k, v in value.items() if k not in ("a", "b")}
|
||||
out["a"] = a
|
||||
out["b"] = b
|
||||
return out
|
||||
|
||||
|
||||
def _dedupe_open_spans(value):
|
||||
"""Drop repeats of the same stretch (either direction) — one wall, one span."""
|
||||
seen = set()
|
||||
out = []
|
||||
for span in value:
|
||||
a, b = span["a"], span["b"]
|
||||
fwd = (round(a[0], 6), round(a[1], 6), round(b[0], 6), round(b[1], 6))
|
||||
key = min(fwd, (fwd[2], fwd[3], fwd[0], fwd[1]))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(span)
|
||||
return out
|
||||
|
||||
|
||||
def _require_geometry(room: dict) -> dict:
|
||||
if "poly" in room or all(k in room for k in ("x", "y", "w", "h")):
|
||||
@@ -196,6 +251,9 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
vol.Optional("temp_min"): vol.Coerce(float),
|
||||
vol.Optional("temp_max"): vol.Coerce(float),
|
||||
vol.Optional("show_lqi"): bool,
|
||||
# "draw less" switches; absent = False = everything is drawn as before
|
||||
vol.Optional("hide_decor"): bool,
|
||||
vol.Optional("hide_openings"): bool,
|
||||
vol.Optional("label_temp"): bool,
|
||||
vol.Optional("label_hum"): bool,
|
||||
vol.Optional("label_lqi"): bool,
|
||||
@@ -209,12 +267,43 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
# Live text on a decor label (docs/LIVE-TEXT.md). An entity id is
|
||||
# `<domain>.<object_id>`; HA itself allows only lowercase letters, digits and
|
||||
# underscores in both halves. The bound is a sanity limit, not a policy.
|
||||
MAX_ENTITY_ID = 255
|
||||
_ENTITY_ID = vol.All(str, vol.Length(min=3, max=MAX_ENTITY_ID),
|
||||
vol.Match(r"^[a-z0-9_]+\.[a-z0-9_]+$"))
|
||||
# A caption is a caption: the inline-reference template is bounded. Attribute
|
||||
# and unit bounds below apply only to legacy beta.9 link fields, which remain
|
||||
# accepted so an older saved plan can reach the frontend and migrate on edit.
|
||||
MAX_DECOR_TEXT = 200
|
||||
MAX_DECOR_ATTR = 64
|
||||
MAX_DECOR_UNIT = 16
|
||||
# The text block is scaled by dragging its corners; the range is what a human
|
||||
# could mean on a 1000-unit canvas, the rest is garbage insurance.
|
||||
DECOR_TEXT_SCALE_MIN = 0.15
|
||||
DECOR_TEXT_SCALE_MAX = 20.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
|
||||
# before the user can save. What is enforced is the shape of the id — a flat
|
||||
# lowercase name — and its length; an id this backend has never heard of simply
|
||||
# renders as nothing in an older card.
|
||||
MAX_FURN_SYMBOL = 32
|
||||
_FURN_SYMBOL = vol.All(str, vol.Length(min=1, max=MAX_FURN_SYMBOL),
|
||||
vol.Match(r"^[a-z0-9_]+$"))
|
||||
# …and its size: strictly positive, capped by the same canvas insurance limit
|
||||
# an opening's length is. A piece of furniture is a SIZE, not a coordinate.
|
||||
_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("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
|
||||
}
|
||||
_NORM = vol.All(vol.Coerce(float), vol.Range(min=-1, max=2))
|
||||
# Decor lives on the same unbounded canvas as everything else (docs/CANVAS.md):
|
||||
# it used to be pinned to -1..2, i.e. "one canvas of slack around the square".
|
||||
_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,
|
||||
@@ -222,19 +311,72 @@ DECOR_SCHEMA = vol.Any(
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("w"): _NORM, vol.Required("h"): _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},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("text"): vol.All(str, vol.Length(min=1, max=200)),
|
||||
vol.Optional("size"): vol.In(["s", "m", "l"])},
|
||||
# the template: newlines are the user's own line breaks and are
|
||||
# kept verbatim (docs/LIVE-TEXT.md); the label never wraps itself
|
||||
vol.Required("text"): vol.All(str, vol.Length(min=1, max=MAX_DECOR_TEXT)),
|
||||
# legacy font size ('s'|'m'|'l'). The dialog no longer offers it
|
||||
# — the block is scaled by its corner handles — but a plan
|
||||
# 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.
|
||||
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)),
|
||||
# Legacy one-value link (beta.9 and earlier). New labels store
|
||||
# every `{entity[:attribute]}` reference directly in `text`;
|
||||
# these stay accepted solely for backward compatibility.
|
||||
vol.Optional("entity"): vol.Any(None, _ENTITY_ID),
|
||||
vol.Optional("attr"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_ATTR))),
|
||||
vol.Optional("unit"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_UNIT)))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
# A piece of furniture (docs/FURNITURE.md): a symbol id, a normalised box
|
||||
# and an optional rotation. It is a NEW kind, so no existing plan carries
|
||||
# it, nothing is migrated, and an integration that has this branch reads
|
||||
# every older config byte-for-byte as before.
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "furniture",
|
||||
vol.Required("symbol"): _FURN_SYMBOL,
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("w"): _FURN_SIZE, vol.Required("h"): _FURN_SIZE,
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
|
||||
|
||||
def _wall_endpoints_pair(entry: dict) -> dict:
|
||||
"""Exact wall endpoints are useful only as a complete a/b pair."""
|
||||
if ("a" in entry) != ("b" in entry):
|
||||
raise vol.Invalid("wall exact endpoints require both a and b")
|
||||
return entry
|
||||
|
||||
|
||||
WALL_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("key"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
|
||||
# New writes retain exact normalized interval endpoints. The old
|
||||
# key remains the compatibility lookup; endpoints preserve a
|
||||
# differing-thickness breakpoint after a virtual span is closed.
|
||||
vol.Optional("a"): vol.All([_NORM], vol.Length(min=2, max=2)),
|
||||
vol.Optional("b"): vol.All([_NORM], vol.Length(min=2, max=2)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_wall_endpoints_pair,
|
||||
)
|
||||
|
||||
SPACE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("id"): vol.All(str, vol.Match(SPACE_ID_RE.pattern)),
|
||||
vol.Required("title"): str,
|
||||
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
|
||||
vol.Optional("plan_url"): vol.Any(str, None),
|
||||
@@ -247,6 +389,17 @@ 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
|
||||
# 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),
|
||||
vol.Optional("plan_y"): vol.Any(None, _COORD),
|
||||
vol.Optional("plan_scale"): vol.Any(
|
||||
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
|
||||
),
|
||||
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)),
|
||||
@@ -258,7 +411,9 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Required("x"): _GEOM,
|
||||
vol.Required("y"): _GEOM,
|
||||
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
|
||||
# a SIZE: strictly positive, capped by the canvas insurance
|
||||
# limit rather than by the old unit square (docs/CANVAS.md)
|
||||
vol.Required("length"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
|
||||
vol.Optional("contact"): vol.Any(str, None),
|
||||
vol.Optional("lock"): vol.Any(str, None),
|
||||
vol.Optional("invert"): bool,
|
||||
@@ -268,6 +423,17 @@ SPACE_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
], vol.Length(max=MAX_OPENINGS)),
|
||||
# Wall thickness (docs/WALL-THICKNESS.md): keyed by a segment identity
|
||||
# (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)),
|
||||
# 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
|
||||
# keeps working on its own (AUD-159B6-03).
|
||||
vol.Optional("open_spans"): vol.All(
|
||||
vol.Length(max=MAX_OPEN_SPANS), [_open_span], _dedupe_open_spans,
|
||||
),
|
||||
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
|
||||
# independent existence. Still accepted so a stale browser tab cannot fail a save;
|
||||
# the card strips the field on every write.
|
||||
@@ -282,7 +448,11 @@ MARKER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
# 'device:<device_id>' | 'entity:<entity_id>' | 'virtual'
|
||||
vol.Required("binding"): str,
|
||||
vol.Required("binding"): vol.All(
|
||||
str,
|
||||
vol.Length(min=1, max=MAX_TEXT),
|
||||
vol.Match(r"^(device:.+|entity:.+|virtual)$"),
|
||||
),
|
||||
vol.Optional("space"): vol.Any(str, None),
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("hidden"): bool,
|
||||
@@ -291,7 +461,7 @@ MARKER_SCHEMA = vol.Schema(
|
||||
vol.Optional("model"): _TEXT_OR_NONE,
|
||||
vol.Optional("link"): vol.Any(None, _URL),
|
||||
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", None),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "cover", None),
|
||||
# the 'run' target: only the runnable domains, nothing else is callable
|
||||
vol.Optional("tap_target"): vol.Any(
|
||||
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
|
||||
@@ -322,10 +492,12 @@ MARKER_SCHEMA = vol.Schema(
|
||||
# climate current_temperature: badge + room-average vote (off unless True)
|
||||
vol.Optional("use_climate_temp"): vol.Any(bool, None),
|
||||
vol.Optional("room_id"): vol.Any(str, None),
|
||||
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
|
||||
# asserts every option the editor offers is accepted here (issue #3)
|
||||
# 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("ripple_color"): vol.Any(str, None),
|
||||
vol.Optional("ripple_color"): vol.Any(
|
||||
None, vol.Match(r"^#[0-9a-fA-F]{6}$")
|
||||
),
|
||||
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
|
||||
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
|
||||
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
|
||||
|
||||
@@ -7,6 +7,7 @@ import base64
|
||||
import binascii
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -36,6 +37,36 @@ from .validation import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_OPTIMIZE_BACKUP = "optimize_backup"
|
||||
_OPTIMIZE_PENDING = "optimize_pending"
|
||||
|
||||
|
||||
def _optimizer_backup_is_current(config_data: dict[str, Any], layout_data: dict[str, Any]) -> bool:
|
||||
"""An optimization can be undone only before any later plan edit."""
|
||||
backup = layout_data.get(_OPTIMIZE_BACKUP)
|
||||
if not isinstance(backup, dict):
|
||||
return False
|
||||
try:
|
||||
return (
|
||||
int(backup.get("after_config_rev", -1)) == int(config_data.get("rev", 0))
|
||||
and int(backup.get("after_layout_rev", -1)) == int(layout_data.get("rev", 0))
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
async def _discard_optimizer_snapshot(rt: HouseplanData) -> None:
|
||||
"""Free a snapshot made stale by a later ordinary config edit."""
|
||||
data = await rt.store.async_load() or {}
|
||||
if _OPTIMIZE_BACKUP not in data and _OPTIMIZE_PENDING not in data:
|
||||
return
|
||||
await rt.store.async_save({
|
||||
**{
|
||||
k: v for k, v in data.items()
|
||||
if k not in (_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@callback
|
||||
def async_register(hass: HomeAssistant) -> None:
|
||||
@@ -48,6 +79,8 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_layout_delete)
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
|
||||
websocket_api.async_register_command(hass, ws_plan_set)
|
||||
websocket_api.async_register_command(hass, ws_plans_list)
|
||||
websocket_api.async_register_command(hass, ws_plans_delete)
|
||||
@@ -85,8 +118,13 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
if rt is None:
|
||||
return
|
||||
data = await rt.store.async_load() or {}
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
connection.send_result(
|
||||
msg["id"], {"layout": data.get("layout", {}), "rev": int(data.get("rev", 0))}
|
||||
msg["id"], {
|
||||
"layout": data.get("layout", {}),
|
||||
"rev": int(data.get("rev", 0)),
|
||||
"can_optimize_undo": _optimizer_backup_is_current(config_data, data),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -121,7 +159,8 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
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})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
@@ -151,7 +190,8 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
# optimistic locking on layout/set meaningless — every drag reset the
|
||||
# counter to 0 (HP-1454-08)
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
|
||||
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
|
||||
"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})
|
||||
@@ -548,7 +588,8 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
if msg["device_id"] in layout:
|
||||
del layout[msg["device_id"]]
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in (
|
||||
"layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)},
|
||||
"layout": layout, "rev": new_rev})
|
||||
if new_rev is not None:
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
@@ -561,13 +602,27 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/config/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Return the configuration and its revision."""
|
||||
"""Return the configuration, its revision, and whether this user may write.
|
||||
|
||||
`can_write` is the single source of truth for the card's editor chrome
|
||||
(audit P0-4): the UI must mirror `may_write`, not a hard-coded is_admin
|
||||
check that drifted from the integration option.
|
||||
"""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"config": config,
|
||||
"rev": data.get("rev", 0),
|
||||
"can_write": may_write(hass, getattr(connection, "user", None)),
|
||||
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -679,6 +734,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
try:
|
||||
await _discard_optimizer_snapshot(rt)
|
||||
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
|
||||
_LOGGER.exception("House Plan: discarding stale optimization backup failed")
|
||||
# Still holding the lock: the file system is not part of the store's
|
||||
# transaction, so collection has to be pinned to this commit (R3-1).
|
||||
# It is best-effort housekeeping behind an already durable write — a
|
||||
@@ -704,6 +763,184 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
|
||||
# ---------------- whole-plan maintenance ----------------
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plan/optimize",
|
||||
vol.Required("config"): CONFIG_SCHEMA,
|
||||
vol.Required("layout"): LAYOUT_SCHEMA,
|
||||
vol.Required("expected_config_rev"): int,
|
||||
vol.Required("expected_layout_rev"): int,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Atomically-intended replacement of config+layout with one-deep undo.
|
||||
|
||||
Home Assistant stores are separate files, so a literal cross-file
|
||||
transaction is impossible. Persisting the target as an intent before
|
||||
either half changes makes a crash resumable during the next setup; the UI
|
||||
only receives success once both halves are durable.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may optimize plans")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
size = len(json.dumps(msg["config"], separators=(",", ":")))
|
||||
if size > MAX_CONFIG_BYTES:
|
||||
connection.send_error(
|
||||
msg["id"], "too_large",
|
||||
f"Configuration is {size // 1024} KB, the limit is {MAX_CONFIG_BYTES // 1024} KB",
|
||||
)
|
||||
return
|
||||
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
|
||||
connection.send_error(
|
||||
msg["id"], "conflict",
|
||||
f"Plan changed elsewhere (config {config_rev}, layout {layout_rev})",
|
||||
)
|
||||
return
|
||||
|
||||
missing = await hass.async_add_executor_job(
|
||||
_missing_internal_plans,
|
||||
Path(hass.config.path(PLANS_DIR)),
|
||||
msg["config"],
|
||||
config_data.get("config"),
|
||||
)
|
||||
if missing:
|
||||
connection.send_error(
|
||||
msg["id"], "missing_plan",
|
||||
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
||||
)
|
||||
return
|
||||
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
backup = {
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout", {}),
|
||||
"created": int(time.time()),
|
||||
"after_config_rev": new_config_rev,
|
||||
"after_layout_rev": new_layout_rev,
|
||||
}
|
||||
pending = {
|
||||
"config": msg["config"],
|
||||
"layout": msg["layout"],
|
||||
"clear_backup": False,
|
||||
}
|
||||
layout_meta = {
|
||||
k: v for k, v in layout_data.items()
|
||||
if k not in ("layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
|
||||
}
|
||||
|
||||
# Intent first. A setup-time finisher completes whichever half a crash
|
||||
# interrupted; until then the visible layout/revision remain unchanged.
|
||||
await rt.store.async_save({
|
||||
**layout_meta,
|
||||
"layout": layout_data.get("layout", {}),
|
||||
"rev": layout_rev,
|
||||
_OPTIMIZE_BACKUP: backup,
|
||||
_OPTIMIZE_PENDING: pending,
|
||||
})
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_config_rev})
|
||||
await rt.store.async_save({
|
||||
**layout_meta,
|
||||
"layout": msg["layout"],
|
||||
"rev": new_layout_rev,
|
||||
_OPTIMIZE_BACKUP: backup,
|
||||
})
|
||||
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"can_undo": True,
|
||||
})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plan/optimize_undo",
|
||||
vol.Required("expected_config_rev"): int,
|
||||
vol.Required("expected_layout_rev"): int,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Restore the snapshot, but never overwrite edits made after optimization."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may undo optimization")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
|
||||
connection.send_error(msg["id"], "conflict", "Plan changed elsewhere")
|
||||
return
|
||||
if not _optimizer_backup_is_current(config_data, layout_data):
|
||||
connection.send_error(
|
||||
msg["id"], "no_backup",
|
||||
"The optimization backup is unavailable or a later edit made it stale",
|
||||
)
|
||||
return
|
||||
|
||||
backup = layout_data[_OPTIMIZE_BACKUP]
|
||||
restored_config = backup.get("config") or DEFAULT_CONFIG
|
||||
restored_layout = backup.get("layout") or {}
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
pending = {
|
||||
"config": restored_config,
|
||||
"layout": restored_layout,
|
||||
"clear_backup": True,
|
||||
}
|
||||
layout_meta = {
|
||||
k: v for k, v in layout_data.items()
|
||||
if k not in ("layout", "rev", _OPTIMIZE_BACKUP, _OPTIMIZE_PENDING)
|
||||
}
|
||||
await rt.store.async_save({
|
||||
**layout_meta,
|
||||
"layout": layout_data.get("layout", {}),
|
||||
"rev": layout_rev,
|
||||
_OPTIMIZE_BACKUP: backup,
|
||||
_OPTIMIZE_PENDING: pending,
|
||||
})
|
||||
await rt.config_store.async_save({"config": restored_config, "rev": new_config_rev})
|
||||
await rt.store.async_save({
|
||||
**layout_meta,
|
||||
"layout": restored_layout,
|
||||
"rev": new_layout_rev,
|
||||
})
|
||||
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"can_undo": False,
|
||||
})
|
||||
|
||||
|
||||
# ---------------- plan uploads ----------------
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Backdrop image (docs/BACKDROP.md) — two shots for the owner:
|
||||
// backdrop_frame — the transform frame mid-gesture: dashed outline, four
|
||||
// finger-sized corner handles, and the live "W × H" badge
|
||||
// stating the picture's real size through cell_cm;
|
||||
// backdrop_paper — the new paper rule on an image plan: the opaque sheet is
|
||||
// the ROOM CONTOURS, so the scene colour is visible around
|
||||
// them and under the parts of the picture nobody has drawn
|
||||
// over. The picture is scaled down here so both are obvious.
|
||||
// Usage: node demo/shot_backdrop.mjs <outdir>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
const settle = () => page.waitForTimeout(260);
|
||||
|
||||
// ---- 1) the frame, mid-drag ---------------------------------------------
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('decor');
|
||||
c._decorTool = 'backdrop';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const stage = window.__card.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
{
|
||||
// grab the bottom-right handle and hold it mid-gesture, so the shot carries
|
||||
// the frame AND the live badge at once
|
||||
const [ax, ay] = await screenPt(1000, 900);
|
||||
const [bx, by] = await screenPt(820, 760);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 4 });
|
||||
await settle();
|
||||
await page.screenshot({ path: `${outDir}/backdrop_frame.png` });
|
||||
await page.mouse.up();
|
||||
}
|
||||
await settle();
|
||||
|
||||
// ---- 2) the paper is the rooms ------------------------------------------
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
// a picture pulled well inside the plan and an acid scene colour: what shows
|
||||
// through around the rooms is the SCENE, which is the whole new rule
|
||||
sp.plan_x = 0.16; sp.plan_y = 0.10; sp.plan_scale = 0.62;
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#7bd389' };
|
||||
c._cfgEpoch++; c._frame = null;
|
||||
c._setMode('view');
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._fitAll();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
await settle();
|
||||
await page.screenshot({ path: `${outDir}/backdrop_paper.png` });
|
||||
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir + '/backdrop_frame.png, ' + outDir + '/backdrop_paper.png');
|
||||
@@ -0,0 +1,33 @@
|
||||
// Capture: a curtain on the move — the semantic transition ring around a
|
||||
// NEUTRAL plate (owner 2026-08-03). The pulse is frozen at a visible frame so
|
||||
// the shot is deterministic.
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover', display: 'icon_ripple' },
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
] };
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'cover.gate': { entity_id: 'cover.gate', state: 'opening',
|
||||
attributes: { friendly_name: 'Curtain', device_class: 'curtain' } } } };
|
||||
c._setMode('view'); c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const r = (c.shadowRoot || c.renderRoot).querySelector('.dev.activity-transition').getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
});
|
||||
const pad = 70;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/cover_move.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.w + pad * 2, height: box.h + pad * 2 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,70 @@
|
||||
// Capture: one curtain in the four states an owner sees — closed, open,
|
||||
// opening, closing (owner's contract 2026-08-04). The plate is the plain
|
||||
// neutral badge in ALL of them; open/closed is told by the icon morph alone,
|
||||
// and the two travelling ones add the breathing transition ring (frozen at a
|
||||
// visible frame so the shot is deterministic).
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 520 }, 2);
|
||||
const STATES = ['closed', 'open', 'opening', 'closing'];
|
||||
await page.evaluate(async (STATES) => {
|
||||
const c = window.__card;
|
||||
const devices = {}; const entities = {}; const states = {};
|
||||
STATES.forEach((s, i) => {
|
||||
const id = 'd_cur' + i;
|
||||
devices[id] = { id, name: 'Curtain ' + s, model: 'Roller shade driver E1',
|
||||
area_id: 'garden', identifiers: [['demo', id]], entry_type: null, via_device_id: null };
|
||||
entities['cover.cur' + i] = { entity_id: 'cover.cur' + i, device_id: id, platform: 'demo' };
|
||||
states['cover.cur' + i] = { entity_id: 'cover.cur' + i, state: s,
|
||||
attributes: { friendly_name: 'Curtain ' + s, device_class: 'curtain' } };
|
||||
});
|
||||
c.hass = { ...c.hass,
|
||||
devices: { ...c.hass.devices, ...devices },
|
||||
entities: { ...c.hass.entities, ...entities },
|
||||
states: { ...c.hass.states, ...states } };
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
...STATES.map((s, i) => ({ id: 'm_cur' + i, binding: 'device:d_cur' + i,
|
||||
tap_action: 'cover', display: 'icon_ripple' })),
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
{ id: 'm_gate', binding: 'device:d_gate', hidden: true },
|
||||
] };
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
const layout = { ...c._layout };
|
||||
STATES.forEach((s, i) => {
|
||||
const d = c._devices.find((x) => x.bindingRef === 'd_cur' + i);
|
||||
if (d) layout[d.id] = { s: 'garden', x: 0.18 + i * 0.215, y: 0.5 };
|
||||
});
|
||||
c._layout = layout;
|
||||
c._setMode('view'); c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
}, STATES);
|
||||
await page.waitForTimeout(500);
|
||||
// captions under each badge, so the four states are readable in the file
|
||||
const box = await page.evaluate((STATES) => {
|
||||
const c = window.__card;
|
||||
const els = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.devlayer .dev')]
|
||||
.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x);
|
||||
let minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
|
||||
els.forEach((el, i) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const cap = document.createElement('div');
|
||||
cap.textContent = STATES[i];
|
||||
cap.style.cssText = `position:fixed;left:${r.x + r.width / 2}px;top:${r.y + r.height + 10}px;`
|
||||
+ 'transform:translateX(-50%);font:600 13px system-ui,sans-serif;color:#1c2530;'
|
||||
+ 'letter-spacing:.02em;z-index:9999;padding:2px 7px;border-radius:6px;'
|
||||
+ 'background:rgba(255,255,255,0.88);white-space:nowrap;';
|
||||
document.body.appendChild(cap);
|
||||
minX = Math.min(minX, r.x); maxX = Math.max(maxX, r.x + r.width);
|
||||
minY = Math.min(minY, r.y); maxY = Math.max(maxY, r.y + r.height);
|
||||
});
|
||||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
||||
}, STATES);
|
||||
const pad = 55;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/cover_states.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad),
|
||||
width: box.w + pad * 2, height: box.h + pad * 2 + 22 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,63 @@
|
||||
// Скриншоты библиотеки мебели: палитра и расставленная мебель на плане.
|
||||
import { launch } from './serve.mjs';
|
||||
const OUT = process.env.HP_SHOT_DIR || '/tmp';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
sr.querySelectorAll('.modetab')[2].click();
|
||||
await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [];
|
||||
c._decorTool = 'furniture';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
sr.querySelector('.furnitem[data-symbol="sofa"]').click();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: `${OUT}/furniture_palette.png` });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const P = 1000 / 240;
|
||||
const cm = (v) => (v / 5) * P / 1000; // cm -> normalised
|
||||
const put = (id, symbol, w, h, cx, cy, angle) => ({
|
||||
id, kind: 'furniture', symbol,
|
||||
x: cx / 1000 - cm(w) / 2, y: cy / 1000 - cm(h) / 2,
|
||||
w: cm(w), h: cm(h), color: '#8d6e63', width: 3,
|
||||
...(angle ? { angle } : {}),
|
||||
});
|
||||
// r1 living 40..550 x 140..580 | r2 kitchen 550..960 x 140..460
|
||||
// r3 bedroom 550..960 x 460..860 | r4 hall 40..550 x 580..860
|
||||
c._curSpaceCfg.decor = [
|
||||
put('f1', 'sofa', 220, 90, 250, 140 + 37.5),
|
||||
put('f2', 'coffee_table', 110, 60, 250, 300),
|
||||
put('f3', 'tv', 120, 30, 250, 570),
|
||||
put('f4', 'armchair', 90, 85, 90, 340, 90),
|
||||
put('f5', 'table_dining', 140, 80, 700, 300),
|
||||
put('f6', 'chair', 45, 45, 700, 240),
|
||||
put('f7', 'chair', 45, 45, 700, 360, 180),
|
||||
put('f8', 'fridge', 60, 65, 590, 140 + 27, 0),
|
||||
put('f9', 'stove', 60, 60, 670, 140 + 25),
|
||||
put('f10', 'kitchen_sink', 80, 60, 760, 140 + 25),
|
||||
put('f11', 'dishwasher', 60, 60, 840, 140 + 25),
|
||||
put('f12', 'bed_double', 160, 200, 700, 460 + 42),
|
||||
put('f13', 'nightstand', 45, 40, 620, 470),
|
||||
put('f14', 'nightstand', 45, 40, 780, 470),
|
||||
put('f15', 'wardrobe', 100, 60, 910, 700, 90),
|
||||
put('f16', 'washer', 60, 60, 120, 860 - 25, 180),
|
||||
put('f17', 'toilet', 40, 70, 220, 860 - 29, 180),
|
||||
put('f18', 'bathtub', 170, 75, 400, 860 - 31, 180),
|
||||
put('f19', 'plant', 40, 40, 500, 620),
|
||||
put('f20', 'shower', 90, 90, 490, 700),
|
||||
];
|
||||
c._decorTool = 'select';
|
||||
c._decorSel = 'f1';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/furniture_plan.png` });
|
||||
await browser.close();
|
||||
console.log('shots written to', OUT);
|
||||
@@ -2,6 +2,9 @@ import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.binding !== 'device:d_motion');
|
||||
c._serverCfg.markers.push({ id: 'd_motion', binding: 'device:d_motion', display: 'icon_ripple' });
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
// the flash is keyed to a WITNESSED off→on transition (owner's rule
|
||||
// 2026-08-01: разовая вспышка в момент обнаружения) — cycle through off
|
||||
const set = async (state) => {
|
||||
@@ -14,13 +17,13 @@ await page.evaluate(async () => {
|
||||
await set('on');
|
||||
// freeze the pulse at a visible frame for a deterministic capture
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.dev.senseflash::after{animation-delay:-0.4s;animation-play-state:paused;}';
|
||||
st.textContent = '.activity-ring.event i{animation-delay:-0.4s!important;animation-play-state:paused!important;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const el = (c.shadowRoot || c.renderRoot).querySelector('.dev.senseflash');
|
||||
const el = (c.shadowRoot || c.renderRoot).querySelector('.dev.activity-event');
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Capture: the shoulder rulers + centre tick while PLACING a new opening
|
||||
// (Opening tool, cursor hovering the wall centre) — owner 2026-08-03.
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [];
|
||||
c._setMode('plan'); c._tool = 'opening';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const pt = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const stage = c.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
return { x: r.left + ((293.7 - vx) / vw) * r.width, y: r.top + ((141 - vy) / vh) * r.height,
|
||||
sx: r.left, sy: r.top, sw: r.width, sh: r.height };
|
||||
});
|
||||
await page.mouse.move(pt.x, pt.y, { steps: 4 });
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/opening_place.png',
|
||||
clip: { x: pt.sx, y: pt.sy, width: pt.sw, height: Math.min(pt.sh, 420) } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,45 @@
|
||||
// Owner 2026-08-04: «углы границ комнат всё ещё с зубцами». A miter join on a
|
||||
// sharp corner shoots a spike far past the walls that meet there; past the
|
||||
// miter limit it flips to a flat bevel. Room borders now join round, like the
|
||||
// decor lines already did. Two rooms make the defect obvious: an L (a concave
|
||||
// 90° corner plus the convex ones) and a wedge with a ~45° apex — the angle
|
||||
// where the miter spike is longest before the miter limit clips it to a bevel.
|
||||
// Usage: node demo/shot_room_joins.mjs <outdir> <name>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const name = process.argv[3] || 'room_joins';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 6);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
delete sp.plan_url;
|
||||
sp.rooms = [
|
||||
{ id: 'rL', name: '', area: 'living_room',
|
||||
poly: [[0.06, 0.04], [0.52, 0.04], [0.52, 0.14], [0.94, 0.14], [0.94, 0.26], [0.06, 0.26]] },
|
||||
{ id: 'rW', name: '', area: 'kitchen',
|
||||
poly: [[0.20, 0.318], [0.90, 0.55], [0.20, 0.782]] },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, show_names: false,
|
||||
fill_mode: 'none', room_color: '#3ea6ff', room_opacity: 1 };
|
||||
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
// crop tight around the wedge's ~28° apex: at plan scale the whole stage hides
|
||||
// a 5-unit spike, and the spike is the entire point of the shot
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const svg = c.shadowRoot.querySelector('.stage svg');
|
||||
const poly = [...svg.querySelectorAll('polygon.room')].pop();
|
||||
const pts = poly.getAttribute('points').split(' ').map((s) => s.split(',').map(Number));
|
||||
const apex = pts.reduce((m, p) => (p[0] > m[0] ? p : m), pts[0]);
|
||||
const m = svg.getScreenCTM();
|
||||
return { x: m.a * apex[0] + m.c * apex[1] + m.e, y: m.b * apex[0] + m.d * apex[1] + m.f };
|
||||
});
|
||||
await page.screenshot({ path: `${outDir}/${name}.png`,
|
||||
clip: { x: box.x - 62, y: box.y - 30, width: 72, height: 60 } });
|
||||
await browser.close();
|
||||
console.log('shot written to ' + outDir + '/' + name + '.png');
|
||||
+2
-1
@@ -46,7 +46,8 @@ await page.evaluate(async () => {
|
||||
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
const dlg = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.dialog'));
|
||||
const dlg = await page.evaluateHandle(() => window.__card.shadowRoot
|
||||
.querySelector('hp-dialog').shadowRoot.querySelector('.surface'));
|
||||
await dlg.asElement().screenshot({ path: outDir + '/sun_compass.png' });
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Brightness check for the wedges (owner 2026-08-03: «поярче, иногда плохо
|
||||
// видны»). Two hardest cases side by side: a DAYLIGHT sun on white paper (the
|
||||
// wedge colour is nearly neutral there) and a low sun over the dark glow
|
||||
// canvas. Usage: node demo/shot_sun_bright.mjs <outdir> [suffix]
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const tag = process.argv[3] ? '_' + process.argv[3] : '';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
// the fade is a 2 s animation; reduced motion pins the layer at full opacity
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
const setup = (fill, azimuth, elevation, ceiling) => page.evaluate(async ([fill, azimuth, elevation, ceiling]) => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: fill };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgEpoch++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: ceiling },
|
||||
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth, elevation } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}, [fill, azimuth, elevation, ceiling]);
|
||||
|
||||
const shoot = async (name) => {
|
||||
await page.waitForTimeout(400);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${name}${tag}.png` });
|
||||
};
|
||||
|
||||
// 1) plain daylight on white paper: neutral wedge colour, brightest canvas
|
||||
await setup('none', 265, 24, 'off');
|
||||
await shoot('sun_bright_paper');
|
||||
// 2) a low warm sun over the dark glow canvas
|
||||
await setup('glow', 265, 4, 'off');
|
||||
await shoot('sun_bright_glow');
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -0,0 +1,64 @@
|
||||
// Stills for the rim (docs/SUN.md, «The rim»). Two scenes — white paper and
|
||||
// the dark glow canvas — and, in each, the SAME frame at several rim peaks so
|
||||
// the value can be picked by looking rather than by arguing. The alphas other
|
||||
// than the shipped one are applied by rewriting the `hp-sunrim-*` stops in the
|
||||
// DOM, which is why a rebuild is not needed to compare them.
|
||||
// node demo/shot_sun_rim.mjs <outdir> [alphas, default 0,0.3,0.42,0.5]
|
||||
// `0` is the "before" frame: the wedge alone, with no rim at all.
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const ALPHAS = (process.argv[3] || '0,0.3,0.42,0.5').split(',').map(Number);
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no fades
|
||||
|
||||
const setup = async (scene) => {
|
||||
await page.evaluate(async (sc) => {
|
||||
const c = window.__card;
|
||||
const cfg = c._serverCfg;
|
||||
const sp = cfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.plan_url = null; // plain paper, nothing to hide behind
|
||||
sp.settings = { ...(sp.settings || {}), fill_mode: sc === 'dark' ? 'glow' : 'none' };
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, sun_rays: true,
|
||||
bg_mode: 'static', bg_color: sc === 'dark' ? '#101720' : '#f2f2f0' };
|
||||
c._cfgEpoch++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: sc === 'dark' ? 'on' : 'off' },
|
||||
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 250, elevation: 35 } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}, scene);
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
|
||||
// Rescale every rim gradient to `peak`, always from the stops as BUILT — a
|
||||
// scale relative to the current values would collapse to zero the moment the
|
||||
// 0 frame is taken and never come back.
|
||||
const setAlpha = async (peak) => {
|
||||
await page.evaluate((a) => {
|
||||
for (const g of window.__card.shadowRoot.querySelectorAll('linearGradient[id^=hp-sunrim-]')) {
|
||||
const stops = [...g.querySelectorAll('stop')];
|
||||
if (!g.__base) g.__base = stops.map((s) => Number(s.getAttribute('stop-opacity')));
|
||||
const p0 = g.__base[0] || 1;
|
||||
stops.forEach((s, i) => s.setAttribute('stop-opacity', (a * (g.__base[i] / p0)).toFixed(4)));
|
||||
}
|
||||
}, peak);
|
||||
await page.waitForTimeout(120);
|
||||
};
|
||||
|
||||
for (const scene of ['light', 'dark']) {
|
||||
await setup(scene);
|
||||
for (const a of ALPHAS) {
|
||||
await setAlpha(a);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/rim_${scene}_${String(a).replace('.', '')}.png` });
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -0,0 +1,48 @@
|
||||
// Stills for the 2026-08-04 ray report: 30 % shorter shafts with SHARP sides
|
||||
// that dissolve only along the ray (docs/SUN.md). The low-sun frame is the
|
||||
// interesting one — the north and south windows get a nearly wall-grazing sun,
|
||||
// the case that used to hang a bright kerb across the floor. Usage:
|
||||
// node demo/shot_sun_short.mjs <outdir> <prefix>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const tag = process.argv[3] || 'sun_rays_short';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no 45 s sky glide
|
||||
|
||||
const setup = async (az, el) => {
|
||||
await page.evaluate(async ([azimuth, elevation]) => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: 'off' },
|
||||
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth, elevation } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}, [az, el]);
|
||||
await page.waitForTimeout(350);
|
||||
};
|
||||
|
||||
// low sun in the west — the long shafts, the case that hit the far walls
|
||||
await setup(265, 5);
|
||||
let stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${tag}_low.png` });
|
||||
|
||||
// high southern sun — the short noon pools
|
||||
await setup(180, 55);
|
||||
stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${tag}_high.png` });
|
||||
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -21,14 +21,27 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const inPlan = c._baseVb();
|
||||
o.editorSeesWholeCanvas = inPlan[2] === 1000 && inPlan[3] === 1000;
|
||||
// docs/CANVAS.md: холста-квадрата больше нет, поэтому «редактор видит весь
|
||||
// холст» переписано в исходное НАМЕРЕНИЕ HP-1490-03 — «в редакторе есть куда
|
||||
// рисовать наружу». Рамка теперь по содержимому и в редакторе тоже, а место
|
||||
// даёт запас панорамирования (§5) и зум-аут до 3x.
|
||||
o.editorFrameIsContent = inPlan[2] < 999 && inPlan[2] > 0;
|
||||
o.editorCanPanPastContent = (() => {
|
||||
const fit = c._viewOr(c._baseVb());
|
||||
const moved = c._clampView({ x: fit.x + fit.w * 0.9, y: fit.y, w: fit.w, h: fit.h }, fit);
|
||||
return moved.x > fit.x + fit.w * 0.5; // ушли почти на экран вправо, зажима нет
|
||||
})();
|
||||
o.editorZoomOutGivesRoom = (() => {
|
||||
c._resetZoom(); c._applyView(1 / 3);
|
||||
const wide = c._view.w; c._resetZoom();
|
||||
return wide > inPlan[2] * 2.5; // видно втрое больше, чем нарисовано
|
||||
})();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
o.editorViewCoversCanvas = v.w >= 999; // старый cropped view не пережил смену
|
||||
// в редакторе можно ткнуть в дальний угол холста
|
||||
o.canReachFarCorner = (() => {
|
||||
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
|
||||
const pt = c._screenToVb(stage.clientWidth - 1, stage.clientHeight - 1);
|
||||
return pt[0] > 900 || pt[1] > 900;
|
||||
return pt[0] > inPlan[0] + inPlan[2] * 0.9 || pt[1] > inPlan[1] + inPlan[3] * 0.9;
|
||||
})();
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Backdrop image: move, uniform scale, and the new paper rule (docs/BACKDROP.md).
|
||||
*
|
||||
* The whole contract, on the demo's f1 — which IS an image plan, so every
|
||||
* assertion here lands on exactly the case that used to behave differently:
|
||||
* • the transform frame exists in the backdrop editor and nowhere else;
|
||||
* • dragging the body moves the picture and writes plan_x/plan_y;
|
||||
* • a corner handle scales UNIFORMLY about the opposite corner;
|
||||
* • live badges state the real size in metres, through cell_cm;
|
||||
* • position and size always land on the grid, including with Shift;
|
||||
* • the paper follows the ROOMS even here, and the picture is drawn above
|
||||
* the paper and below the walls;
|
||||
* • «Вписать всё» still finds a picture that has been dragged away;
|
||||
* • the static card reads the same three fields.
|
||||
*/
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
|
||||
const restore = () => page.evaluate((s) => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = JSON.parse(s);
|
||||
c._bdDrag = null;
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, snap);
|
||||
|
||||
const mode = (m) => page.evaluate((m) => {
|
||||
const c = window.__card;
|
||||
c._setMode(m); c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, m);
|
||||
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 };
|
||||
});
|
||||
/** The image rect AS RENDERED (attributes of the <image> in the stage svg). */
|
||||
const imageRect = () => page.evaluate(() => {
|
||||
const im = window.__card.renderRoot.querySelector('.stage svg image');
|
||||
if (!im) return null;
|
||||
const a = (n) => Number(im.getAttribute(n));
|
||||
return { x: a('x'), y: a('y'), w: a('width'), h: a('height') };
|
||||
});
|
||||
const frameRect = () => page.evaluate(() => {
|
||||
const b = window.__card.renderRoot.querySelector('.bdframe .bdbox');
|
||||
if (!b) return null;
|
||||
const a = (n) => Number(b.getAttribute(n));
|
||||
return { x: a('x'), y: a('y'), w: a('width'), h: a('height') };
|
||||
});
|
||||
const badges = () => page.evaluate(() =>
|
||||
[...window.__card.renderRoot.querySelectorAll('.measurelabel.bdmeasure')].map((e) => e.textContent.trim()));
|
||||
/** Screen position of a render-unit point (the svg viewBox fills the stage). */
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const stage = window.__card.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
const near = (a, b, tol = 1e-4) => Math.abs(a - b) <= tol;
|
||||
|
||||
// f1: plan_aspect 1.25 → the centred default is 1000×800 at (0,100)
|
||||
const BASE = { x: 0, y: 100, w: 1000, h: 800 };
|
||||
const PITCH = 1000 / 240;
|
||||
// "on a node", allowing for the 6 decimals the normalised config keeps
|
||||
// (5e-7 normalised ≈ 5e-4 render units ≈ 1e-4 of a step)
|
||||
const onGrid = (v, tol = 1e-3) => Math.abs(v / PITCH - Math.round(v / PITCH)) < tol;
|
||||
/** Metres a render-unit length is worth on the demo plan (cell_cm = 5). */
|
||||
const metres = (u) => (((u / PITCH) * 5) / 100).toFixed(2) + ' m';
|
||||
|
||||
// ---------- 1) the frame lives in the backdrop editor and nowhere else -----
|
||||
await mode('view');
|
||||
await settle();
|
||||
check('no_frame_in_view', await q('.bdframe'), 0);
|
||||
await mode('plan');
|
||||
await settle();
|
||||
check('no_frame_in_plan', await q('.bdframe'), 0);
|
||||
const tool = (t) => page.evaluate((t) => {
|
||||
const c = window.__card;
|
||||
c._decorTool = t; c._decorDraft = null; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, 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);
|
||||
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('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);
|
||||
// a drawing tool owns the drag instead — no frame competing with it
|
||||
await tool('rect');
|
||||
await settle();
|
||||
check('no_frame_under_a_drawing_tool', await q('.bdframe'), 0);
|
||||
await tool('backdrop');
|
||||
await settle();
|
||||
|
||||
// ---------- 2) drag the body: the picture moves, the plan does not --------
|
||||
const roomsBefore = await page.evaluate(() =>
|
||||
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms));
|
||||
{
|
||||
const [ax, ay] = await screenPt(500, 500);
|
||||
// +102 / +52 render units: neither is a whole number of grid steps, so the
|
||||
// snap has something to do (24 steps = 100, 12 steps = 50)
|
||||
const [bx, by] = await screenPt(602, 552);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 3 });
|
||||
await settle();
|
||||
const live = await badges();
|
||||
check('live_badge_while_dragging', live.length, 1);
|
||||
// the demo's cell_cm is 5 → the untouched picture is 240 cells wide, i.e.
|
||||
// 12.00 m by 9.60 m. Moving does not change the size, and the badge says so.
|
||||
check('live_badge_is_the_real_size', live[0], `${metres(BASE.w)} × ${metres(BASE.h)}`);
|
||||
check('live_badge_is_metres', live[0], '12.00 m × 9.60 m');
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
}
|
||||
check('badge_gone_after_release', (await badges()).length, 0);
|
||||
{
|
||||
const cfg = await spaceCfg();
|
||||
const im = await imageRect();
|
||||
// the gesture asked for +102/+52 render units; the pointer arrives in whole
|
||||
// screen pixels, so the ASSERTION is the contract, not the arithmetic: the
|
||||
// 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('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).
|
||||
// Exact, and it is meant to be — nothing may rescale the stage mid-gesture
|
||||
// (the frame is frozen while _bdDrag is live; the toolbar does not reflow),
|
||||
// or the picture would run away from the finger.
|
||||
check('landed_on_the_node_nearest_the_finger', [im.x, im.y], [100, 150]);
|
||||
check('frame_follows_the_picture', await frameRect(), im);
|
||||
}
|
||||
check('the_plan_geometry_never_moved', await page.evaluate(() =>
|
||||
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms)), roomsBefore);
|
||||
|
||||
// ---------- 3) Shift cannot step off the grid -----------------------------
|
||||
await restore();
|
||||
await settle();
|
||||
{
|
||||
const [ax, ay] = await screenPt(500, 500);
|
||||
const [bx, by] = await screenPt(602, 552);
|
||||
await page.keyboard.down('Shift');
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await page.keyboard.up('Shift');
|
||||
await settle();
|
||||
const im = await imageRect();
|
||||
check('shift_still_lands_on_the_nearest_node', [im.x, im.y], [100, 150]);
|
||||
check('shift_stays_on_the_lattice', onGrid(im.x) && onGrid(im.y), true);
|
||||
}
|
||||
|
||||
// ---------- 4) a corner handle scales UNIFORMLY about the opposite one ----
|
||||
await restore();
|
||||
await settle();
|
||||
{
|
||||
// the bottom-right corner of the default rect is (1000, 900); pull it in to
|
||||
// (700, ...) — the top-left corner (0,100) must not budge and the ratio must
|
||||
// stay 1.25, whatever the vertical component of the gesture says
|
||||
const [ax, ay] = await screenPt(BASE.x + BASE.w, BASE.y + BASE.h);
|
||||
const [bx, by] = await screenPt(700, 600);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await settle();
|
||||
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 3 });
|
||||
await settle();
|
||||
check('live_badge_while_scaling', (await badges()).length, 1);
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const im = await imageRect();
|
||||
check('opposite_corner_stayed_put', near(im.x, BASE.x, 1e-6) && near(im.y, BASE.y, 1e-6), true);
|
||||
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);
|
||||
// …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;
|
||||
c._bdDrag = { kind: 'move', pid: 1, sx: 0, sy: 0, base: { x: 0, y: 100, w: 1000, h: 800 },
|
||||
p0: { dx: 0, dy: 0, k: 1 }, fx: 0, fy: 0, sgx: 0, sgy: 0, moved: false };
|
||||
c.requestUpdate();
|
||||
return c.updateComplete.then(() =>
|
||||
c.renderRoot.querySelector('.measurelabel.bdmeasure')?.textContent.trim() ?? null);
|
||||
}), `${metres(im.w)} × ${metres(im.h)}`);
|
||||
await page.evaluate(() => { const c = window.__card; c._bdDrag = null; c.requestUpdate(); return c.updateComplete; });
|
||||
}
|
||||
|
||||
// ---------- 5) the reset button ------------------------------------------
|
||||
check('reset_button_offered_once_moved', await q('.decorbar .bdreset'), 1);
|
||||
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);
|
||||
|
||||
// ---------- 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
|
||||
// tool would take away the one-finger pan (docs/BACKDROP.md §2), which is why
|
||||
// moving is a tool. smoke_pan_any_zoom guards the same thing from the outside.
|
||||
await tool('select');
|
||||
await settle();
|
||||
check('select_tool_still_pans_over_the_picture', await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const before = { ...c._viewOr(c._baseVb()) };
|
||||
c._stagePointerDown({ pointerId: 91, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 91, clientX: 360, clientY: 350 });
|
||||
const after = { ...c._viewOr(c._baseVb()) };
|
||||
c._stagePointerUp({ pointerId: 91, clientX: 360, clientY: 350 });
|
||||
await c.updateComplete;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
return Math.abs(after.x - before.x) > 1 && sp.plan_x == null;
|
||||
}), true);
|
||||
|
||||
// ---------- 6) the paper is the ROOMS, and the picture sits on it ---------
|
||||
await mode('view');
|
||||
await settle();
|
||||
const layers = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const m = c._spaceModel();
|
||||
const svgEl = c.renderRoot.querySelector('.stage svg');
|
||||
const kids = [...svgEl.children];
|
||||
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
const papers = [...svgEl.querySelectorAll('.hp-paper')];
|
||||
return {
|
||||
paperShapes: papers.length,
|
||||
rooms: m.rooms.length,
|
||||
// not one paper shape the size of the picture any more
|
||||
imageSizedPaper: papers.some((p) => p.tagName === 'rect'
|
||||
&& Math.abs(Number(p.getAttribute('width')) - m.bg.w) < 1e-6
|
||||
&& Math.abs(Number(p.getAttribute('height')) - m.bg.h) < 1e-6),
|
||||
iPaper: idx('.hp-paper'), iImage: idx('image'), iRoom: idx('.room-outline, .room'),
|
||||
};
|
||||
});
|
||||
check('paper_is_one_shape_per_room', layers.paperShapes, layers.rooms);
|
||||
check('no_paper_the_size_of_the_picture', layers.imageSizedPaper, false);
|
||||
check('picture_is_above_the_paper', layers.iImage > layers.iPaper && layers.iPaper >= 0, true);
|
||||
check('picture_is_below_the_walls', layers.iImage < layers.iRoom, true);
|
||||
|
||||
// ---------- 6b) editors keep a WHITE sheet under the grid with a backdrop --
|
||||
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 probe = async (mode) => {
|
||||
c._setMode(mode);
|
||||
await upd();
|
||||
const stage = sr().querySelector('.stage');
|
||||
const paper = sr().querySelector('.stage svg .hp-paper');
|
||||
return {
|
||||
mode,
|
||||
stageWhite: getComputedStyle(stage).backgroundColor === 'rgb(255, 255, 255)',
|
||||
paperWhite: !paper || getComputedStyle(paper).fill === 'rgb(255, 255, 255)',
|
||||
hasBg: !!c._spaceModel().bg,
|
||||
noNoplan: !stage.classList.contains('noplan'),
|
||||
};
|
||||
};
|
||||
const plan = await probe('plan');
|
||||
const devices = await probe('devices');
|
||||
const decor = await probe('decor');
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
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)'
|
||||
&& !viewStage.classList.contains('noplan');
|
||||
return { plan, devices, decor, viewForcedWhite, hasBg: plan.hasBg };
|
||||
});
|
||||
check('editors_have_backdrop', editorWhite.hasBg, true);
|
||||
check('plan_editor_stage_white_with_backdrop', editorWhite.plan.stageWhite && editorWhite.plan.noNoplan, true);
|
||||
check('plan_editor_paper_white_with_backdrop', editorWhite.plan.paperWhite, true);
|
||||
check('devices_editor_stage_white_with_backdrop', editorWhite.devices.stageWhite, true);
|
||||
check('decor_editor_stage_white_with_backdrop', editorWhite.decor.stageWhite, true);
|
||||
check('view_with_backdrop_is_not_forced_white', editorWhite.viewForcedWhite, false);
|
||||
|
||||
// ---------- 7) «Вписать всё» keeps a picture that was dragged away --------
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.plan_x = 1.5; sp.plan_y = 1.5; sp.plan_scale = 0.5;
|
||||
c._cfgEpoch++; c._frame = null; c.requestUpdate();
|
||||
return c.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
const fit = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._fitAll();
|
||||
const b = c._baseVb();
|
||||
const bg = c._spaceModel().bg;
|
||||
return { b, bg };
|
||||
});
|
||||
check('the_frame_reaches_the_moved_picture',
|
||||
fit.b[0] <= fit.bg.x && fit.b[1] <= fit.bg.y
|
||||
&& fit.b[0] + fit.b[2] >= fit.bg.x + fit.bg.w
|
||||
&& fit.b[1] + fit.b[3] >= fit.bg.y + fit.bg.h, true);
|
||||
check('and_the_moved_picture_is_where_the_transform_says',
|
||||
near(fit.bg.x, 1500) && near(fit.bg.y, 1600) && near(fit.bg.w, 500) && near(fit.bg.h, 400), true);
|
||||
|
||||
// ---------- 8) the static card reads the same three fields ---------------
|
||||
const stat = await page.evaluate(async () => {
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const main = window.__card;
|
||||
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
const hass = { ...main.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {} };
|
||||
return { ok: true };
|
||||
} };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('.hp-static-stage svg image') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const root = card.renderRoot.querySelector('.hp-static-stage svg');
|
||||
const im = root.querySelector('image');
|
||||
const a = (n) => Number(im.getAttribute(n));
|
||||
const kids = [...root.children];
|
||||
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
return {
|
||||
rect: { x: a('x'), y: a('y'), w: a('width'), h: a('height') },
|
||||
papers: root.querySelectorAll('.hp-paper').length,
|
||||
imageSizedPaper: [...root.querySelectorAll('.hp-paper')].some((p) => p.tagName === 'rect'
|
||||
&& Math.abs(Number(p.getAttribute('width')) - a('width')) < 1e-6),
|
||||
iPaper: idx('.hp-paper'), iImage: idx('image'),
|
||||
};
|
||||
});
|
||||
check('static_card_applies_the_transform', stat.rect, { x: 1500, y: 1600, w: 500, h: 400 });
|
||||
check('static_card_papers_the_rooms', stat.papers, 4);
|
||||
check('static_card_has_no_picture_paper', stat.imageSizedPaper, false);
|
||||
check('static_card_draws_the_picture_on_the_paper', stat.iImage > stat.iPaper, true);
|
||||
|
||||
await restore();
|
||||
await finish(browser);
|
||||
+20
-10
@@ -48,7 +48,7 @@ const res = await page.evaluate(async () => {
|
||||
};
|
||||
c._openSettingsDialog();
|
||||
await c.updateComplete;
|
||||
out.dialogHasBgRow = !!sr().querySelector('.dialog .colorrow input[type=color]');
|
||||
out.dialogHasBgRow = !!sr().querySelector('hp-dialog .colorrow input[type=color]');
|
||||
c._settingsDialog = { ...c._settingsDialog, bgColor: '#0a2a4a' };
|
||||
out.dialogPreviews = (await stageBg()) === rgb('#0a2a4a');
|
||||
await c._saveSettingsDialog();
|
||||
@@ -144,9 +144,11 @@ checkAll(res);
|
||||
|
||||
// ---- 11) opaque plan paper (owner 2026-08-03) --------------------------
|
||||
// The scene bg_color must never bleed through the plan itself: opaque
|
||||
// .hp-paper shapes sit under everything the plan draws. An IMAGE plan papers
|
||||
// the backdrop image rect (the canvas is the paper); a DRAWN plan papers the
|
||||
// ROOM CONTOURS — one shape per room, never their bounding box (section 12).
|
||||
// .hp-paper shapes sit under everything the plan draws. Since v1.58.0 the
|
||||
// paper is the ROOM CONTOURS and ONLY them — one shape per room, never their
|
||||
// bounding box (section 12), and never the backdrop image rect either
|
||||
// (docs/BACKDROP.md §3). The demo's f1 IS an image plan, so this section now
|
||||
// asserts the new rule on exactly the case that used to be the exception.
|
||||
// The scene colour is visible ONLY around the paper; the 'daynight' night
|
||||
// dims the paper via brightness only.
|
||||
const paper = await page.evaluate(async () => {
|
||||
@@ -158,20 +160,28 @@ const paper = await page.evaluate(async () => {
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
await upd();
|
||||
const m = c._spaceModel();
|
||||
const p = sr().querySelector('.stage svg rect.hp-paper');
|
||||
const shapes = () => [...sr().querySelectorAll('.stage svg .hp-paper')];
|
||||
const p = shapes()[0];
|
||||
out.paperExists = !!p;
|
||||
out.imagePlanPapersTheRooms = shapes().length === m.rooms.length;
|
||||
// …and NOT the image: no paper rect the size of the backdrop any more
|
||||
out.noImageSizedPaper = ![...sr().querySelectorAll('.stage svg rect.hp-paper')].some((r) =>
|
||||
Math.abs(Number(r.getAttribute('width')) - m.bg.w) < 1e-6
|
||||
&& Math.abs(Number(r.getAttribute('height')) - m.bg.h) < 1e-6);
|
||||
if (p) {
|
||||
const a = (n) => Number(p.getAttribute(n));
|
||||
out.paperHugsImage = Math.abs(a('x') - m.bg.x) < 1e-6 && Math.abs(a('y') - m.bg.y) < 1e-6
|
||||
&& Math.abs(a('width') - m.bg.w) < 1e-6 && Math.abs(a('height') - m.bg.h) < 1e-6;
|
||||
const cs = getComputedStyle(p);
|
||||
out.paperOpaque = cs.fillOpacity === '1' && cs.opacity === '1'
|
||||
&& cs.fill !== 'none' && !/rgba\(.*,\s*0\)/.test(cs.fill);
|
||||
}
|
||||
// …and the picture is drawn ON that paper: image after paper, before walls
|
||||
const kids = [...sr().querySelector('.stage svg').children];
|
||||
const idxOf = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
out.imageAbovePaper = idxOf('image') > idxOf('.hp-paper') && idxOf('image') >= 0;
|
||||
out.imageBelowRooms = idxOf('image') < idxOf('.room, .room-outline');
|
||||
// editors keep the paper too (their canvas ignores bg_color anyway)
|
||||
c._setMode('decor');
|
||||
await upd();
|
||||
out.editorKeepsPaper = !!sr().querySelector('.stage svg rect.hp-paper');
|
||||
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
// daynight night: the paper dims via the brightness filter ONLY, never alpha
|
||||
@@ -184,7 +194,7 @@ const paper = await page.evaluate(async () => {
|
||||
} } };
|
||||
await upd();
|
||||
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
|
||||
const pn = sr().querySelector('.stage svg rect.hp-paper');
|
||||
const pn = sr().querySelector('.stage svg .hp-paper');
|
||||
out.nightPaperStaysOpaque = !!pn && getComputedStyle(pn).fillOpacity === '1' && getComputedStyle(pn).opacity === '1';
|
||||
delete c._serverCfg.settings.bg_mode;
|
||||
delete c._serverCfg.settings.north_deg;
|
||||
|
||||
@@ -15,7 +15,7 @@ const res = await page.evaluate(async () => {
|
||||
radios()[1].click(); await c.updateComplete;
|
||||
out.dropShown = !!sr().querySelector('.dropbtn');
|
||||
out.panelOpen = !!sr().querySelector('.droppanel');
|
||||
const saveBtn = [...sr().querySelectorAll('.dialog .btn.on')].pop();
|
||||
const saveBtn = [...sr().querySelectorAll('hp-dialog .btn.on')].pop();
|
||||
out.saveDisabled = saveBtn?.disabled === true;
|
||||
// без чекбокса в списке нет individual-сущностей устройств
|
||||
const subs = () => [...sr().querySelectorAll('.cand .cs')].map((e) => e.textContent);
|
||||
@@ -32,7 +32,7 @@ const res = await page.evaluate(async () => {
|
||||
sr().querySelector('.cand').click(); await c.updateComplete;
|
||||
out.picked = c._markerDialog.binding !== '' && c._markerDialog.binding !== 'virtual';
|
||||
out.panelClosed = !sr().querySelector('.droppanel');
|
||||
out.saveEnabled = [...sr().querySelectorAll('.dialog .btn.on')].pop()?.disabled === false;
|
||||
out.saveEnabled = [...sr().querySelectorAll('hp-dialog .btn.on')].pop()?.disabled === false;
|
||||
// радио назад на «Виртуальное» → binding=virtual
|
||||
radios()[0].click(); await c.updateComplete;
|
||||
out.backToVirtual = c._markerDialog.binding === 'virtual' && c._markerDialog.bindingMode === 'virtual';
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// The content frame is PRESENTATION (docs/CANVAS.md §4) — audit dev@2c947f4.
|
||||
//
|
||||
// DEV-2C947-01: a hidden device is not drawn, but it still stretched the
|
||||
// frame — hiding a marker that had wandered into the yard left the visible
|
||||
// house a dot in the corner (the full card AND the static
|
||||
// houseplan-space-card, which drew `devs` but framed `spaceDevs`).
|
||||
//
|
||||
// DEV-2C947-02: inside an editor the frame only grows (a frame that shrank
|
||||
// mid-drag would move the ground under the pointer); that union was memoised
|
||||
// without the mode, so it survived the way back into View.
|
||||
//
|
||||
// DEV-2C947-03: a room-outlier is rejected from the frame, but iconUnit()
|
||||
// measured ALL rooms — so the stray it had just rejected still blew every
|
||||
// marker of the main plan up ~91x.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
const out = {};
|
||||
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
|
||||
// Load a config/layout into the card AND into the demo's WS stub, so the
|
||||
// static card (which fetches its own copy) sees exactly the same house.
|
||||
const load = (cfg, layout, space) => page.evaluate(async ([c2, l2, sp]) => {
|
||||
const c = window.__card;
|
||||
const srvCfg = (await c.hass.callWS({ type: 'houseplan/config/get' })).config;
|
||||
const srvLay = (await c.hass.callWS({ type: 'houseplan/layout/get' })).layout;
|
||||
for (const k of Object.keys(srvCfg)) delete srvCfg[k];
|
||||
Object.assign(srvCfg, JSON.parse(JSON.stringify(c2)));
|
||||
for (const k of Object.keys(srvLay)) delete srvLay[k];
|
||||
Object.assign(srvLay, JSON.parse(JSON.stringify(l2)));
|
||||
c._serverCfg = srvCfg;
|
||||
c._layout = JSON.parse(JSON.stringify(l2));
|
||||
c._modelCache = null;
|
||||
c._frame = null;
|
||||
c._showFar = false;
|
||||
c._space = sp;
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions();
|
||||
c._view = null;
|
||||
c._zoom = 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._resetZoom();
|
||||
await c.updateComplete;
|
||||
}, [cfg, layout, space]);
|
||||
|
||||
// ---------------------------------------------------------------- 01: hidden
|
||||
// The auditor's probe, verbatim: ONE visible room and ONE marker parked 90
|
||||
// canvases away. Two content items is below MIN_VOTERS, so the outlier vote
|
||||
// never runs and the far position is guaranteed to stay in the core — the
|
||||
// frame either sees the marker or it does not.
|
||||
const oneRoom = (hidden) => ({
|
||||
spaces: [{
|
||||
id: 'h1', title: 'Hidden', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
|
||||
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]] }],
|
||||
}],
|
||||
// a made-up area: no auto device of the demo house lands in this space, so
|
||||
// the only content is the room and the one marker below
|
||||
markers: [{ id: 'm_far', binding: 'device:d_motion', area: 'zx_room', hidden }],
|
||||
settings: { filter_seeded: true },
|
||||
});
|
||||
const FAR_POS = { m_far: { s: 'h1', x: 90, y: 0.5 } };
|
||||
|
||||
await load(oneRoom(false), FAR_POS, 'h1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const b = c._baseVb();
|
||||
return {
|
||||
// premise: while it IS drawn, the marker is content and the frame holds it
|
||||
visibleMarkerIsDrawn: sr.querySelectorAll('.devlayer .dev').length === 1,
|
||||
visibleMarkerWidensTheFrame: b[2] > 50000,
|
||||
};
|
||||
}));
|
||||
|
||||
await load(oneRoom(true), FAR_POS, 'h1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
const b = c._baseVb();
|
||||
o.hiddenMarkerNotDrawn = sr.querySelectorAll('.devlayer .dev').length === 0;
|
||||
o.hiddenMarkerStillBuilt = !!c._devices.find((d) => d.id === 'm_far' && d.hidden === true);
|
||||
// the room is 100..900 render units, padded by 5 % of the longer side
|
||||
o.frameIsTheVisibleRoom = Math.round(b[0]) === 60 && Math.round(b[2]) === 880
|
||||
&& Math.round(b[1]) === 60 && Math.round(b[3]) === 880;
|
||||
// and the room fills the screen instead of being a dot 112x too small
|
||||
const st = sr.querySelector('.stage').getBoundingClientRect();
|
||||
const poly = sr.querySelector('polygon.room').getBoundingClientRect();
|
||||
o.roomFillsTheStage = poly.width > st.width * 0.7;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// the same house on the STATIC card (space-render.ts drew `devs` but framed
|
||||
// `spaceDevs` — the identical split)
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const o = {};
|
||||
const host = document.createElement('div');
|
||||
host.style.cssText = 'width:400px;height:400px';
|
||||
document.body.appendChild(host);
|
||||
const el = document.createElement('houseplan-space-card');
|
||||
el.setConfig({ type: 'custom:houseplan-space-card', space: 'h1' });
|
||||
el.hass = window.__card.hass;
|
||||
host.appendChild(el);
|
||||
const t0 = Date.now();
|
||||
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
}
|
||||
await el.updateComplete;
|
||||
const svg = el.renderRoot.querySelector('.hp-static-stage svg');
|
||||
const vb = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
||||
o.staticCardHidesTheMarker = el.renderRoot.querySelectorAll('.devlayer .dev').length === 0;
|
||||
o.staticFrameIsTheVisibleRoom = vb.length === 4
|
||||
&& Math.round(vb[0]) === 60 && Math.round(vb[2]) === 880;
|
||||
host.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ------------------------------------------------------- 02: editor -> view
|
||||
const oneRoomAt = (x0) => ({
|
||||
spaces: [{
|
||||
id: 'e1', title: 'Editor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
|
||||
poly: [[x0, 0.1], [x0 + 0.8, 0.1], [x0 + 0.8, 0.9], [x0, 0.9]] }],
|
||||
}],
|
||||
markers: [], settings: { filter_seeded: true },
|
||||
});
|
||||
|
||||
await load(oneRoomAt(0.1), {}, 'e1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const round = () => c._baseVb().map((n) => Math.round(n));
|
||||
o.viewFrameBefore = JSON.stringify(round()) === JSON.stringify([60, 60, 880, 880]);
|
||||
// move the only room 5 canvases to the right, inside the Plan editor
|
||||
c._setMode('plan');
|
||||
await c.updateComplete;
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].rooms[0].poly = [[5.1, 0.1], [5.9, 0.1], [5.9, 0.9], [5.1, 0.9]];
|
||||
c._serverCfg = cfg;
|
||||
c._modelCache = null;
|
||||
c._cfgEpoch++;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const inEditor = round();
|
||||
// inside the editor the frame is the UNION — that is deliberate: it bounds
|
||||
// pan and defines zoom 1, and it must not shrink under a live gesture
|
||||
o.editorFrameOnlyGrows = inEditor[2] > 5000;
|
||||
c._setMode('view');
|
||||
await c.updateComplete;
|
||||
const back = round();
|
||||
o.viewFrameFollowsTheRoomAgain = JSON.stringify(back) === JSON.stringify([5060, 60, 880, 880]);
|
||||
if (!o.viewFrameFollowsTheRoomAgain) console.log('frame after exit', back, 'editor', inEditor);
|
||||
// and back into an editor it starts from the CURRENT geometry, not the old union
|
||||
c._setMode('plan');
|
||||
await c.updateComplete;
|
||||
o.reenteringTheEditorDoesNotResurrectTheUnion =
|
||||
JSON.stringify(round()) === JSON.stringify([5060, 60, 880, 880]);
|
||||
c._setMode('view');
|
||||
await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------- 03: icons and outliers
|
||||
// Three rooms in the core plus one dragged 90 canvases out. The frame rejects
|
||||
// the stray (that part always worked); the ICON UNIT used to measure all four
|
||||
// rooms, so every marker of the plan the user is looking at came out ~91x too
|
||||
// big and covered the house.
|
||||
const withFarRoom = (far) => ({
|
||||
spaces: [{
|
||||
id: 'i1', title: 'Icons', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [
|
||||
{ id: 'r1', name: 'A', area: 'zx_a', poly: [[0.10, 0.10], [0.30, 0.10], [0.30, 0.30], [0.10, 0.30]] },
|
||||
{ id: 'r2', name: 'B', area: 'zx_b', poly: [[0.30, 0.10], [0.55, 0.10], [0.55, 0.35], [0.30, 0.35]] },
|
||||
{ id: 'r3', name: 'C', area: 'zx_c', poly: [[0.10, 0.30], [0.35, 0.30], [0.35, 0.55], [0.10, 0.55]] },
|
||||
...(far ? [{ id: 'r4', name: 'Far', area: 'zx_d', poly: [[90, 90], [91, 90], [91, 91], [90, 91]] }] : []),
|
||||
],
|
||||
}],
|
||||
markers: [{ id: 'm_one', binding: 'device:d_light1', area: 'zx_a' }],
|
||||
settings: { filter_seeded: true },
|
||||
});
|
||||
const ICON_POS = { m_one: { s: 'i1', x: 0.2, y: 0.2 } };
|
||||
|
||||
const iconPx = () => page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const el = sr.querySelector('.devlayer .dev');
|
||||
// the badge is --dev-size plus a 1 px border on each side (smoke_icon_scale)
|
||||
return { px: el ? Math.round((el.getBoundingClientRect().width - 2) * 100) / 100 : null,
|
||||
outliers: c._outliers, frameW: Math.round(c._baseVb()[2]) };
|
||||
});
|
||||
|
||||
await load(withFarRoom(false), ICON_POS, 'i1');
|
||||
await raf();
|
||||
const plain = await iconPx();
|
||||
await load(withFarRoom(true), ICON_POS, 'i1');
|
||||
await raf();
|
||||
const strayed = await iconPx();
|
||||
out.strayRoomIsRejectedFromTheFrame = strayed.outliers === 1 && strayed.frameW < 1000;
|
||||
out.strayRoomDoesNotInflateTheIcons =
|
||||
plain.px > 4 && Math.abs(strayed.px - plain.px) < 0.5;
|
||||
if (!out.strayRoomDoesNotInflateTheIcons) console.log('icon px', plain, strayed);
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -13,7 +13,7 @@ const res = await page.evaluate(async () => {
|
||||
out.hasDev = !!dev;
|
||||
c._infoCard = dev; await c.updateComplete;
|
||||
// 1) блок сущностей идёт ПЕРВЫМ, до модели/ссылок
|
||||
const body = sr().querySelector('.dialog .body');
|
||||
const body = sr().querySelector('hp-dialog .body');
|
||||
out.entListFirst = body.firstElementChild?.classList.contains('entlist')
|
||||
|| body.querySelector('.entlist') === body.children[0];
|
||||
const rows = [...sr().querySelectorAll('.entrow')];
|
||||
|
||||
@@ -45,6 +45,19 @@ const res = await page.evaluate(async () => {
|
||||
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.delroomIgnored = !confirmCalled;
|
||||
// 6) Delete has exactly one semantic result: the clicked room disappears;
|
||||
// it never closes a virtual boundary or merges a neighbour into it.
|
||||
const roomCount = c._curSpaceCfg.rooms.length;
|
||||
const deleteId = room.id;
|
||||
window.confirm = () => true;
|
||||
c._deleteRoomClick(c._roomCenter(room));
|
||||
await c.updateComplete;
|
||||
out.deleteOnlyClickedRoom = c._curSpaceCfg.rooms.length === roomCount - 1
|
||||
&& !c._curSpaceCfg.rooms.some((x) => x.id === deleteId);
|
||||
c._undoGeometry();
|
||||
await c.updateComplete;
|
||||
out.deleteUndoRestoresRoom = c._curSpaceCfg.rooms.length === roomCount
|
||||
&& c._curSpaceCfg.rooms.some((x) => x.id === deleteId);
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
@@ -61,12 +61,12 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
// у обычного термометра чекбокса нет
|
||||
const th = c._devices.find((d) => d.entities?.includes('sensor.living_temp'));
|
||||
c._openMarkerDialog(th); await c.updateComplete;
|
||||
o.noCheckboxForThermometer = !sr().querySelector('.dialog .climrow');
|
||||
o.noCheckboxForThermometer = !sr().querySelector('hp-dialog .climrow');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
// у кондиционера — есть и по умолчанию снят
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
c._openMarkerDialog(ac); await c.updateComplete;
|
||||
const row = sr().querySelector('.dialog .climrow input');
|
||||
const row = sr().querySelector('hp-dialog .climrow input');
|
||||
o.checkboxForClimate = !!row;
|
||||
o.uncheckedByDefault = !!row && !row.checked;
|
||||
if (row) row.scrollIntoView({ block: 'center' });
|
||||
@@ -77,7 +77,7 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const r = sr.querySelector('.dialog .climrow input')?.getBoundingClientRect();
|
||||
const r = sr.querySelector('hp-dialog .climrow input')?.getBoundingClientRect();
|
||||
return r ? { x: r.x + r.width / 2, y: r.y + r.height / 2 } : null;
|
||||
});
|
||||
check('checkboxClickable', !!box);
|
||||
@@ -138,7 +138,7 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
c._openMarkerDialog(ac); await c.updateComplete;
|
||||
o.survivesReopen = c._markerDialog?.useClimateTemp === true;
|
||||
o.checkboxChecked = sr().querySelector('.dialog .climrow input')?.checked === true;
|
||||
o.checkboxChecked = sr().querySelector('hp-dialog .climrow input')?.checked === true;
|
||||
c._markerDialog = null;
|
||||
return o;
|
||||
}));
|
||||
|
||||
@@ -19,7 +19,8 @@ const res = await page.evaluate(async () => {
|
||||
c._openMarkerDialog();
|
||||
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Выключатель', binding: 'virtual',
|
||||
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights, icon: 'mdi:light-switch' };
|
||||
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights,
|
||||
icon: 'mdi:light-switch', display: 'icon_ripple' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => d.name === 'Выключатель');
|
||||
out.markerSaved = !!dev && JSON.stringify(dev.marker.controls) === JSON.stringify(lights);
|
||||
@@ -36,6 +37,7 @@ const res = await page.evaluate(async () => {
|
||||
// 3) значок отражает цели: горит одна → класс on
|
||||
const el = [...sr().querySelectorAll('.dev')].find((e) => e.title === 'Выключатель' || e.textContent.includes(''));
|
||||
out.stateOn = !!c._stateClass(dev2()).includes('on');
|
||||
out.aggregateActivityRuns = c._stateClass(dev2()).includes('activity-running');
|
||||
await setSt({ [lights[0]]: 'off' });
|
||||
out.stateOff = c._stateClass(dev2()) === '';
|
||||
// 4) без tap_action=toggle клик НЕ переключает (инфо)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// A cover NEVER wears a coloured plate (owner's contract 2026-08-04):
|
||||
// «у штор не должно быть жёлтой подложки никогда, индикация открыто/закрыто
|
||||
// за счёт морфинга иконки».
|
||||
//
|
||||
// Before this the domain shared one branch with `valve`: 'open'/'opening' put
|
||||
// the orange «открыто» frame on the badge (.dev.open — a FILLED orange plate,
|
||||
// --hp-open #ff9f43, not just a border), and the travelling states carried it
|
||||
// underneath the breathing ring. Now a cover returns no plate class at all:
|
||||
//
|
||||
// closed / open / ajar -> neutral plate, the ICON tells the state
|
||||
// opening / closing -> neutral plate + activity-transition when the
|
||||
// marker opts into «Icon + activity»
|
||||
//
|
||||
// The frame itself is NOT gone: door/window binary sensors and locks still
|
||||
// wear it, and a valve keeps it too (nothing morphs its icon). Those are
|
||||
// re-checked at the bottom, or «no yellow for curtains» would quietly cost
|
||||
// the whole house its «открыто» indication.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// --- colour probes: resolve the tokens the same way the badge does ------
|
||||
const probe = (css) => {
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
|
||||
sr().appendChild(d);
|
||||
const v = getComputedStyle(d).backgroundColor;
|
||||
d.remove();
|
||||
return v;
|
||||
};
|
||||
const YELLOW = probe('var(--hp-on)'); // «включено»
|
||||
const ORANGE = probe('var(--hp-open)'); // «открыто» frame
|
||||
const NEUTRAL = probe('var(--hp-bg)'); // the plain badge
|
||||
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
|
||||
o.yellowIsTheYellow = YELLOW === 'rgb(255, 212, 92)';
|
||||
o.orangeIsTheOpenFrame = ORANGE === 'rgb(255, 159, 67)';
|
||||
|
||||
// --- the gate marker, alone in the Garden, told it IS the curtain -------
|
||||
const setMarker = async () => {
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover', display: 'icon_ripple' },
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const setCover = async (state, attrs = {}) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'cover.gate': { entity_id: 'cover.gate', state, attributes: { friendly_name: 'Gate', ...attrs } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
// .dev animates `background 0.15s` — a colour read in the same frame is
|
||||
// still mid-fade and would pass on a plate that ends up orange
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
};
|
||||
c._setMode('view');
|
||||
c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await setMarker();
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
|
||||
const devEl = () => sr().querySelector('.devlayer .dev');
|
||||
const cls = () => [...(devEl()?.classList || [])];
|
||||
const plate = () => getComputedStyle(devEl()).backgroundColor;
|
||||
const icon = () => devEl()?.querySelector('ha-icon')?.getAttribute('icon') || '';
|
||||
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.devlayer .dev').length === 1;
|
||||
|
||||
/** One state: the plate must be the neutral one, and nothing else. */
|
||||
const plateIsNeutral = (tag) => {
|
||||
o[tag + 'PlateNeutral'] = plate() === NEUTRAL;
|
||||
o[tag + 'NotYellow'] = plate() !== YELLOW;
|
||||
o[tag + 'NotOrange'] = plate() !== ORANGE;
|
||||
o[tag + 'NoPlateClass'] = !cls().includes('on') && !cls().includes('open');
|
||||
};
|
||||
|
||||
plateIsNeutral('closed');
|
||||
o.closedNoRing = !cls().includes('activity-transition');
|
||||
o.closedIconClosed = icon() === 'mdi:curtains-closed';
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
plateIsNeutral('open');
|
||||
// This fixture jumps closed → open without an intermediate HA state, so
|
||||
// the unified resolver supplies the agreed ~3.3 s transition fallback.
|
||||
o.openDirectTransition = cls().includes('activity-transition');
|
||||
o.openIconOpen = icon() === 'mdi:curtains';
|
||||
|
||||
// ajar: HA reports a positioned curtain as plain 'open' — it reads as OPEN
|
||||
await setCover('open', { device_class: 'curtain', current_position: 40 });
|
||||
plateIsNeutral('ajar');
|
||||
o.ajarIconOpen = icon() === 'mdi:curtains';
|
||||
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
plateIsNeutral('opening');
|
||||
o.openingRings = cls().includes('activity-transition');
|
||||
o.openingRingIsTheBreathingOne = (() => {
|
||||
const ring = devEl()?.querySelector('.activity-ring.transition i:first-child');
|
||||
const cs = ring ? getComputedStyle(ring) : null;
|
||||
return !!cs && cs.animationName === 'hp-activity-breathe' && cs.animationDuration === '2.2s';
|
||||
})();
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
plateIsNeutral('closing');
|
||||
o.closingRings = cls().includes('activity-transition');
|
||||
|
||||
// --- the morph covers every class, both ways ---------------------------
|
||||
const morph = async (dc, closedIcon, openIcon) => {
|
||||
await setCover('closed', dc ? { device_class: dc } : {});
|
||||
const a = icon();
|
||||
await setCover('open', dc ? { device_class: dc } : {});
|
||||
const b = icon();
|
||||
return a === closedIcon && b === openIcon && a !== b;
|
||||
};
|
||||
o.morphBlind = await morph('blind', 'mdi:blinds', 'mdi:blinds-open');
|
||||
o.morphShade = await morph('shade', 'mdi:blinds', 'mdi:blinds-open');
|
||||
o.morphShutter = await morph('shutter', 'mdi:window-shutter', 'mdi:window-shutter-open');
|
||||
o.morphCurtain = await morph('curtain', 'mdi:curtains-closed', 'mdi:curtains');
|
||||
o.morphWindow = await morph('window', 'mdi:window-closed', 'mdi:window-open');
|
||||
o.morphAwning = await morph('awning', 'mdi:awning-outline', 'mdi:awning');
|
||||
o.morphDoor = await morph('door', 'mdi:door-closed', 'mdi:door-open');
|
||||
o.morphGarage = await morph('garage', 'mdi:garage', 'mdi:garage-open');
|
||||
o.morphGate = await morph('gate', 'mdi:gate', 'mdi:gate-open');
|
||||
o.morphDamper = await morph('damper', 'mdi:circle-slice-8', 'mdi:circle-outline');
|
||||
// no device_class at all (z2m ships plenty): the gate's auto icon is the
|
||||
// one the name rule «ворота|garage|gate» hands out, and it morphs too
|
||||
o.gateAutoIcon = c._devices.find((x) => x.bindingRef === 'd_gate')?.icon === 'mdi:garage-variant';
|
||||
o.morphNoClass = await morph('', 'mdi:garage-variant', 'mdi:garage-open-variant');
|
||||
// …and an unreadable state morphs nothing, plate still neutral
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
plateIsNeutral('unknown');
|
||||
o.unknownNoMorph = icon() === 'mdi:garage-variant';
|
||||
o.unknownNoRing = !cls().includes('activity-transition');
|
||||
|
||||
// --- NOT touched: the «открыто» frame elsewhere ------------------------
|
||||
// the demo house has a lock and a window sensor on the ground floor
|
||||
c._space = 'f1';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const elOf = (dev) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(dev);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'lock.front_door': { entity_id: 'lock.front_door', state: 'unlocked', attributes: { friendly_name: 'Front door' } },
|
||||
'binary_sensor.window': { entity_id: 'binary_sensor.window', state: 'on', attributes: { friendly_name: 'Window', device_class: 'window' } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const lockEl = elOf(dev('d_lock'));
|
||||
const winEl = elOf(dev('d_window'));
|
||||
o.lockMarkerFound = !!lockEl;
|
||||
o.windowMarkerFound = !!winEl;
|
||||
o.unlockedLockKeepsTheFrame = !!lockEl && lockEl.classList.contains('open')
|
||||
&& getComputedStyle(lockEl).backgroundColor === ORANGE;
|
||||
o.openWindowKeepsTheFrame = !!winEl && winEl.classList.contains('open')
|
||||
&& getComputedStyle(winEl).backgroundColor === ORANGE;
|
||||
// …and a locked lock is neutral again, so the frame still MEANS something
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'lock.front_door': { entity_id: 'lock.front_door', state: 'locked', attributes: { friendly_name: 'Front door' } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.lockedLockIsNeutral = getComputedStyle(elOf(dev('d_lock'))).backgroundColor === NEUTRAL;
|
||||
|
||||
// a VALVE keeps the frame too: nothing morphs its icon, so the frame is the
|
||||
// only thing it has to say «открыт» with (deliberately left alone)
|
||||
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 r = c._stateClass(d);
|
||||
c.hass = saved;
|
||||
return r;
|
||||
};
|
||||
o.valveOpenKeepsTheFrame = clsOfState(valve, { 'valve.water': { state: 'open', attributes: {} } }) === 'open';
|
||||
o.valveClosedIsNeutral = clsOfState(valve, { 'valve.water': { state: 'closed', attributes: {} } }) === '';
|
||||
// and the cover's own class string, straight from the source
|
||||
const cover = { id: 'k', primary: 'cover.blind', entities: ['cover.blind'], marker: { display: 'icon_ripple' } };
|
||||
o.coverOpenClassIsEmpty = clsOfState(cover, { 'cover.blind': { state: 'open', attributes: { device_class: 'blind' } } }) === '';
|
||||
o.coverOpeningClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'opening', attributes: { device_class: 'blind' } } }) === 'activity-transition';
|
||||
o.coverClosingClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'closing', attributes: { device_class: 'blind' } } }) === 'activity-transition';
|
||||
o.coverUnavailableStillFades = clsOfState(cover, { 'cover.blind': { state: 'unavailable', attributes: {} } }) === 'unavail';
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,230 @@
|
||||
// «Открыть/закрыть» on a curtain whose cover entity used to lose to an option
|
||||
// switch in registry/domain order.
|
||||
// (owner's report 2026-08-04: «в настройках "открыть\закрыть", а по нажатию
|
||||
// по-прежнему инфо-карточка»).
|
||||
//
|
||||
// The device below is his, entity for entity: an Aqara «Roller shade driver
|
||||
// E1» ships the `cover.*` HIDDEN by the integration and a perfectly visible
|
||||
// `switch.*_reverse_direction` beside it. The shared role resolver must pick
|
||||
// the real cover for status, while the explicit tap action decides whether a
|
||||
// click controls it. A mixed lamp+cover still stays a lamp unless the owner
|
||||
// explicitly selects the cover action.
|
||||
//
|
||||
// End to end: the action is chosen in the dialog and SAVED through the normal
|
||||
// path, and the click is done on the marker the card rebuilt from that config.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
|
||||
// ---- the owner's curtain driver, joined to the demo house --------------
|
||||
const ENTS = {
|
||||
// hidden by the integration — exactly as z2m ships it
|
||||
'cover.office_curtain': { entity_id: 'cover.office_curtain', device_id: 'd_curtain', platform: 'demo', hidden: true },
|
||||
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', device_id: 'd_curtain', platform: 'demo', entity_category: 'diagnostic' },
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', device_id: 'd_curtain', platform: 'demo' },
|
||||
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', device_id: 'd_curtain', platform: 'demo' },
|
||||
// a MIXED device: a lamp that also happens to ship a cover. Its primary is
|
||||
// meaningfully the light, and nothing below may take that away from it.
|
||||
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
};
|
||||
const coverState = (state, attrs = {}) => ({
|
||||
entity_id: 'cover.office_curtain', state,
|
||||
attributes: { friendly_name: 'Office curtain', ...attrs },
|
||||
});
|
||||
const push = async (state, attrs = {}) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
devices: { ...c.hass.devices,
|
||||
d_curtain: { id: 'd_curtain', name: 'Office curtain', model: 'Roller shade driver E1',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_curtain']], entry_type: null, via_device_id: null },
|
||||
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null } },
|
||||
entities: { ...c.hass.entities, ...ENTS },
|
||||
states: { ...c.hass.states,
|
||||
'cover.office_curtain': coverState(state, attrs),
|
||||
'sensor.office_curtain_battery': { entity_id: 'sensor.office_curtain_battery', state: '84', attributes: { device_class: 'battery', unit_of_measurement: '%' } },
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state: 'off', attributes: { friendly_name: 'Reverse direction' } },
|
||||
'sensor.office_curtain_motor_state': { entity_id: 'sensor.office_curtain_motor_state', state: 'stopped', attributes: {} },
|
||||
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', state: 'opening', attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
|
||||
},
|
||||
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
|
||||
};
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
// park both extra markers in their own corners: the DOM assertions below
|
||||
// find a marker by its position, and the auto grid would sit them on top
|
||||
// of the demo house's own bedroom devices
|
||||
const cu = c._devices.find((x) => x.bindingRef === 'd_curtain');
|
||||
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
|
||||
if (cu && mx) {
|
||||
c._layout = { ...c._layout,
|
||||
[cu.id]: { s: cu.space, x: 0.06, y: 0.94 },
|
||||
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 } };
|
||||
}
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
const curtain = () => c._devices.find((x) => x.bindingRef === 'd_curtain');
|
||||
const mixed = () => c._devices.find((x) => x.bindingRef === 'd_mixed');
|
||||
o.deviceIsOnThePlan = !!curtain();
|
||||
// Registry order and visibility no longer let an option switch outrank the
|
||||
// actual functional entity.
|
||||
o.primaryIsTheCover = curtain()?.primary === 'cover.office_curtain';
|
||||
o.coverIsAmongTheEntities = (curtain()?.entities || []).includes('cover.office_curtain');
|
||||
|
||||
// ---- the dialog offers the action and SAVES it -------------------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(curtain()); await c.updateComplete;
|
||||
const optionValues = () => [...sr().querySelectorAll('hp-dialog option')].map((e) => e.value);
|
||||
o.dialogOffersCover = optionValues().includes('cover');
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'cover', display: 'icon_ripple' };
|
||||
await c.updateComplete;
|
||||
await c._saveMarker();
|
||||
await c.updateComplete;
|
||||
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
|
||||
o.markerSaved = saved?.tap_action === 'cover';
|
||||
|
||||
// ---- and the tap on THAT marker opens the curtain -----------------------
|
||||
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const tap = async () => {
|
||||
c._clickDevice({ stopPropagation() {} }, curtain());
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
};
|
||||
o.tapActionSurvivedTheRebuild = curtain()?.tapAction === 'cover';
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.closedOpensTheCover = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
o.noInfoCard = !c._infoCard;
|
||||
c._infoCard = null;
|
||||
|
||||
await push('open', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.openClosesTheCover = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'close_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.travellingStops = (calls[calls.length - 1] || [])[1] === 'stop_cover';
|
||||
|
||||
// the service switch, which used to be the whole story, is never touched
|
||||
o.serviceSwitchNeverCalled = !calls.some((x) => String(x[2]?.entity_id).startsWith('switch.'));
|
||||
|
||||
// ---- the guarded classes still degrade, read off the COVER --------------
|
||||
await push('closed', { device_class: 'garage' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.garageCallsNothing = calls.length === 0;
|
||||
o.garageShowsInfo = !!c._infoCard;
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog(curtain()); await c.updateComplete;
|
||||
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
|
||||
c._markerDialog = null;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
|
||||
// ---- and the marker SHOWS that cover, not the service switch -----------
|
||||
// Owner, 2026-08-04: «нет ни дышащего кольца во время хода, ни рамки
|
||||
// "открыто", ни морфинга иконки». (The «открыто» frame itself was retired
|
||||
// for covers later the same day — the marker still had to start speaking for
|
||||
// the cover, which is what this section pins.) Same cause, same helper:
|
||||
// _stateClass and
|
||||
// the icon morph read d.primary, so the plan reported the state of
|
||||
// `switch.*_reverse_direction`. The rule (docs/FILTERING.md «What a marker
|
||||
// SHOWS»): the marker indicates the entity its tap ACTS ON — the cover
|
||||
// exactly when the owner has explicitly chosen «Открыть/закрыть».
|
||||
const devEl = (dev) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(dev);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const clsOf = (dev) => [...(devEl(dev)?.classList || [])];
|
||||
const iconOf = (dev) => devEl(dev)?.querySelector('ha-icon')?.getAttribute('icon') || '';
|
||||
const setSwitch = async (state) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'switch.office_curtain_reverse': { entity_id: 'switch.office_curtain_reverse', state, attributes: {} } } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
};
|
||||
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
o.markerIsOnScreen = !!devEl(curtain());
|
||||
o.closedIsNeitherOpenNorMoving =
|
||||
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('activity-transition');
|
||||
o.closedIconIsClosedCurtains = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
|
||||
await push('open', { device_class: 'curtain' });
|
||||
// the plate is neutral in every cover state since 2026-08-04 — the morph is
|
||||
// the whole open/closed story (smoke_cover_no_plate.mjs)
|
||||
o.openWearsNoColouredPlate =
|
||||
!clsOf(curtain()).includes('open') && !clsOf(curtain()).includes('on');
|
||||
o.openIconIsOpenCurtains = iconOf(curtain()) === 'mdi:curtains';
|
||||
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
o.travellingBreathes = clsOf(curtain()).includes('activity-transition');
|
||||
await push('closing', { device_class: 'curtain' });
|
||||
o.closingBreathesToo = clsOf(curtain()).includes('activity-transition');
|
||||
o.stateClassIsTheSameStory = c._stateClass(curtain()).includes('activity-transition');
|
||||
|
||||
// the service switch never speaks for the marker again: reverse-direction
|
||||
// ON used to paint the curtain yellow («включено») while it stood still
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('on');
|
||||
o.reverseSwitchNeverLightsTheMarker = !clsOf(curtain()).includes('on');
|
||||
|
||||
// Status and action are independent: taking the explicit action away keeps
|
||||
// the resolved cover status, but a click returns to the ordinary info path.
|
||||
const savedMarker = (c._serverCfg.markers || []).find((m) => m.binding === 'device:d_curtain');
|
||||
savedMarker.tap_action = 'info';
|
||||
await push('opening', { device_class: 'curtain' });
|
||||
await setSwitch('on');
|
||||
o.withoutTheActionResolvedCoverSpeaks = clsOf(curtain()).includes('activity-transition')
|
||||
&& !clsOf(curtain()).includes('on');
|
||||
savedMarker.tap_action = 'cover';
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('off');
|
||||
o.actionBackMeansCoverBack = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
|
||||
// ---- the auditor's own probe: BOTH entities visible ---------------------
|
||||
// The same rule must hold when BOTH entities are visible.
|
||||
ENTS['cover.office_curtain'] = { ...ENTS['cover.office_curtain'], hidden: false };
|
||||
await push('closed', { device_class: 'curtain' });
|
||||
await setSwitch('off');
|
||||
o.visibleCoverIsThePrimary = curtain()?.primary === 'cover.office_curtain';
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.visibleCoverOpensOnTap = JSON.stringify(calls[calls.length - 1] || [])
|
||||
=== JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.office_curtain' }]);
|
||||
o.visibleCoverAlsoIndicates = iconOf(curtain()) === 'mdi:curtains-closed';
|
||||
o.noInfoCardForTheVisibleCover = !c._infoCard;
|
||||
|
||||
// ---- a MIXED device is not hijacked ------------------------------------
|
||||
// A lit lamp that also owns a cover keeps the lamp: no explicit «Open/close»
|
||||
// on this marker, so nothing changes for it — and even if there were one,
|
||||
// a shining light still wins the yellow plate (the glow and the badge may
|
||||
// never disagree, owner's principle 2026-07-29).
|
||||
o.mixedPrimaryIsTheLight = mixed()?.primary === 'light.mixed';
|
||||
o.mixedCoverIsTravelling = c.hass.states['cover.mixed'].state === 'opening';
|
||||
o.mixedStaysALitLamp = clsOf(mixed()).includes('on') && !clsOf(mixed()).includes('activity-transition');
|
||||
// its icon is the device's own auto icon, NOT the cover's open/closed pair
|
||||
o.mixedIconIsNotMorphedByTheCover =
|
||||
iconOf(mixed()) === mixed()?.icon && !iconOf(mixed()).includes('curtains');
|
||||
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,208 @@
|
||||
// An EXPLICIT «Открыть/закрыть» marker is a curtain, and a curtain is never
|
||||
// painted — audit DEV-1DA1-01 (P2).
|
||||
//
|
||||
// `_stateClass` used to answer in this order: bound `controls` first, then any
|
||||
// lit `light.*` of the device, and only then the entity the tap acts on
|
||||
// (`_actEntity` → `_coverIndicator`). So the two combinations below still made
|
||||
// a curtain yellow, in flat contradiction with the owner's contract («у штор
|
||||
// не должно быть жёлтой подложки НИКОГДА») and with the promise that a marker
|
||||
// indicates the entity its tap drives:
|
||||
//
|
||||
// 1. a MIXED device (a lamp that also ships a blind) told `tap_action:
|
||||
// 'cover'` — the lit `light.mixed` returned 'on' before the cover branch
|
||||
// was ever reached, so the travelling blind lost its breathing ring too;
|
||||
// 2. a curtain marker with a bound `controls` target that happened to be on
|
||||
// — `controls` returned 'on' first, same result.
|
||||
//
|
||||
// The order now starts with the explicit cover: saying «this thing is a
|
||||
// curtain» in the dialog is the strongest statement the card has about what a
|
||||
// marker IS, so nothing may paint over it. Everything else keeps the old
|
||||
// precedence — a marker that was NOT told it is a curtain is untouched, and
|
||||
// that is re-checked at the bottom.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// ---- the plate colours, resolved the way the badge resolves them -------
|
||||
const probe = (css) => {
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
|
||||
sr().appendChild(d);
|
||||
const v = getComputedStyle(d).backgroundColor;
|
||||
d.remove();
|
||||
return v;
|
||||
};
|
||||
const YELLOW = probe('var(--hp-on)');
|
||||
const ORANGE = probe('var(--hp-open)');
|
||||
const NEUTRAL = probe('var(--hp-bg)');
|
||||
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
|
||||
|
||||
// =======================================================================
|
||||
// Part 1 — the class itself, straight from _stateClass, over every state
|
||||
// =======================================================================
|
||||
const withStates = (states, fn) => {
|
||||
const saved = c.hass;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
|
||||
const r = fn();
|
||||
c.hass = saved;
|
||||
return r;
|
||||
};
|
||||
const st = (state, attrs = {}) => ({ state, attributes: attrs });
|
||||
/** The auditor's marker #1: primary is a LIT light, the cover is explicit. */
|
||||
const litLamp = {
|
||||
id: 'p_lamp', primary: 'light.mixed', entities: ['light.mixed', 'cover.mixed'],
|
||||
tapAction: 'cover', marker: { display: 'icon_ripple' },
|
||||
};
|
||||
/** The auditor's marker #2: a cover marker whose bound control is on. */
|
||||
const controlled = {
|
||||
id: 'p_ctrl', primary: 'cover.mixed', entities: ['cover.mixed'],
|
||||
tapAction: 'cover', marker: { id: 'p_ctrl', binding: 'device:x', controls: ['switch.control'], display: 'icon_ripple' },
|
||||
};
|
||||
const CV = { closed: '', open: '', opening: 'activity-transition', closing: 'activity-transition' };
|
||||
for (const [state, want] of Object.entries(CV)) {
|
||||
const states = {
|
||||
'light.mixed': st('on', { friendly_name: 'Bedside lamp' }),
|
||||
'switch.control': st('on', {}),
|
||||
'cover.mixed': st(state, { device_class: 'curtain' }),
|
||||
};
|
||||
o['litLamp_' + state] = withStates(states, () => c._stateClass(litLamp)) === want;
|
||||
o['controlled_' + state] = withStates(states, () => c._stateClass(controlled)) === want;
|
||||
}
|
||||
// an unreachable cover still only fades — never yellow, never orange
|
||||
o.litLampUnavailFades = withStates({
|
||||
'light.mixed': st('on', {}), 'cover.mixed': st('unavailable', {}),
|
||||
}, () => c._stateClass(litLamp)) === 'unavail';
|
||||
o.controlledUnavailFades = withStates({
|
||||
'switch.control': st('on', {}), 'cover.mixed': st('unavailable', {}),
|
||||
}, () => c._stateClass(controlled)) === 'unavail';
|
||||
// …and a selected cover with no state is unavailable, never borrowed from
|
||||
// the lit neighbour that the explicit cover action deliberately outranks.
|
||||
o.litLampNoCoverStateIsUnavailable = withStates({
|
||||
'light.mixed': st('on', {}),
|
||||
}, () => c._stateClass(litLamp)) === 'unavail';
|
||||
|
||||
// ---- the OTHER side of the rule: nothing changed for the rest ----------
|
||||
// the very same mixed device WITHOUT the explicit action keeps its primary
|
||||
const plainLamp = { ...litLamp, id: 'p_plain', tapAction: null, marker: null };
|
||||
o.mixedWithoutTheOptionStaysYellow = withStates({
|
||||
'light.mixed': st('on', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(plainLamp)) === 'on';
|
||||
o.mixedWithoutTheOptionOffIsNeutral = withStates({
|
||||
'light.mixed': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(plainLamp)) === '';
|
||||
// a wall switch with controls and NO cover in sight still mirrors them
|
||||
const remote = {
|
||||
id: 'p_remote', primary: 'sensor.remote', entities: ['sensor.remote'], tapAction: null,
|
||||
marker: { id: 'p_remote', binding: 'device:y', controls: ['light.hall'] },
|
||||
};
|
||||
o.remoteMirrorsItsControls = withStates({ 'light.hall': st('on', {}) },
|
||||
() => c._stateClass(remote)) === 'on';
|
||||
o.remoteOffIsNeutral = withStates({ 'light.hall': st('off', {}) },
|
||||
() => c._stateClass(remote)) === '';
|
||||
// a curtain marker with controls that are OFF is still just a curtain
|
||||
o.controlledOffStillRings = withStates({
|
||||
'switch.control': st('off', {}), 'cover.mixed': st('opening', { device_class: 'curtain' }),
|
||||
}, () => c._stateClass(controlled)) === 'activity-transition';
|
||||
// an explicit «cover» marker with NO cover among its entities falls back to
|
||||
// the old order — the statement is only as strong as the entity behind it
|
||||
const noCover = {
|
||||
id: 'p_nocover', primary: 'light.mixed', entities: ['light.mixed'], tapAction: 'cover', marker: null,
|
||||
};
|
||||
o.explicitCoverWithoutACoverKeepsThePrimary = withStates({ 'light.mixed': st('on', {}) },
|
||||
() => c._stateClass(noCover)) === 'on';
|
||||
|
||||
// =======================================================================
|
||||
// Part 2 — the real markers on the real plan: the PLATE, not just a class
|
||||
// =======================================================================
|
||||
const ENTS = {
|
||||
'light.mixed': { entity_id: 'light.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', device_id: 'd_mixed', platform: 'demo' },
|
||||
'cover.ctrl': { entity_id: 'cover.ctrl', device_id: 'd_ctrl', platform: 'demo' },
|
||||
'switch.control': { entity_id: 'switch.control', device_id: 'd_wall', platform: 'demo' },
|
||||
};
|
||||
const push = async (coverState) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
devices: { ...c.hass.devices,
|
||||
d_mixed: { id: 'd_mixed', name: 'Bedside lamp', model: 'Lamp with a blind',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_mixed']], entry_type: null, via_device_id: null },
|
||||
d_ctrl: { id: 'd_ctrl', name: 'Office curtain', model: 'Curtain motor',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_ctrl']], entry_type: null, via_device_id: null },
|
||||
d_wall: { id: 'd_wall', name: 'Wall switch', model: 'Wall switch',
|
||||
area_id: 'bedroom', identifiers: [['demo', 'd_wall']], entry_type: null, via_device_id: null } },
|
||||
entities: { ...c.hass.entities, ...ENTS },
|
||||
states: { ...c.hass.states,
|
||||
'light.mixed': { entity_id: 'light.mixed', state: 'on', attributes: { friendly_name: 'Bedside lamp' } },
|
||||
'cover.mixed': { entity_id: 'cover.mixed', state: coverState, attributes: { friendly_name: 'Bedside blind', device_class: 'curtain' } },
|
||||
'cover.ctrl': { entity_id: 'cover.ctrl', state: coverState, attributes: { friendly_name: 'Office curtain', device_class: 'curtain' } },
|
||||
'switch.control': { entity_id: 'switch.control', state: 'on', attributes: { friendly_name: 'Wall switch' } },
|
||||
},
|
||||
};
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_mixed', binding: 'device:d_mixed', tap_action: 'cover', display: 'icon_ripple' },
|
||||
{ id: 'm_ctrl', binding: 'device:d_ctrl', tap_action: 'cover', controls: ['switch.control'], display: 'icon_ripple' },
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
const mx = c._devices.find((x) => x.bindingRef === 'd_mixed');
|
||||
const ct = c._devices.find((x) => x.bindingRef === 'd_ctrl');
|
||||
if (mx && ct) {
|
||||
c._layout = { ...c._layout,
|
||||
[mx.id]: { s: mx.space, x: 0.94, y: 0.06 },
|
||||
[ct.id]: { s: ct.space, x: 0.06, y: 0.94 } };
|
||||
}
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
// .dev animates `background 0.15s` — read a settled colour, not a fade
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
};
|
||||
c._setMode('view');
|
||||
c._space = 'f1';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await push('opening');
|
||||
|
||||
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
|
||||
const elOf = (d) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(d);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const report = (tag, ref) => {
|
||||
const d = dev(ref);
|
||||
const el = d && elOf(d);
|
||||
o[tag + 'MarkerFound'] = !!el;
|
||||
if (!el) return;
|
||||
const bg = getComputedStyle(el).backgroundColor;
|
||||
o[tag + 'PlateNeutral'] = bg === NEUTRAL;
|
||||
o[tag + 'NotYellow'] = bg !== YELLOW;
|
||||
o[tag + 'NotOrange'] = bg !== ORANGE;
|
||||
o[tag + 'NoOnClass'] = !el.classList.contains('on') && !el.classList.contains('open');
|
||||
o[tag + 'Rings'] = el.classList.contains('activity-transition');
|
||||
o[tag + 'IconIsTheCover'] = el.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:curtains';
|
||||
};
|
||||
o.lampIsTrulyLit = c.hass.states['light.mixed'].state === 'on';
|
||||
o.controlIsTrulyOn = c.hass.states['switch.control'].state === 'on';
|
||||
report('litLampDom', 'd_mixed');
|
||||
report('controlledDom', 'd_ctrl');
|
||||
|
||||
// …and at rest (closed) they are plain, with no ring
|
||||
await push('closed');
|
||||
const closedBg = (ref) => getComputedStyle(elOf(dev(ref))).backgroundColor;
|
||||
o.litLampClosedNeutral = closedBg('d_mixed') === NEUTRAL;
|
||||
o.controlledClosedNeutral = closedBg('d_ctrl') === NEUTRAL;
|
||||
o.litLampClosedNoRing = !elOf(dev('d_mixed')).classList.contains('activity-transition');
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,197 @@
|
||||
// Covers: the «Open/close» tap action + the travelling indication
|
||||
// (owner's contract 2026-08-03).
|
||||
//
|
||||
// closed -> cover.open_cover
|
||||
// open / ajar -> cover.close_cover
|
||||
// opening / closing -> cover.stop_cover (a tap during travel is a STOP;
|
||||
// the next tap simply travels the other way)
|
||||
// unknown -> cover.toggle, no morphing, no pulse
|
||||
//
|
||||
// The option is offered ONLY for a binding that HAS a cover entity, and never
|
||||
// for the guarded classes (garage/door/gate) — for those a value smuggled into
|
||||
// the config degrades to 'info', exactly like a card-wide toggle does.
|
||||
// With «Icon + activity», travelling breathes a soft activity ring (the
|
||||
// vacuum puck's 2.2s period) and the plate stays NEUTRAL — yellow means work.
|
||||
// Since 2026-08-04 the plate is neutral in EVERY cover state, the «открыто»
|
||||
// frame included: open/closed is told by the icon morph alone (the full
|
||||
// contract lives in smoke_cover_no_plate.mjs).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
const YELLOW = 'rgb(255, 212, 92)'; // --hp-on, the «включено» plate
|
||||
|
||||
const gateEntity = (state, attrs) => ({
|
||||
entity_id: 'cover.gate', state,
|
||||
attributes: { friendly_name: 'Gate', ...attrs },
|
||||
});
|
||||
/** Push a cover state into hass keeping the service recorder in place. */
|
||||
const setCover = async (state, attrs = {}) => {
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
states: { ...c.hass.states, 'cover.gate': gateEntity(state, attrs) },
|
||||
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; },
|
||||
};
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
/** Marker for the gate device: tap action + optional confirmation.
|
||||
* `solo` also hides the mower, leaving exactly one badge in the Garden —
|
||||
* the indication checks then read the cover's own plate, not a neighbour's. */
|
||||
const setMarker = async (tapAction, confirm = false, solo = false) => {
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: tapAction,
|
||||
tap_confirm: confirm || null, display: 'icon_ripple' },
|
||||
...(solo ? [{ id: 'm_mower', binding: 'device:d_mower', hidden: true }] : []),
|
||||
],
|
||||
};
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const gate = () => c._devices.find((x) => x.bindingRef === 'd_gate');
|
||||
const tap = async () => { c._clickDevice({ stopPropagation() {} }, gate()); await c.updateComplete; };
|
||||
// never throw when the feature is missing: the smoke must REPORT the gap
|
||||
const lastCall = () => calls[calls.length - 1] || ['-', '-', {}];
|
||||
// the gate is the only cover in the Garden space; the plan draws no names
|
||||
// inside the badge, so the icon set of the space is the readable handle
|
||||
const devEl = () => sr().querySelector('.dev');
|
||||
const icons = () => [...sr().querySelectorAll('.dev ha-icon')].map((e) => e.getAttribute('icon'));
|
||||
|
||||
// the gate lives in the Garden space — look at it, not at the ground floor
|
||||
c._setMode('view');
|
||||
c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
|
||||
// ---- the dialog offers the option only where it makes sense ------------
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const optionValues = () => [...sr().querySelectorAll('hp-dialog option')].map((e) => e.value);
|
||||
c._openMarkerDialog(gate()); await c.updateComplete;
|
||||
o.dialogOffersCoverForCurtain = optionValues().includes('cover');
|
||||
// …and the confirmation checkbox follows the choice, like it does for run/toggle
|
||||
const rowsBefore = sr().querySelectorAll('hp-dialog .srcrow').length;
|
||||
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' }; await c.updateComplete;
|
||||
o.confirmCheckboxShownForCover = sr().querySelectorAll('hp-dialog .srcrow').length > rowsBefore;
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
|
||||
// a GARAGE door never gets the option (owner: «нет, только шторы/жалюзи»)
|
||||
await setCover('closed', { device_class: 'garage' });
|
||||
c._openMarkerDialog(gate()); await c.updateComplete;
|
||||
o.dialogHidesCoverForGarage = !optionValues().includes('cover');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
// …and neither does a device without a cover entity at all
|
||||
c._openMarkerDialog(c._devices.find((x) => x.bindingRef === 'd_mower')); await c.updateComplete;
|
||||
o.dialogHidesCoverWithoutCover = !optionValues().includes('cover');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
c._setMode('view'); c._space = 'garden'; c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// ---- the tap itself ----------------------------------------------------
|
||||
await setMarker('cover');
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.closedOpens = JSON.stringify(lastCall()) === JSON.stringify(['cover', 'open_cover', { entity_id: 'cover.gate' }]);
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.openCloses = lastCall()[0] === 'cover' && lastCall()[1] === 'close_cover';
|
||||
|
||||
// a half-drawn curtain is still 'open' for HA — it closes
|
||||
await setCover('open', { device_class: 'curtain', current_position: 40 });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.ajarCloses = lastCall()[1] === 'close_cover';
|
||||
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.openingStops = lastCall()[1] === 'stop_cover';
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.closingStops = lastCall()[1] === 'stop_cover';
|
||||
|
||||
// no readable state — a plain cover.toggle, nothing clever
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
await tap(); await new Promise((r) => setTimeout(r, 10));
|
||||
o.unknownToggles = lastCall()[0] === 'cover' && lastCall()[1] === 'toggle';
|
||||
|
||||
// ---- the confirmation checkbox works for this action too ---------------
|
||||
await setMarker('cover', true);
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
o.confirmAsks = !!c._tapConfirm && c._tapConfirm.text.includes('Gate');
|
||||
c._tapConfirm = null; await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.confirmCancelCallsNothing = calls.length === 0;
|
||||
await tap();
|
||||
const conf = c._tapConfirm; c._tapConfirm = null; conf?.exec?.();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.confirmOkOpens = calls.length === 1 && calls[0][1] === 'open_cover';
|
||||
|
||||
// ---- a saved 'cover' on a GARAGE degrades to the info card -------------
|
||||
await setMarker('cover');
|
||||
await setCover('closed', { device_class: 'garage' });
|
||||
calls.length = 0;
|
||||
await tap();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
o.garageSavedCoverCallsNothing = calls.length === 0;
|
||||
o.garageSavedCoverShowsInfo = !!c._infoCard;
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
|
||||
// ---- the indication ----------------------------------------------------
|
||||
const afterAnim = () => {
|
||||
const el = devEl()?.querySelector('.activity-ring.transition i:first-child');
|
||||
if (!el) return null;
|
||||
const cs = getComputedStyle(el);
|
||||
return { name: cs.animationName, dur: cs.animationDuration };
|
||||
};
|
||||
await setMarker('cover', false, true); // the gate alone on the plan
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
// .dev animates `background 0.15s`: lit recycles the badge node the hidden
|
||||
// mower used, so a plate colour read in the same frame is still mid-fade
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.dev').length === 1;
|
||||
o.pulseWhileOpening = sr().querySelectorAll('.dev.activity-transition').length === 1;
|
||||
const a1 = afterAnim();
|
||||
o.pulseIsBreathingRing = a1?.name === 'hp-activity-breathe' && a1?.dur === '2.2s';
|
||||
// the plate stays NEUTRAL while travelling: yellow is reserved for «включено»
|
||||
o.movingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.pulseWhileClosing = sr().querySelectorAll('.dev.activity-transition').length === 1;
|
||||
o.closingPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
o.noPulseWhenOpen = sr().querySelectorAll('.dev.activity-transition').length === 0;
|
||||
o.iconMorphOpen = icons().includes('mdi:curtains') && !icons().includes('mdi:curtains-closed');
|
||||
// owner 2026-08-04: never a coloured plate on a cover — not even «открыто»
|
||||
o.openWearsNoFrame = sr().querySelectorAll('.dev.open').length === 0;
|
||||
o.openPlateNotYellow = getComputedStyle(devEl()).backgroundColor !== YELLOW;
|
||||
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
o.directCloseGetsFallback = sr().querySelectorAll('.dev.activity-transition').length === 1;
|
||||
o.iconMorphClosed = icons().includes('mdi:curtains-closed') && !icons().includes('mdi:curtains');
|
||||
|
||||
await setCover('open', { device_class: 'shutter' });
|
||||
o.iconMorphShutterOpen = icons().includes('mdi:window-shutter-open');
|
||||
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
o.noPulseWhenUnknown = sr().querySelectorAll('.dev.activity-transition').length === 0;
|
||||
o.noMorphWhenUnknown = icons().includes(gate().icon)
|
||||
&& !icons().some((i) => i.startsWith('mdi:curtains'));
|
||||
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
+133
-4
@@ -11,7 +11,10 @@ const res = await page.evaluate(async () => {
|
||||
tabs[2].click(); await c.updateComplete;
|
||||
out.decorMode = c._mode === 'decor';
|
||||
out.decorBar = !!sr().querySelector('.editbar.decorbar');
|
||||
out.toolBtns = sr().querySelectorAll('.decorbar .btn:not(.barclose)').length === 6;
|
||||
// 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
|
||||
out.toolBtns = sr().querySelectorAll('.decorbar .btn.dtool').length === 8;
|
||||
// 2) нарисовать прямоугольник drag-ом (через прямые вызовы)
|
||||
c._decorTool = 'rect'; c._decorStyle = { color: '#ff0000', width: 3, fill: true }; await c.updateComplete;
|
||||
const g = c._gridPitch;
|
||||
@@ -45,8 +48,49 @@ 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;
|
||||
// 4) надпись через диалог
|
||||
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', size: 'l', color: '#0000ff' };
|
||||
// 3b) живая плашка размера, пока фигуру ещё тянут (владелец 2026-08-04:
|
||||
// «в редакторе подложки у линий писать длину, как при рисовании комнат в
|
||||
// редакторе плана»). Та же .measurelabel, тот же formatLength/cell_cm.
|
||||
const label = () => sr().querySelector('.measurelabel.dmeasure');
|
||||
const txt = () => (label() ? label().textContent.trim() : null);
|
||||
const cellCm = 5; // cell_cm по умолчанию
|
||||
const fmt = (px) => (((px / g) * cellCm) / 100).toFixed(2) + ' m';
|
||||
const vw = () => c._viewOr(c._baseVb());
|
||||
c._decorTool = 'line';
|
||||
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 4, g * 20], pid: 3 };
|
||||
await c.updateComplete;
|
||||
out.noBadgeBeforeTheDrag = !label(); // нулевая длина — молчим
|
||||
c._decorDraft = { ...c._decorDraft, b: [g * 16, g * 20] }; // 12 клеток вправо
|
||||
await c.updateComplete;
|
||||
out.lineBadgeShown = !!label();
|
||||
// 12 клеток × 5 см = 0.60 m, угол 0° — ровно то, что пишет плашка стены
|
||||
out.lineBadgeText = txt();
|
||||
out.lineBadgeMatchesGeometry = txt() === fmt(g * 12) + ' · 0°';
|
||||
// ...ровно на середине отрезка (владелец: «плашка на середине линии»)
|
||||
out.lineBadgeAtTheMiddle = !!label() && Math.abs(
|
||||
parseFloat(label().style.left) - ((g * 10 - vw().x) / vw().w) * 100) < 0.02;
|
||||
// 0° кратен 45° — плашка зелёная, как у стены на 45°
|
||||
out.badgeGreenOnAxis = !!label() && label().classList.contains('on45');
|
||||
// косой отрезок 3-4-5: длина 5 клеток, угол 53.1° — не кратен 45°
|
||||
c._decorDraft = { kind: 'line', a: [g * 4, g * 20], b: [g * 7, g * 24], pid: 3 };
|
||||
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);
|
||||
// отпустили — плашки нет
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
out.badgeGoneAfterRelease = !label();
|
||||
c._curSpaceCfg.decor = c._decorList.slice(0, 3); // назад к трём фигурам
|
||||
c._decorTool = 'select';
|
||||
await c.updateComplete;
|
||||
out.stillThreeShapes = c._decorList.length === 3;
|
||||
// 4) надпись через диалог. Поля `size` в диалоге больше нет — размер задаётся
|
||||
// углами блока (см. smoke_decor_text); сохранённый старый `size` по-прежнему
|
||||
// читается как начальный масштаб.
|
||||
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', color: '#0000ff' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.textSaved = c._decorList.some((x) => x.kind === 'text' && x.text === 'Сауна');
|
||||
out.textRendered = [...sr().querySelectorAll('.decorlayer text')].some((t) => t.textContent.includes('Сауна'));
|
||||
@@ -62,6 +106,90 @@ const res = await page.evaluate(async () => {
|
||||
c._curSpaceCfg.decor = c._decorList.map((x) => x.id === m.id ? { ...x, x: m.orig.x + dx5 } : x);
|
||||
out.moveKeepsSize = Math.abs(c._decorList.find((x) => x.id === rect.id).w - before.w) < 1e-9;
|
||||
c._decorMove = null;
|
||||
// 5b) инструмент рисования ВЛАДЕЕТ холстом: клик по существующей фигуре
|
||||
// начинает новую фигуру в этой точке, а не выделяет старую (владелец
|
||||
// 2026-08-04: «нельзя поставить начало линии на конец другой»).
|
||||
const stageEl = () => sr().querySelector('.stage');
|
||||
// экранные координаты точки холста (обратное _screenToVb)
|
||||
const toScreen = (x, y) => {
|
||||
const r = stageEl().getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
|
||||
clientY: r.top + ((y - v.y) / v.h) * r.height };
|
||||
};
|
||||
const press = (el, x, y, pid) => {
|
||||
const { clientX, clientY } = toScreen(x, y);
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
pointerId: pid, clientX, clientY, button: 0, isPrimary: true }));
|
||||
};
|
||||
// подопытная линия с известными концами
|
||||
c._decorTool = 'select'; c._decorSel = null; c._decorDraft = null; c._decorMove = null;
|
||||
const decorBefore = c._decorList.slice();
|
||||
c._curSpaceCfg.decor = [{ id: 'dcprobe', kind: 'line', x1: (g * 6) / 1000, y1: (g * 30) / 1000,
|
||||
x2: (g * 18) / 1000, y2: (g * 30) / 1000, color: '#000000', width: 3 }];
|
||||
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const probe = () => [...sr().querySelectorAll('.decorlayer line.dshape')]
|
||||
.find((l) => Math.abs(+l.getAttribute('x1') - g * 6) < 0.01);
|
||||
out.probeRendered = !!probe();
|
||||
// — инструмент «линия»: жмём ровно на КОНЕЦ линии
|
||||
c._decorTool = 'line'; await c.updateComplete;
|
||||
// фигура вообще перестаёт быть мишенью для указателя
|
||||
out.shapeInertUnderLineTool = getComputedStyle(probe()).pointerEvents === 'none';
|
||||
press(probe(), g * 18, g * 30, 41);
|
||||
await c.updateComplete;
|
||||
out.lineToolStartsDraft = !!c._decorDraft && c._decorDraft.kind === 'line';
|
||||
// ...и именно из этой точки (снап к концу старой линии)
|
||||
out.draftStartsAtTheEnd = !!c._decorDraft
|
||||
&& Math.abs(c._decorDraft.a[0] - g * 18) < 0.51 && Math.abs(c._decorDraft.a[1] - g * 30) < 0.51;
|
||||
out.lineToolNoSelect = c._decorSel === null;
|
||||
out.lineToolNoMove = !c._decorMove;
|
||||
c._decorDraft = null; await c.updateComplete;
|
||||
// — прямоугольник и овал ведут себя так же
|
||||
for (const [tool, key] of [['rect', 'rectToolStartsDraft'], ['ellipse', 'ellipseToolStartsDraft']]) {
|
||||
c._decorTool = tool; c._decorSel = null; await c.updateComplete;
|
||||
press(probe(), g * 12, g * 30, 42);
|
||||
await c.updateComplete;
|
||||
out[key] = !!c._decorDraft && c._decorDraft.kind === tool && c._decorSel === null;
|
||||
c._decorDraft = null;
|
||||
}
|
||||
// — «текст» по ЛИНИИ открывает диалог новой надписи, а не выделяет её.
|
||||
// Инертность нетекстовых фигур под рисующими инструментами остаётся в силе;
|
||||
// единственное исключение — надпись под инструментом «текст», она открывает
|
||||
// СВОЮ форму (владелец 2026-08-04, пинится в smoke_decor_text).
|
||||
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null; await c.updateComplete;
|
||||
press(probe(), g * 12, g * 30, 43);
|
||||
await c.updateComplete;
|
||||
out.textToolOpensDialog = !!c._decorTextDialog && !c._decorTextDialog.id && c._decorSel === null;
|
||||
c._decorTextDialog = null;
|
||||
// — а вот в «выборе» всё по-прежнему: клик выделяет и берёт на перетаскивание
|
||||
c._decorTool = 'select'; c._decorSel = null; c._decorDraft = null; await c.updateComplete;
|
||||
out.shapeLiveUnderSelect = getComputedStyle(probe()).pointerEvents !== 'none';
|
||||
press(probe(), g * 12, g * 30, 44);
|
||||
await c.updateComplete;
|
||||
out.selectStillSelects = c._decorSel === 'dcprobe';
|
||||
out.selectStillGrabs = !!c._decorMove && c._decorMove.id === 'dcprobe';
|
||||
out.selectMakesNoDraft = !c._decorDraft;
|
||||
c._decorMove = null;
|
||||
// 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.
|
||||
probe().dispatchEvent(new MouseEvent('dblclick', {
|
||||
bubbles: true, composed: true, cancelable: true, button: 0,
|
||||
}));
|
||||
await c.updateComplete;
|
||||
out.doubleClickOpensObjectDialog = c._decorShapeDialog?.id === 'dcprobe'
|
||||
&& !!sr().querySelector('hp-dialog .dfill') === false;
|
||||
c._decorShapeDialog = { ...c._decorShapeDialog, color: '#123456', width: 6.5 };
|
||||
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;
|
||||
c._decorSel = null;
|
||||
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
|
||||
c._decorTool = 'select'; await c.updateComplete;
|
||||
out.decorRestored = c._decorList.length === decorBefore.length;
|
||||
// 6) erase удаляет
|
||||
const n0 = c._decorList.length;
|
||||
c._decorTool = 'erase';
|
||||
@@ -82,5 +210,6 @@ const res = await page.evaluate(async () => {
|
||||
out.deleteKey = c._decorList.length === n1 - 1;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
// значение плашки зафиксировано числом: 12 клеток × cell_cm 5 = 0.60 m, 0°
|
||||
checkAll(res, { lineBadgeText: '0.60 m · 0°' });
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// Текстовый блок декора (правки владельца 2026-08-04):
|
||||
// 1) выбора размера шрифта БОЛЬШЕ НЕТ — размер задаётся углами блока, а
|
||||
// сохранённый старый `size` читается как начальный масштаб;
|
||||
// 2) многострочный текст: перевод строки сохраняется и рисуется, блок
|
||||
// выравнивается по центру;
|
||||
// 3) у выделенного блока есть угловые ручки (масштаб) и ручка поворота
|
||||
// (шаг 5°, Shift — мимо шага); ВИДИМЫЙ размер ручки — четверть хит-зоны
|
||||
// (правка владельца 2026-08-05 «уменьшить в 4 раза»), сама хит-зона
|
||||
// прежняя, пальцевая: 1.8 % видимого вида;
|
||||
// 4) при инструменте «текст» клик по УЖЕ РАЗМЕЩЁННОЙ надписи открывает её
|
||||
// форму, а не создаёт новую; по пустому месту и по НЕтекстовой фигуре —
|
||||
// создаёт новую (нетекстовые фигуры остаются инертными, см. smoke_decor).
|
||||
// ПАДАЕТ на сборке до этих правок: в диалоге ещё радиокнопки размера, перевод
|
||||
// строки схлопывается в одну строку, ручек нет, а «текст» по надписи создаёт
|
||||
// вторую надпись поверх первой.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
// null-safe: на сборке ДО этих правок у надписи нет ни data-id, ни tspan —
|
||||
// смок должен показать список провалов, а не упасть с исключением
|
||||
const el = (id) => sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
|
||||
const attr = (id, name) => { const e = el(id); return e ? e.getAttribute(name) : null; };
|
||||
const tspans = (id) => { const e = el(id); return e ? [...e.querySelectorAll('tspan')] : []; };
|
||||
const stageEl = () => sr().querySelector('.stage');
|
||||
/** открыть форму существующей надписи (на старой сборке — двойным щелчком) */
|
||||
const open = (sh) => (c._decorOpenText ? c._decorOpenText(sh) : c._decorShapeDbl(sh));
|
||||
const toScreen = (x, y) => {
|
||||
const r = stageEl().getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
|
||||
clientY: r.top + ((y - v.y) / v.h) * r.height };
|
||||
};
|
||||
const ev = (type, target, x, y, extra = {}) => {
|
||||
const { clientX, clientY } = toScreen(x, y);
|
||||
target.dispatchEvent(new PointerEvent(type, { bubbles: true, composed: true,
|
||||
cancelable: true, pointerId: 7, clientX, clientY, button: 0, isPrimary: true, ...extra }));
|
||||
};
|
||||
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [];
|
||||
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
|
||||
|
||||
// ================= 1. размера шрифта в диалоге больше нет ================
|
||||
c._decorTool = 'text';
|
||||
c._decorTextDialog = { x: 0.3, y: 0.3, text: '', color: '#223344' };
|
||||
await c.updateComplete;
|
||||
out.noSizePicker = !sr().querySelector('input[name="dtsize"]');
|
||||
out.textareaNotInput = !!sr().querySelector('hp-dialog textarea.dtarea');
|
||||
c._decorTextDialog = null; await c.updateComplete;
|
||||
|
||||
// старый `size` продолжает рисоваться ровно как раньше: 'l' = 30px
|
||||
c._curSpaceCfg.decor = [
|
||||
{ id: 'dtl', kind: 'text', x: 0.25, y: 0.25, text: 'Старый', size: 'l', color: '#223344' },
|
||||
{ id: 'dts', kind: 'text', x: 0.25, y: 0.4, text: 'Мелкий', size: 's', color: '#223344' },
|
||||
{ id: 'dtm', kind: 'text', x: 0.25, y: 0.55, text: 'Обычный', color: '#223344' },
|
||||
];
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
out.legacyLarge = +attr('dtl', 'font-size') === 30;
|
||||
out.legacySmall = +attr('dts', 'font-size') === 14;
|
||||
out.noSizeIsMedium = +attr('dtm', 'font-size') === 20;
|
||||
|
||||
// ================= 2. многострочный текст, по центру =====================
|
||||
c._decorTool = 'text';
|
||||
c._decorTextDialog = { x: 0.5, y: 0.5, text: ' Гараж\nпод ключ ', color: '#223344' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const multi = c._decorList.find((x) => x.text && x.text.includes('\n'));
|
||||
out.newlineStored = !!multi && multi.text === 'Гараж\nпод ключ'; // края обрезаны, середина цела
|
||||
const tsp = tspans(multi.id);
|
||||
out.twoLinesRendered = tsp.length === 2
|
||||
&& tsp[0].textContent === 'Гараж' && tsp[1].textContent === 'под ключ';
|
||||
out.linesCentredHorizontally = tsp.length === 2 && !!el(multi.id)
|
||||
&& getComputedStyle(el(multi.id)).textAnchor === 'middle'
|
||||
&& tsp[0].getAttribute('x') === tsp[1].getAttribute('x');
|
||||
// блок центрирован и по вертикали: якорь ровно между строками
|
||||
const ay = multi.y * 1000;
|
||||
const y0 = tsp[0] ? +tsp[0].getAttribute('y') : NaN;
|
||||
const y1 = tsp[1] ? +tsp[1].getAttribute('y') : NaN;
|
||||
out.blockCentredVertically = Math.abs((y0 + y1) / 2 - ay) < 0.01 && y1 > y0;
|
||||
// длинная строка НЕ переносится сама (docs/LIVE-TEXT.md: никаких автопереносов)
|
||||
open(multi); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, text: 'x'.repeat(120) };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
out.noAutoWrap = tspans(multi.id).length === 1;
|
||||
open(c._decorList.find((x) => x.id === multi.id)); await c.updateComplete;
|
||||
c._decorTextDialog = { ...c._decorTextDialog, text: 'Гараж\nпод ключ' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
|
||||
// ================= 3. ручки масштаба и поворота ==========================
|
||||
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
|
||||
out.noFrameWithoutSelection = !sr().querySelector('.dtframe');
|
||||
c._decorSel = 'dtm'; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
const frame = () => sr().querySelector('.dtframe');
|
||||
out.frameOnSelection = !!frame();
|
||||
out.fourCornersAndRotate = !!frame()
|
||||
&& frame().querySelectorAll('.dthandle').length === 5
|
||||
&& !!frame().querySelector('.dtrot');
|
||||
|
||||
// --- визуал в 4 раза меньше, хит-зона прежняя (docs/LIVE-TEXT.md §3) ---
|
||||
out.fiveVisibleKnobs = frame()?.querySelectorAll('.dtknob').length === 5;
|
||||
const rOf = (sel) => { const e = frame()?.querySelector(sel); return e ? +e.getAttribute('r') : NaN; };
|
||||
const hitR = rOf('.dthandle.dtrot');
|
||||
const knobR = rOf('.dtknob');
|
||||
const vw = c._viewOr(c._baseVb());
|
||||
const expectHit = Math.max(vw.w, vw.h) * 0.018;
|
||||
out.hitRadiusUnchanged = Math.abs(hitR - expectHit) < 0.1; // прежние 1.8 %
|
||||
out.knobIsAQuarterOfTheHit = Math.abs(knobR * 4 - hitR) / Math.max(hitR, 1e-9) < 0.05;
|
||||
out.knobTakesNoPointer = getComputedStyle(frame().querySelector('.dtknob')).pointerEvents === 'none';
|
||||
out.handleTakesThePointer = getComputedStyle(frame().querySelector('.dthandle')).pointerEvents !== 'none';
|
||||
// …и палец, попавший МИМО бусины, но внутрь прежнего круга, всё ещё берёт ручку
|
||||
const rotEl = frame().querySelector('.dtrot');
|
||||
const rcx = +rotEl.getAttribute('cx'), rcy = +rotEl.getAttribute('cy');
|
||||
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');
|
||||
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 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.fontFollowsScale = +attr('dtm', 'font-size') === 40;
|
||||
ev('pointerup', stageEl(), ax + 80, ay2);
|
||||
out.dragEnded = !c._dtDrag;
|
||||
|
||||
// старый `size` заменяется масштабом, который он означал (одна правда)
|
||||
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];
|
||||
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;
|
||||
ev('pointerup', stageEl(), lx + 100, ly);
|
||||
|
||||
// ручка поворота: шаг 5°, Shift — мимо шага
|
||||
c._decorSel = 'dtm'; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
const [rx, ry] = anchor();
|
||||
const rot = frame()?.querySelector('.dthandle.dtrot') || stageEl();
|
||||
ev('pointerdown', rot, rx + 100, ry);
|
||||
out.rotateStarted = c._dtDrag?.kind === 'rotate';
|
||||
ev('pointermove', stageEl(), rx + 100, ry + 100); // ровно 45°
|
||||
await c.updateComplete;
|
||||
out.rotates = Math.abs((c._decorList.find((x) => x.id === 'dtm').angle || 0) - 45) < 1e-6;
|
||||
ev('pointermove', stageEl(), rx + 100, ry + 2); // ~1.1° → прилипает к 0
|
||||
await c.updateComplete;
|
||||
out.snapsTo5 = (c._decorList.find((x) => x.id === 'dtm').angle || 0) === 0;
|
||||
ev('pointermove', stageEl(), rx + 100, ry + 2, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
const free = c._decorList.find((x) => x.id === 'dtm').angle;
|
||||
out.shiftGoesFree = free > 0.5 && free < 5; // тот же жест, но точный угол
|
||||
ev('pointerup', stageEl(), rx + 100, ry + 2);
|
||||
await c.updateComplete;
|
||||
out.rotationRendered = /rotate\(/.test(attr('dtm', 'transform') || '');
|
||||
// …а без Shift тот же 1.1° снова прилипает к нулю
|
||||
// повернули обратно в ноль — поле исчезает, фигура снова «прямая»
|
||||
ev('pointerdown', frame()?.querySelector('.dthandle.dtrot') || stageEl(), rx + 100, ry);
|
||||
ev('pointermove', stageEl(), rx + 100, ry);
|
||||
ev('pointerup', stageEl(), rx + 100, ry);
|
||||
await c.updateComplete;
|
||||
out.zeroAngleDropped = c._decorList.find((x) => x.id === 'dtm').angle === undefined;
|
||||
|
||||
// ================= 4. инструмент «текст» по надписи = редактировать =======
|
||||
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null;
|
||||
await c.updateComplete;
|
||||
const before = c._decorList.length;
|
||||
const target = c._decorList.find((x) => x.id === 'dtm');
|
||||
out.labelIsLiveUnderTextTool = !!el('dtm') && getComputedStyle(el('dtm')).pointerEvents !== 'none';
|
||||
ev('pointerdown', el('dtm') || stageEl(), target.x * 1000, target.y * 1000);
|
||||
await c.updateComplete;
|
||||
out.textToolEditsTheLabel = c._decorTextDialog?.id === 'dtm'
|
||||
&& c._decorTextDialog.text === target.text;
|
||||
out.textToolMakesNoSecondLabel = c._decorList.length === before;
|
||||
out.textToolDoesNotSelect = c._decorSel === null;
|
||||
c._decorTextDialog = null; await c.updateComplete;
|
||||
|
||||
// по пустому месту — новая надпись
|
||||
ev('pointerdown', stageEl(), 60, 940);
|
||||
await c.updateComplete;
|
||||
out.textToolOnEmptyCreates = !!c._decorTextDialog && !c._decorTextDialog.id;
|
||||
c._decorTextDialog = null; await c.updateComplete;
|
||||
|
||||
// по НЕтекстовой фигуре — тоже новая надпись: она осталась инертной
|
||||
const lineEl = sr().querySelector('.decorlayer line.dshape');
|
||||
out.lineStillInert = getComputedStyle(lineEl).pointerEvents === 'none';
|
||||
ev('pointerdown', lineEl, 250, 800);
|
||||
await c.updateComplete;
|
||||
out.textToolOnAShapeCreates = !!c._decorTextDialog && !c._decorTextDialog.id
|
||||
&& c._decorSel === null;
|
||||
c._decorTextDialog = null;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,126 @@
|
||||
// DEV-B58-01 — the infinite canvas reached the FRAME and the drawing, but not
|
||||
// the drag handlers. A device marker was clamped into the CONTENT FRAME (with
|
||||
// a 0.8 % inset), a room label into the space's stored view_box — which, for
|
||||
// every plan the card has ever written, is the old unit square. The owner and a
|
||||
// user hit the same wall: "названия комнат и устройства не перетаскиваются
|
||||
// дальше старых границ холста".
|
||||
//
|
||||
// Every interactive coordinate now has ONE bound: the ±5000 the backend
|
||||
// enforces (docs/CANVAS.md §9). This smoke starts from an ORDINARY plan — one
|
||||
// that lives inside 0..1, so the old clamps really were in the way — and drags
|
||||
// each kind of thing out to 2.5 / 2.2 and beyond.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const NORM_W = 1000;
|
||||
const near = (a, b, eps = 1 / 240) => Math.abs(a - b) <= eps + 1e-9;
|
||||
const onGrid = (v) => Math.abs(v * 240 - Math.round(v * 240)) < 1e-9;
|
||||
|
||||
// the demo plan is an ordinary one: rooms inside the old unit square
|
||||
const b0 = c._baseVb();
|
||||
o.startsFromAnOrdinaryPlan = b0[0] + b0[2] < 1.3 * NORM_W && b0[1] + b0[3] < 1.3 * NORM_W;
|
||||
|
||||
/** Turn a wanted RENDER-unit delta into the clientX/clientY a handler wants. */
|
||||
const mk = (dxu, dyu, sx = 400, sy = 400, extra = {}) => {
|
||||
const rect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return new PointerEvent('pointermove', {
|
||||
clientX: sx + (dxu / v.w) * rect.width,
|
||||
clientY: sy + (dyu / v.h) * rect.height,
|
||||
bubbles: true, ...extra,
|
||||
});
|
||||
};
|
||||
|
||||
// ---- (a) a DEVICE MARKER goes to 2.5 / 2.2 ---------------------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => !d.virtual);
|
||||
const p0 = c._pos(dev);
|
||||
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
|
||||
c._pointerMove(mk(2.5 * NORM_W - p0.x, 2.2 * NORM_W - p0.y), dev);
|
||||
c._pointerUp(new PointerEvent('pointerup'), dev);
|
||||
await c.updateComplete;
|
||||
const saved = c._layout[dev.id];
|
||||
o.markerLeftTheOldCanvas = near(saved.x, 2.5) && near(saved.y, 2.2);
|
||||
o.markerIsOnTheGrid = onGrid(saved.x) && onGrid(saved.y);
|
||||
|
||||
// ---- (b) it SURVIVES a rebuild ---------------------------------------
|
||||
c._drag = null;
|
||||
c._modelCache = null; c._frame = null; c._defPos = c._defaultPositions();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const back = c._pos(c._devices.find((d) => d.id === dev.id));
|
||||
o.markerSurvivesRebuild = near(back.x / NORM_W, 2.5) && near(back.y / NORM_W, 2.2);
|
||||
// …and the frame followed it out there instead of pretending it is elsewhere
|
||||
const b1 = c._baseVb();
|
||||
o.frameFollowedTheMarker = b1[0] + b1[2] > 2.4 * NORM_W;
|
||||
|
||||
// ---- (c) a ROOM LABEL, the worse of the two (it clamped to view_box) --
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const room = c._spaceModel(c._space).rooms.find((r) => r.name);
|
||||
const lp = c._labelPos(room, c._space);
|
||||
c._drag = { id: 'rl_' + room.id, sx: 400, sy: 400, ox: lp.x, oy: lp.y, moved: false };
|
||||
c._labelMove(mk(2.7 * NORM_W - lp.x, 2.4 * NORM_W - lp.y), room, c._space);
|
||||
c._labelUp(room);
|
||||
await c.updateComplete;
|
||||
const sl = c._layout['rl_' + room.id];
|
||||
o.labelLeftTheOldCanvas = near(sl.x, 2.7) && near(sl.y, 2.4);
|
||||
o.labelIsOnTheGrid = onGrid(sl.x) && onGrid(sl.y);
|
||||
c._drag = null;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const lp2 = c._labelPos(room, c._space);
|
||||
o.labelSurvivesRebuild = near(lp2.x / NORM_W, 2.7) && near(lp2.y / NORM_W, 2.4);
|
||||
|
||||
// ---- (d) DECOR born far out, and moved further still -------------------
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._decorTool = 'rect';
|
||||
c._decorDraft = { kind: 'rect', a: [2.6 * NORM_W, 2.6 * NORM_W], b: [2.9 * NORM_W, 2.8 * NORM_W], pid: 1 };
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
const sh = c._decorList[c._decorList.length - 1];
|
||||
o.decorBornFarOut = near(sh.x, 2.6) && near(sh.y, 2.6);
|
||||
// the decor mover works in ABSOLUTE svg points, so the gesture is built the
|
||||
// way _decorShapeDown builds it: grab at the shape, release 0.6/0.5 away
|
||||
const atUnits = (X, Y) => {
|
||||
const rect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return new PointerEvent('pointermove', {
|
||||
clientX: rect.left + ((X - v.x) / v.w) * rect.width,
|
||||
clientY: rect.top + ((Y - v.y) / v.h) * rect.height, bubbles: true,
|
||||
});
|
||||
};
|
||||
const grab = c._svgPoint(atUnits(2.6 * NORM_W, 2.6 * NORM_W));
|
||||
c._decorMove = { id: sh.id, start: grab,
|
||||
orig: JSON.parse(JSON.stringify(sh)), pid: 2, moved: false };
|
||||
c._decorMoveUpdate(atUnits(3.2 * NORM_W, 3.1 * NORM_W));
|
||||
await c.updateComplete;
|
||||
const sh2 = c._decorList.find((x) => x.id === sh.id);
|
||||
o.decorMovedFurtherOut = near(sh2.x, 3.2) && near(sh2.y, 3.1);
|
||||
c._decorMove = null;
|
||||
|
||||
// ---- (e) the ONE bound that is left is the backend's ±5000 ------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev2 = c._devices.find((d) => d.id !== dev.id && !d.virtual);
|
||||
const q0 = c._pos(dev2);
|
||||
c._drag = { id: dev2.id, sx: 400, sy: 400, ox: q0.x, oy: q0.y, moved: false };
|
||||
c._pointerMove(mk(9e9, 9e9), dev2);
|
||||
c._pointerUp(new PointerEvent('pointerup'), dev2);
|
||||
c._drag = null;
|
||||
o.garbageStillClamped = c._layout[dev2.id].x === 5000 && c._layout[dev2.id].y === 5000;
|
||||
|
||||
// ---- (f) Shift cannot land between the nodes ---------------------------
|
||||
const dev3 = c._devices.find((d) => d.id !== dev.id && d.id !== dev2.id && !d.virtual);
|
||||
const r0 = c._pos(dev3);
|
||||
const halfStep = NORM_W / 240 / 2;
|
||||
c._drag = { id: dev3.id, sx: 400, sy: 400, ox: r0.x, oy: r0.y, moved: false };
|
||||
c._pointerMove(mk(2.5 * NORM_W + halfStep - r0.x, 2.5 * NORM_W - r0.y, 400, 400, { shiftKey: true }), dev3);
|
||||
c._drag = null;
|
||||
o.shiftStillSnapsToTheNodes = onGrid(c._layout[dev3.id].x);
|
||||
|
||||
c._setMode('view');
|
||||
return o;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Draw toolbar wall thickness (docs/WALL-THICKNESS.md §6): default 15 cm on
|
||||
* commit, live preview, shared neighbour cm kept.
|
||||
*/
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
|
||||
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
|
||||
c._setMode('plan');
|
||||
c._tool = 'draw';
|
||||
await upd();
|
||||
out.fieldDefault = c._drawWallFieldValue === '15' || c._drawWallFieldValue === '15.0';
|
||||
out.fieldInBar = !!sr().querySelector('.editbar .drawwall input');
|
||||
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
|
||||
const space = sp();
|
||||
const savedRooms = JSON.parse(JSON.stringify(space.rooms || []));
|
||||
const savedWalls = space.walls ? JSON.parse(JSON.stringify(space.walls)) : null;
|
||||
space.rooms = [];
|
||||
delete space.walls;
|
||||
await upd();
|
||||
|
||||
const g = c._gridPitch;
|
||||
// 4×4 cell square
|
||||
const pts = [
|
||||
[100, 100], [100 + 8 * g, 100], [100 + 8 * g, 100 + 8 * g], [100, 100 + 8 * g],
|
||||
];
|
||||
c._path = [];
|
||||
c._tool = 'draw';
|
||||
for (const p of pts) {
|
||||
c._path = [...c._path, p];
|
||||
c._cursorPt = p;
|
||||
await upd();
|
||||
}
|
||||
// close
|
||||
c._path = [...c._path, pts[0]];
|
||||
await upd();
|
||||
out.previewWhileOpen = !!sr().querySelector('.drawwall-preview')
|
||||
|| c._drawWallCm === 15;
|
||||
// force closed state if helper exists
|
||||
if (!c._contourClosed) {
|
||||
// last point equals first — _contourClosed should be true
|
||||
}
|
||||
out.contourClosed = !!c._contourClosed;
|
||||
c._nameSel = 'ThickDraw';
|
||||
c._areaSel = '';
|
||||
c._commitRoom();
|
||||
await upd();
|
||||
|
||||
out.roomSaved = (space.rooms || []).some((r) => r.name === 'ThickDraw');
|
||||
out.wallsApplied = (space.walls || []).length >= 4
|
||||
&& (space.walls || []).every((w) => w.cm === 15);
|
||||
out.bodyDrawn = sr().querySelectorAll('[data-hp="wall"]').length >= 1;
|
||||
|
||||
// second room sharing the right edge — neighbour keeps 15, new edges get 20
|
||||
c._drawWallField = '20';
|
||||
const r1 = space.rooms.find((r) => r.name === 'ThickDraw');
|
||||
const poly = r1.poly.map((p) => [p[0] * 1000, p[1] * (c._spaceH || 1000)]);
|
||||
// shared vertical: right edge of r1
|
||||
const x1 = Math.max(...poly.map((p) => p[0]));
|
||||
const y0 = Math.min(...poly.map((p) => p[1]));
|
||||
const y1 = Math.max(...poly.map((p) => p[1]));
|
||||
const w = 6 * g;
|
||||
c._path = [
|
||||
[x1, y0], [x1 + w, y0], [x1 + w, y1], [x1, y1], [x1, y0],
|
||||
];
|
||||
await upd();
|
||||
c._nameSel = 'Neighbour';
|
||||
c._commitRoom();
|
||||
await upd();
|
||||
|
||||
const sharedStill15 = (space.walls || []).some((w) => w.cm === 15);
|
||||
const has20 = (space.walls || []).some((w) => w.cm === 20);
|
||||
out.sharedKept = sharedStill15 && has20;
|
||||
|
||||
// restore demo space
|
||||
space.rooms = savedRooms;
|
||||
if (savedWalls) space.walls = savedWalls;
|
||||
else delete space.walls;
|
||||
c._path = [];
|
||||
await upd();
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -3,6 +3,45 @@ const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const deepActive = () => {
|
||||
let active = document.activeElement;
|
||||
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
|
||||
return active;
|
||||
};
|
||||
|
||||
// A11Y-02: the shared native fallback carries the same modal/focus contract
|
||||
// that ha-dialog supplies in Home Assistant.
|
||||
const opener = document.createElement('button');
|
||||
opener.textContent = 'open settings';
|
||||
sr.append(opener);
|
||||
opener.focus();
|
||||
c._openSettingsDialog(); await c.updateComplete; await frame();
|
||||
const hp = sr.querySelector('hp-dialog');
|
||||
const native = hp?.shadowRoot?.querySelector('dialog');
|
||||
const titleId = native?.getAttribute('aria-labelledby');
|
||||
out.sharedShell = !!hp && !!native?.open;
|
||||
out.modalSemantics = native?.getAttribute('role') === 'dialog'
|
||||
&& native?.getAttribute('aria-modal') === 'true'
|
||||
&& !!titleId && !!hp.shadowRoot.getElementById(titleId)?.textContent.trim();
|
||||
let active = deepActive();
|
||||
out.initialFocus = !!active && (hp.contains(active) || hp.shadowRoot.contains(active));
|
||||
const focusable = [...hp.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])')];
|
||||
const last = focusable.at(-1);
|
||||
last?.focus();
|
||||
last?.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Tab', bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
active = deepActive();
|
||||
out.focusTrap = active?.classList?.contains('close') === true;
|
||||
active?.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape', bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await c.updateComplete; await frame();
|
||||
out.restoreFocus = !c._settingsDialog && deepActive() === opener;
|
||||
opener.remove();
|
||||
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
// общие настройки
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
|
||||
@@ -50,7 +50,7 @@ const res = await page.evaluate(async () => {
|
||||
// 5) превью есть и в диалоге пространства
|
||||
c._openSpaceDialog('edit', spId); await c.updateComplete;
|
||||
out.spacePreview = !!sr().querySelector('.cardpreview');
|
||||
out.spaceSlider = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('space.card_font'));
|
||||
out.spaceSlider = [...sr().querySelectorAll('hp-dialog label')].some((l) => l.textContent === c._t('space.card_font'));
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
// Библиотека мебели в декор-слое (docs/FURNITURE.md, задача владельца №2):
|
||||
// 1) в панели декора есть инструмент «Мебель», он открывает ПАЛИТРУ с
|
||||
// обязательными группами (мебель / техника / сантехника) и превью;
|
||||
// 2) выбранный символ ставится кликом по плану в РЕАЛЬНОМ размере — через
|
||||
// cell_cm пространства, и сразу оказывается выделенным в «выбрать»;
|
||||
// 3) поля ширины/глубины в палитре меняют размер ДО размещения;
|
||||
// 4) магнит к стене: предмет прижимается к ближайшей стене и доворачивается
|
||||
// по ней; Shift не отключает обязательную привязку (docs/CANVAS.md §9.4);
|
||||
// 5) у выделенного предмета та же рамка, что у текстового блока, — угловые
|
||||
// ручки (НЕзависимые ширина и глубина) и ручка поворота с шагом 5°;
|
||||
// 6) во время растягивания угла показываются живые плашки размеров;
|
||||
// 7) фигура пишется в конфиг и переживает пересборку, хуки card-mod на месте.
|
||||
// ПАДАЕТ на сборке до этой задачи: инструмента «Мебель» нет, палитры нет,
|
||||
// kind:'furniture' не рисуется, магнита и плашек не существует. Смок при этом
|
||||
// null-safe — он показывает СПИСОК провалов, а не одно исключение.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const stageEl = () => sr().querySelector('.stage');
|
||||
const el = (id) => (id ? sr().querySelector(`.decorlayer [data-id="${id}"]`) : null);
|
||||
const attr = (id, n) => { const e = el(id); return e ? e.getAttribute(n) : null; };
|
||||
const toScreen = (x, y) => {
|
||||
const r = stageEl().getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
|
||||
clientY: r.top + ((y - v.y) / v.h) * r.height };
|
||||
};
|
||||
const ev = (type, target, x, y, extra = {}) => {
|
||||
// null-safe: на сборке ДО этой задачи фигуры нет вовсе, а значит нет и
|
||||
// её координат — жест просто не происходит, и смок печатает список
|
||||
// провалов вместо одного исключения
|
||||
if (!target || !Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
const { clientX, clientY } = toScreen(x, y);
|
||||
target.dispatchEvent(new PointerEvent(type, { bubbles: true, composed: true,
|
||||
cancelable: true, pointerId: 11, clientX, clientY, button: 0, isPrimary: true, ...extra }));
|
||||
};
|
||||
const PITCH = 1000 / 240; // GRID_PITCH — одна клетка сетки
|
||||
const CELL = 5; // cell_cm демо-пространства (по умолчанию)
|
||||
const cmToUnits = (cm) => (cm / CELL) * PITCH;
|
||||
const near = (a, b, tol) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= tol;
|
||||
const pe = (id) => { const e = el(id); return e ? getComputedStyle(e).pointerEvents : null; };
|
||||
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [];
|
||||
c._decorTool = 'select'; c._decorSel = null; c._furnPalette = null;
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
out.cellCmIsFive = c._cellCm === CELL;
|
||||
|
||||
// ================= 1. инструмент и палитра ==============================
|
||||
const toolBtn = [...sr().querySelectorAll('.decorbar .dtool')]
|
||||
.find((b) => /мебел|furnitur/i.test(b.textContent || ''));
|
||||
out.furnitureToolInTheBar = !!toolBtn;
|
||||
out.noPaletteBeforeTheTool = !sr().querySelector('.furnpalette');
|
||||
if (toolBtn) toolBtn.click();
|
||||
await c.updateComplete;
|
||||
out.toolIsSelected = c._decorTool === 'furniture';
|
||||
const pal = () => sr().querySelector('.furnpalette');
|
||||
out.paletteOpens = !!pal();
|
||||
const groups = [...(pal()?.querySelectorAll('.furngroup') || [])]
|
||||
.map((g) => g.getAttribute('data-group'));
|
||||
out.groupsArePresent = ['furniture', 'appliance', 'sanitary'].every((g) => groups.includes(g));
|
||||
out.everyGroupHasSymbols = groups.length > 0 && [...pal().querySelectorAll('.furnrow')]
|
||||
.every((r) => r.querySelectorAll('.furnitem').length > 0);
|
||||
const item = (sym) => pal()?.querySelector(`.furnitem[data-symbol="${sym}"]`);
|
||||
const pick = (sym) => { const b = item(sym); if (b) b.click(); };
|
||||
out.sofaIsInThePalette = !!item('sofa');
|
||||
out.toiletIsInThePalette = !!item('toilet');
|
||||
out.washerIsInThePalette = !!item('washer');
|
||||
const prevD = item('sofa')?.querySelector('svg.furnprev path')?.getAttribute('d') || '';
|
||||
out.previewIsDrawn = prevD.length > 10;
|
||||
out.namesAreLocalised = /\S/.test(item('sofa')?.querySelector('span')?.textContent || '');
|
||||
|
||||
// ================= 2. размер по умолчанию и поля =========================
|
||||
pick('sofa'); await c.updateComplete;
|
||||
out.pickArmsTheSymbol = c._furnPalette?.symbol === 'sofa';
|
||||
out.defaultSizeIsTheSymbols = c._furnPalette?.w === 220 && c._furnPalette?.h === 90;
|
||||
const wIn = () => pal()?.querySelector('input.furnw');
|
||||
const hIn = () => pal()?.querySelector('input.furnh');
|
||||
out.sizeFieldsAppear = !!wIn() && !!hIn();
|
||||
out.fieldsShowMetres = +(wIn()?.value ?? NaN) === 2.2 && +(hIn()?.value ?? NaN) === 0.9;
|
||||
out.selectedItemIsMarked = !!item('sofa')?.classList.contains('on');
|
||||
|
||||
// ================= 3. размещение в реальном размере ======================
|
||||
// середина комнаты r1 (40..550 × 140..580) — до стен дальше порога магнита
|
||||
ev('pointerdown', stageEl(), 300, 300);
|
||||
await c.updateComplete;
|
||||
const sofa = c._decorList.find((s) => s.kind === 'furniture');
|
||||
const sofaId = sofa?.id ?? null;
|
||||
const sofaNow = () => c._decorList.find((s) => s.id === sofaId) || {};
|
||||
out.placedOnClick = sofa?.symbol === 'sofa';
|
||||
out.realWidthThroughCellCm = near((sofa?.w ?? NaN) * 1000, cmToUnits(220), 1e-6);
|
||||
out.realDepthThroughCellCm = near((sofa?.h ?? NaN) * 1000, cmToUnits(90), 1e-6);
|
||||
out.centredOnTheClick = !!sofa
|
||||
&& near((sofa.x + sofa.w / 2) * 1000, 300, 1e-6)
|
||||
&& near((sofa.y + sofa.h / 2) * 1000, 300, 1e-6);
|
||||
out.noAngleWhenStraight = !!sofa && sofa.angle === undefined;
|
||||
out.selectedRightAway = !!sofaId && c._decorSel === sofaId;
|
||||
out.toolWentBackToSelect = c._decorTool === 'select';
|
||||
out.paletteDisarmed = c._furnPalette === null;
|
||||
// …и она НАРИСОВАНА, со всеми хуками card-mod (docs/STYLING-HOOKS.md §3)
|
||||
out.rendered = el(sofaId)?.tagName?.toLowerCase() === 'path';
|
||||
out.hookHp = attr(sofaId, 'data-hp') === 'decor';
|
||||
out.hookKind = attr(sofaId, 'data-kind') === 'furniture';
|
||||
out.hookSymbol = attr(sofaId, 'data-symbol') === 'sofa';
|
||||
out.strokedNotFilled = attr(sofaId, 'fill') === 'none'
|
||||
&& attr(sofaId, 'stroke') === c._decorStyle.color;
|
||||
out.pathIsAtRealSize = /^M0 0H183\.333V75H0Z/.test(attr(sofaId, 'd') || '');
|
||||
|
||||
// ================= 4. свой размер до размещения ==========================
|
||||
c._decorTool = 'furniture'; await c.updateComplete;
|
||||
pick('toilet'); await c.updateComplete;
|
||||
if (wIn()) {
|
||||
wIn().value = '0.5';
|
||||
wIn().dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
await c.updateComplete;
|
||||
out.fieldEditsTheArmedSize = Math.abs((c._furnPalette?.w ?? 0) - 50) < 1e-6;
|
||||
ev('pointerdown', stageEl(), 300, 400);
|
||||
await c.updateComplete;
|
||||
const wc = c._decorList.find((s) => s.symbol === 'toilet');
|
||||
out.typedSizeIsUsed = near((wc?.w ?? NaN) * 1000, cmToUnits(50), 1e-6)
|
||||
&& near((wc?.h ?? NaN) * 1000, cmToUnits(70), 1e-6);
|
||||
|
||||
// ================= 5. магнит к стене =====================================
|
||||
c._decorTool = 'furniture'; await c.updateComplete;
|
||||
pick('bed_double'); await c.updateComplete;
|
||||
ev('pointerdown', stageEl(), 300, 150); // 10 единиц от стены y=140
|
||||
await c.updateComplete;
|
||||
const bed = c._decorList.find((s) => s.symbol === 'bed_double');
|
||||
const depth = cmToUnits(200);
|
||||
out.wallMagnetPullsItFlat = !!bed
|
||||
&& near((bed.y + bed.h / 2) * 1000, 140 + depth / 2, 1e-6);
|
||||
out.wallMagnetKeepsTheAngle = !!bed && (bed.angle || 0) === 0; // стена горизонтальна
|
||||
out.wallMagnetQuantisesAlongTheWall = !!bed
|
||||
&& Math.abs((bed.x + bed.w / 2) * 1000 - 300) <= PITCH + 1e-6;
|
||||
|
||||
// …Shift больше не отключает магнит
|
||||
c._decorTool = 'furniture'; await c.updateComplete;
|
||||
pick('bed_single'); await c.updateComplete;
|
||||
ev('pointerdown', stageEl(), 301.7, 151.3, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
const free = c._decorList.find((s) => s.symbol === 'bed_single');
|
||||
out.shiftKeepsTheMagnet = !!free
|
||||
&& !near((free.y + free.h / 2) * 1000, 151.3, 1e-6)
|
||||
&& free.angle === undefined;
|
||||
|
||||
// …магнит работает и при ПЕРЕТАСКИВАНИИ: тянем диван к левой стене (x=40)
|
||||
c._decorTool = 'select'; c._decorSel = sofaId; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(40); await c.updateComplete;
|
||||
ev('pointerdown', el(sofaId), 300, 300);
|
||||
out.dragStarted = !!sofaId && c._decorMove?.id === sofaId;
|
||||
ev('pointermove', stageEl(), 55, 300); // 15 единиц от стены x=40
|
||||
await c.updateComplete;
|
||||
const pulled = sofaNow();
|
||||
out.dragMagnetTurnsItToTheWall = Math.abs(Math.abs(pulled.angle || 0) - 90) < 1e-6;
|
||||
out.dragMagnetPressesTheBack =
|
||||
near((pulled.x + pulled.w / 2) * 1000, 40 + cmToUnits(90) / 2, 1e-6);
|
||||
ev('pointermove', stageEl(), 300, 300, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
const shiftDragGridIndex = (sofaNow().x * 1000) / PITCH;
|
||||
out.shiftDragRemainsGridBound = near(shiftDragGridIndex, Math.round(shiftDragGridIndex), 1e-6);
|
||||
ev('pointerup', stageEl(), 300, 300, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
|
||||
// ================= 6. рамка, углы, плашки, поворот =======================
|
||||
c._decorSel = sofaId; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
const frame = () => sr().querySelector('.dtframe');
|
||||
out.frameOnSelection = !!frame();
|
||||
out.sameFrameAsTheTextBlock = !!frame()
|
||||
&& frame().querySelectorAll('.dthandle').length === 5
|
||||
&& frame().querySelectorAll('.dtknob').length === 5
|
||||
&& !!frame().querySelector('.dtrot');
|
||||
const rOf = (sel) => { const e = frame()?.querySelector(sel); return e ? +e.getAttribute('r') : NaN; };
|
||||
out.handleSizeIsTheTaskOneSize = Math.abs(rOf('.dtknob') * 4 - rOf('.dthandle.dtrot'))
|
||||
/ Math.max(rOf('.dthandle.dtrot'), 1e-9) < 0.05;
|
||||
out.frameBoxIsTheShapeBox = !!c._dtBox
|
||||
&& near(c._dtBox.w, sofaNow().w * 1000, 1e-6)
|
||||
&& near(c._dtBox.h, sofaNow().h * 1000, 1e-6);
|
||||
|
||||
// угол: НЕзависимые ширина и глубина + живые плашки
|
||||
const before = { w: sofaNow().w, h: sofaNow().h };
|
||||
const b = c._dtBox || { x: 0, y: 0, w: 0, h: 0 };
|
||||
const handles = [...(frame()?.querySelectorAll('.dthandle') || [])];
|
||||
const se = handles[3] || stageEl(); // SE — четвёртый угол
|
||||
ev('pointerdown', se, b.x + b.w, b.y + b.h);
|
||||
out.cornerDragStarted = c._dtDrag?.kind === 'scale' && !!c._dtDrag?.orig;
|
||||
ev('pointermove', stageEl(), b.x + b.w + 100, b.y + b.h + 20, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
const grown = sofaNow();
|
||||
out.cornerGrowsTheWidth = grown.w > before.w && near(grown.w * 1000, b.w + 100, 1e-6);
|
||||
out.cornerGrowsTheDepthINDEPENDENTLY = grown.h > before.h
|
||||
&& near(((grown.h * 1000) / PITCH) % 1, 0, 1e-6);
|
||||
out.oppositeCornerStaysPut = near(grown.x * 1000, b.x, 1e-6) && near(grown.y * 1000, b.y, 1e-6);
|
||||
const plates = [...sr().querySelectorAll('.measurelabel.furnmeasure')];
|
||||
out.twoLivePlates = plates.length === 2;
|
||||
out.platesShowRealLengths = plates.length === 2
|
||||
&& plates.every((p) => /\d/.test(p.textContent))
|
||||
&& plates.some((p) => /m|м|′/.test(p.textContent));
|
||||
// …и без Shift размер прилипает к сетке
|
||||
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);
|
||||
ev('pointerup', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
|
||||
await c.updateComplete;
|
||||
out.cornerDragEnded = !c._dtDrag;
|
||||
out.noPlatesAfterTheDrag = !sr().querySelector('.measurelabel.furnmeasure');
|
||||
|
||||
// поворот: шаг 5°, Shift мимо шага — та же механика, что у текста
|
||||
await sleep(40); await c.updateComplete;
|
||||
const cx = (sofaNow().x + sofaNow().w / 2) * 1000;
|
||||
const cy = (sofaNow().y + sofaNow().h / 2) * 1000;
|
||||
const piv = c._dtPivot ? c._dtPivot(sofaNow()) : [NaN, NaN];
|
||||
out.rotatesAboutTheCENTRE = near(piv[0], cx, 1e-6) && near(piv[1], cy, 1e-6);
|
||||
ev('pointerdown', frame()?.querySelector('.dthandle.dtrot'), cx + 100, cy);
|
||||
out.rotateStarted = c._dtDrag?.kind === 'rotate';
|
||||
ev('pointermove', stageEl(), cx + 100, cy + 100); // ровно 45°
|
||||
await c.updateComplete;
|
||||
out.rotates45 = near(sofaNow().angle || 0, 45, 1e-6);
|
||||
ev('pointermove', stageEl(), cx + 100, cy + 2); // ~1.1° → липнет к 0
|
||||
await c.updateComplete;
|
||||
out.snapsTo5 = (sofaNow().angle || 0) === 0;
|
||||
ev('pointermove', stageEl(), cx + 100, cy + 2, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
const fine = sofaNow().angle;
|
||||
out.shiftGoesPastTheStep = fine > 0.5 && fine < 5;
|
||||
ev('pointerup', stageEl(), cx + 100, cy + 2, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
out.rotationIsRendered = /rotate\(/.test(attr(sofaId, 'transform') || '');
|
||||
|
||||
// ================= 7. конфиг и пересборка ================================
|
||||
const stored = JSON.parse(JSON.stringify(sofaNow()));
|
||||
out.storedShapeIsComplete = stored.kind === 'furniture' && typeof stored.symbol === 'string'
|
||||
&& ['x', 'y', 'w', 'h'].every((k) => Number.isFinite(stored[k]));
|
||||
c.hass = { ...c.hass }; // полная пересборка
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(40); await c.updateComplete;
|
||||
const after = sofaNow();
|
||||
out.survivesARebuild = after.symbol === stored.symbol
|
||||
&& 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);
|
||||
|
||||
// ================= 8. инертность и удаление ==============================
|
||||
c._decorTool = 'furniture'; c._furnPalette = null; await c.updateComplete;
|
||||
out.inertUnderTheStamp = pe(sofaId) === 'none';
|
||||
const n0 = c._decorList.length;
|
||||
ev('pointerdown', stageEl(), 320, 320); // символ НЕ выбран
|
||||
await c.updateComplete;
|
||||
out.noSymbolNoStamp = c._decorList.length === n0;
|
||||
c._decorTool = 'erase'; await c.updateComplete;
|
||||
ev('pointerdown', el(sofaId), 300, 300);
|
||||
await c.updateComplete;
|
||||
out.eraseRemovesIt = !!sofaId && !c._decorList.some((s) => s.id === sofaId);
|
||||
// а в режиме просмотра мебель видна и не кликается
|
||||
c._setMode('view'); await c.updateComplete; await sleep(30);
|
||||
const left = c._decorList.find((s) => s.kind === 'furniture');
|
||||
out.visibleInView = !!left && !!el(left.id);
|
||||
out.inertInView = !!left && pe(left.id) === 'none';
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -25,14 +25,78 @@ const res = await page.evaluate(async () => {
|
||||
out.dialogOpens = !!c._spaceDialog;
|
||||
out.tabNotSwitched = c._space === cur;
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
// кнопка "+" по-прежнему только в Плане
|
||||
out.noAddInView = !sr().querySelector('.tab.tabadd');
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
out.addInPlan = !!sr().querySelector('.tab.tabadd');
|
||||
// «+» — навигационное действие, а не инструмент редактора плана
|
||||
// (владелец 2026-08-04): кнопка есть во ВСЕХ режимах и кликабельна.
|
||||
const addBtn = () => sr().querySelector('.tab.tabadd');
|
||||
const hittable = () => {
|
||||
const b = addBtn();
|
||||
if (!b) return 'нет кнопки';
|
||||
const r = b.getBoundingClientRect();
|
||||
if (r.width < 8 || r.height < 8) return `кнопка ${r.width}x${r.height}px`;
|
||||
const hit = sr().elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
return hit && hit.closest && hit.closest('.tab.tabadd') === b ? true : 'перекрыта';
|
||||
};
|
||||
out.addInView = hittable();
|
||||
for (const m of ['plan', 'devices', 'decor']) {
|
||||
c._setMode(m); await c.updateComplete;
|
||||
out['addIn_' + m] = hittable();
|
||||
}
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// …и открывает именно диалог СОЗДАНИЯ
|
||||
const b0 = addBtn();
|
||||
if (b0) { b0.click(); await c.updateComplete; }
|
||||
out.addOpensCreate = !!c._spaceDialog && c._spaceDialog.mode === 'create';
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"alignDelta": 0,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
// киоск — витрина: редактирование недоступно, кнопки «+» нет ВООБЩЕ
|
||||
// (display:none у .hdr не считается: скрытый в DOM узел всё ещё кликабелен
|
||||
// программно и всё ещё ловится автотестами как «есть»)
|
||||
const kio = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
c.hass = window.__mkHass();
|
||||
c.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
|
||||
document.body.appendChild(c);
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
c.hass = { ...c.hass }; await c.updateComplete;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
out.kioskNoAddButton = !sr.querySelector('.tab.tabadd');
|
||||
c.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(kio)) check(k, v);
|
||||
|
||||
// мобильный вид: ряд вкладок переносится, а не выезжает за карточку
|
||||
await page.setViewportSize({ width: 390, height: 760 });
|
||||
const narrow = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const head = sr.querySelector('.head');
|
||||
const btn = sr.querySelector('.tab.tabadd');
|
||||
out.narrowAddPresent = !!btn;
|
||||
out.narrowNoHOverflow = head.scrollWidth <= head.clientWidth + 1
|
||||
? true : `scrollW=${head.scrollWidth} > clientW=${head.clientWidth}`;
|
||||
const cr = sr.querySelector('ha-card').getBoundingClientRect();
|
||||
const br = btn ? btn.getBoundingClientRect() : null;
|
||||
out.narrowAddInsideCard = !br ? 'нет кнопки'
|
||||
: br.right <= cr.right + 1 && br.left >= cr.left - 1 ? true
|
||||
: `btn ${Math.round(br.left)}..${Math.round(br.right)} vs card ${Math.round(cr.left)}..${Math.round(cr.right)}`;
|
||||
out.narrowAddHittable = !br ? 'нет кнопки' : (() => {
|
||||
const hit = sr.elementFromPoint(br.left + br.width / 2, br.top + br.height / 2);
|
||||
return hit && hit.closest && hit.closest('.tab.tabadd') === btn ? true : 'перекрыта';
|
||||
})();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(narrow)) check(k, v);
|
||||
|
||||
await finish(browser, { ...res, ...kio, ...narrow });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -7,7 +8,11 @@ const res = await page.evaluate(async () => {
|
||||
// 1) диалог общих настроек: инвентарь строк и групп (обновлён фичей «Солнце»)
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
out.rows = sr().querySelectorAll('.gsrow').length;
|
||||
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
|
||||
out.groups = [...sr().querySelectorAll('hp-dialog .dispsection')].map((l) => l.textContent.trim());
|
||||
// 1b) блок About: версия + две внешние ссылки (target=_blank rel=noopener)
|
||||
out.aboutVersion = sr().querySelector('hp-dialog .aboutver')?.textContent.trim() ?? null;
|
||||
out.aboutLinks = [...sr().querySelectorAll('hp-dialog a.aboutlink')].map((a) => ({
|
||||
href: a.getAttribute('href'), target: a.getAttribute('target'), rel: a.getAttribute('rel') }));
|
||||
// 2) сменить цвет light_on и сохранить
|
||||
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
|
||||
await c._saveSettingsDialog(); await c.updateComplete;
|
||||
@@ -26,10 +31,25 @@ const res = await page.evaluate(async () => {
|
||||
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
|
||||
return out;
|
||||
});
|
||||
// CARD_VERSION из собранного бандла (тот же текст уходит в console-баннер).
|
||||
// terser либо инлайнит строку (v1.56.0), либо оставляет переменную (v${xx}) —
|
||||
// во втором случае доразрешаем её по присваиванию xx="1.56.0".
|
||||
// Версия — SemVer, у пре-релиза есть суффикс (1.58.0-beta.1), он тоже часть строки.
|
||||
const bundle = readFileSync(new URL('./srv/assets/houseplan-card.js', import.meta.url), 'utf8');
|
||||
const SEMVER = '\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?';
|
||||
const m = bundle.match(new RegExp(`HOUSEPLAN-CARD %c v(?:(${SEMVER})|\\$\\{(\\w+)\\})`));
|
||||
const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[2]}="(${SEMVER})"`))?.[1]);
|
||||
check('bundleVersionFound', typeof BUNDLE_VERSION === 'string' && BUNDLE_VERSION.length > 0);
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"rows": 14, // 10 цветов + радиус свечения + режим фона + цвет фона + погода (docs/SUN.md)
|
||||
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background", "Sun"],
|
||||
"rows": 16, // 11 цветов (включая wall_fill) + радиус свечения + фон + погода (docs/SUN.md)
|
||||
// + «Оптимизировать планы» (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}`, // та же константа, что в баннере
|
||||
"aboutLinks": [
|
||||
{ "href": "https://github.com/Matysh/houseplan-card", "target": "_blank", "rel": "noopener" },
|
||||
{ "href": "https://t.me/ha_houseplan", "target": "_blank", "rel": "noopener" },
|
||||
],
|
||||
"saved": {"c": "#ff00ff", "a": 0.5},
|
||||
"lqiBefore": 7,
|
||||
"lqiAfter": 0,
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// «Всё строго по сетке» (docs/CANVAS.md §9). Two halves:
|
||||
// 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;
|
||||
// 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.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const NORM_W = 1000, GRID_N = 240;
|
||||
const PITCH = NORM_W / GRID_N;
|
||||
const onGridN = (v) => Math.abs(v * GRID_N - Math.round(v * GRID_N)) < 1e-9;
|
||||
const onGridR = (v) => Math.abs(v / PITCH - Math.round(v / PITCH)) < 1e-7;
|
||||
|
||||
/** A click/pointer event at a given RENDER-unit point of the current view. */
|
||||
const at = (X, Y, type = 'pointermove', extra = {}) => {
|
||||
const rect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return new PointerEvent(type, {
|
||||
clientX: rect.left + ((X - v.x) / v.w) * rect.width,
|
||||
clientY: rect.top + ((Y - v.y) / v.h) * rect.height, bubbles: true, ...extra,
|
||||
});
|
||||
};
|
||||
// an off-grid target: a third of a step past a node, in both axes
|
||||
const OFF = PITCH / 3;
|
||||
|
||||
// ---- 1a) the snap itself, the one gate every gesture goes through -----
|
||||
const s1 = c._snap([100 + OFF, 200 + OFF]);
|
||||
o.snapRoundsToTheNode = onGridR(s1[0]) && onGridR(s1[1]);
|
||||
const s2 = c._snap([100 + OFF, 200 + OFF], { shiftKey: true });
|
||||
o.shiftCannotSuspendTheSnap = onGridR(s2[0]) && onGridR(s2[1]);
|
||||
o.snapAlsoClampsTheGarbage = c._snap([1e12, -1e12])[0] === 5000 * NORM_W;
|
||||
// the step is NORM_W/GRID_N and nothing else — it did NOT change with the
|
||||
// infinite canvas, so no plan's nodes ever moved out from under it
|
||||
o.pitchIsTheCanvasFreeConstant = Math.abs(c._gridPitch - PITCH) < 1e-12;
|
||||
|
||||
// ---- 1b) a DEVICE dropped with the mouse lands on a node --------------
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => !d.virtual);
|
||||
const p0 = c._pos(dev);
|
||||
const mkDelta = (dxu, dyu, extra = {}) => {
|
||||
const rect = sr().querySelector('.stage').getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return new PointerEvent('pointermove', {
|
||||
clientX: 400 + (dxu / v.w) * rect.width, clientY: 400 + (dyu / v.h) * rect.height,
|
||||
bubbles: true, ...extra,
|
||||
});
|
||||
};
|
||||
c._drag = { id: dev.id, sx: 400, sy: 400, ox: p0.x, oy: p0.y, moved: false };
|
||||
c._pointerMove(mkDelta(137.7 - p0.x + OFF, 251.3 - p0.y + OFF), dev);
|
||||
c._drag = null;
|
||||
o.deviceLandsOnANode = onGridN(c._layout[dev.id].x) && onGridN(c._layout[dev.id].y);
|
||||
|
||||
// ---- 1c) a ROOM LABEL too ---------------------------------------------
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const room = c._spaceModel(c._space).rooms.find((r) => r.name);
|
||||
const lp = c._labelPos(room, c._space);
|
||||
c._drag = { id: 'rl_' + room.id, sx: 400, sy: 400, ox: lp.x, oy: lp.y, moved: false };
|
||||
c._labelMove(mkDelta(311.4 - lp.x + OFF, 402.9 - lp.y + OFF), room, c._space);
|
||||
c._drag = null;
|
||||
o.roomLabelLandsOnANode = onGridN(c._layout['rl_' + room.id].x)
|
||||
&& onGridN(c._layout['rl_' + room.id].y);
|
||||
// …and one that has never been dragged is on a node as well
|
||||
const other = c._spaceModel(c._space).rooms.find((r) => r.name && r.id !== room.id);
|
||||
const op = c._labelPos(other, c._space);
|
||||
o.untouchedLabelIsOnANode = onGridR(op.x) && onGridR(op.y);
|
||||
|
||||
// ---- 1d) DECOR: draft, text anchor, and a move ------------------------
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._decorTool = 'rect';
|
||||
// dispatched for real, so the handler sees a target (it looks for .dshape)
|
||||
sr().querySelector('.stage').dispatchEvent(at(300 + OFF, 300 + OFF, 'pointerdown', { pointerId: 21 }));
|
||||
o.decorDraftStartsOnANode = onGridR(c._decorDraft.a[0]) && onGridR(c._decorDraft.a[1]);
|
||||
c._decorDraft = { ...c._decorDraft, b: c._snap([420 + OFF, 380 + OFF]) };
|
||||
c._decorCommitDraft(); await c.updateComplete;
|
||||
const dsh = c._decorList[c._decorList.length - 1];
|
||||
o.decorShapeIsOnNodes = onGridN(dsh.x) && onGridN(dsh.y)
|
||||
&& onGridN(dsh.x + dsh.w) && onGridN(dsh.y + dsh.h);
|
||||
c._decorTool = 'text';
|
||||
sr().querySelector('.stage').dispatchEvent(at(500 + OFF, 500 + OFF, 'pointerdown', { pointerId: 22 }));
|
||||
o.decorTextAnchorIsOnANode = onGridN(c._decorTextDialog.x) && onGridN(c._decorTextDialog.y);
|
||||
c._decorTextDialog = null;
|
||||
// a shape that is ALREADY off the grid is put on it by one drag: the mover
|
||||
// snaps the resulting anchor, not the delta (which used to preserve the drift)
|
||||
const sp = c._curSpaceCfg;
|
||||
sp.decor = [...c._decorList, { id: 'dcOff', kind: 'rect',
|
||||
x: 0.3013, y: 0.4017, w: 0.1, h: 0.05, color: '#889', width: 2 }];
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const off = c._decorList.find((x) => x.id === 'dcOff');
|
||||
c._decorMove = { id: 'dcOff', start: c._svgPoint(at(301.3, 401.7)),
|
||||
orig: JSON.parse(JSON.stringify(off)), pid: 9, moved: false };
|
||||
c._decorMoveUpdate(at(351.3, 451.7));
|
||||
await c.updateComplete;
|
||||
const off2 = c._decorList.find((x) => x.id === 'dcOff');
|
||||
o.oneDragPutsADriftedShapeOnTheGrid = onGridN(off2.x) && onGridN(off2.y);
|
||||
c._decorMove = null;
|
||||
sp.decor = sp.decor.filter((x) => x.id !== 'dcOff' && x.id !== dsh.id);
|
||||
|
||||
// ---- 1e) an OPENING is WALL-bound: on its wall, whole steps along it ---
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
c._tool = 'opening';
|
||||
const spm = c._spaceModel(c._space);
|
||||
const wall = (() => {
|
||||
for (const r of spm.rooms) {
|
||||
const poly = r.poly || null;
|
||||
if (!poly) continue;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) > PITCH * 20) return [a, b];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
o.foundAWallToTestWith = !!wall;
|
||||
if (wall) {
|
||||
const [a, b] = wall;
|
||||
const mid = [(a[0] + b[0]) / 2 + 0.37 * PITCH, (a[1] + b[1]) / 2 + 0.4];
|
||||
c._openingClick(mid);
|
||||
const d = c._openingDialog;
|
||||
o.openingPlaced = !!d;
|
||||
if (d) {
|
||||
// exactly on the wall segment…
|
||||
const t = ((d.x - a[0]) * (b[0] - a[0]) + (d.y - a[1]) * (b[1] - a[1]))
|
||||
/ ((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2);
|
||||
const px = a[0] + t * (b[0] - a[0]), py = a[1] + t * (b[1] - a[1]);
|
||||
o.openingSitsOnItsWall = Math.hypot(d.x - px, d.y - py) < 1e-6;
|
||||
// …and at a whole number of steps along it (or at the wall's own centre,
|
||||
// which is what the magnet is for and what the ruler shows)
|
||||
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
const along = t * len;
|
||||
o.openingIsQuantisedAlongTheWall =
|
||||
Math.abs(along / PITCH - Math.round(along / PITCH)) < 1e-6
|
||||
|| Math.abs(along - len / 2) < 1e-6;
|
||||
}
|
||||
c._openingDialog = null;
|
||||
}
|
||||
|
||||
// ---- 1f) a room VERTEX drawn by hand ----------------------------------
|
||||
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 = []; c._tool = 'draw';
|
||||
|
||||
// ---- 2) «Оптимизировать планы» -----------------------------------------
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// a deliberately detuned fixture: a third of a step off, everywhere
|
||||
const D = 1 / GRID_N / 3;
|
||||
const FIX = { spaces: [{
|
||||
id: 'g1', title: 'Grid test', view_box: [0, 0, 1, 1],
|
||||
rooms: [
|
||||
{ id: 'r1', name: 'A', area: 'living_room',
|
||||
poly: [[0.2 + D, 0.2], [0.5, 0.2 - D], [0.5 + D, 0.5], [0.2, 0.5]] },
|
||||
{ id: 'r2', name: 'B', area: 'kitchen', x: 0.5, y: 0.2, w: 0.3 + D, h: 0.3 },
|
||||
],
|
||||
openings: [{ id: 'o1', type: 'window', x: 0.3 + D, y: 0.2 + D / 2, angle: 0, length: 0.06 }],
|
||||
decor: [{ id: 'd1', kind: 'line', x1: 0.1 + D, y1: 0.7, x2: 0.9, y2: 0.7 - D }],
|
||||
}], markers: [], settings: {} };
|
||||
const FIXLAY = { d_light1: { s: 'g1', x: 0.3 + D, y: 0.3 }, rl_r1: { s: 'g1', x: 0.25 + D, y: 0.25 } };
|
||||
c._serverCfg = JSON.parse(JSON.stringify(FIX));
|
||||
c._layout = JSON.parse(JSON.stringify(FIXLAY));
|
||||
c._modelCache = null; c._frame = null; c._space = 'g1';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
c._openAlignDialog();
|
||||
await c.updateComplete;
|
||||
const dlg = c._alignDialog;
|
||||
o.alignDialogOpens = !!dlg && !!sr().querySelector('hp-dialog .alignmsg');
|
||||
o.alignCountsWhatMoves = !!dlg && dlg.report.moved > 0 && dlg.report.moved <= dlg.report.total;
|
||||
// half a step of 5 cm cells ≈ 2.5 cm; a third of a step is well under that
|
||||
o.alignPromisesASmallShift = !!dlg && dlg.cm > 0 && dlg.cm < 3;
|
||||
o.alignExplainsSafeUndo = (sr().querySelector('hp-dialog .rhint')?.textContent || '').length > 20;
|
||||
|
||||
// capture what the network is told, then run it
|
||||
const sent = [];
|
||||
const base = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m.type); return base(m); } };
|
||||
await c._runAlignToGrid();
|
||||
await c.updateComplete;
|
||||
o.alignDialogClosed = c._alignDialog === null;
|
||||
o.alignUsedAtomicConfigLayoutWrite = sent.filter((t) => t === 'houseplan/plan/optimize').length === 1;
|
||||
|
||||
const g = c._serverCfg.spaces[0];
|
||||
o.alignedRoomPoly = g.rooms[0].poly.every((p) => onGridN(p[0]) && onGridN(p[1]));
|
||||
o.alignedRoomRect = onGridN(g.rooms[1].x) && onGridN(g.rooms[1].x + g.rooms[1].w);
|
||||
o.alignedDecor = onGridN(g.decor[0].x1) && onGridN(g.decor[0].y1)
|
||||
&& onGridN(g.decor[0].x2) && onGridN(g.decor[0].y2);
|
||||
o.alignedLayout = onGridN(c._layout.d_light1.x) && onGridN(c._layout.rl_r1.x);
|
||||
// the opening stayed ON the top wall of r1 rather than being rounded off it
|
||||
const w0 = g.rooms[0].poly[0], w1 = g.rooms[0].poly[1];
|
||||
const tt = ((g.openings[0].x - w0[0]) * (w1[0] - w0[0]) + (g.openings[0].y - w0[1]) * (w1[1] - w0[1]))
|
||||
/ ((w1[0] - w0[0]) ** 2 + (w1[1] - w0[1]) ** 2);
|
||||
o.alignedOpeningStaysOnItsWall = Math.hypot(
|
||||
g.openings[0].x - (w0[0] + tt * (w1[0] - w0[0])),
|
||||
g.openings[0].y - (w0[1] + tt * (w1[1] - w0[1]))) < 1e-9;
|
||||
|
||||
// ---- 2b) …and the second run has nothing to do ------------------------
|
||||
c._openAlignDialog();
|
||||
await c.updateComplete;
|
||||
o.secondRunMovesNothing = c._alignDialog.report.moved === 0;
|
||||
o.secondRunOffersNoButton = !sr().querySelector('hp-dialog .btn.on');
|
||||
c._alignDialog = null;
|
||||
await c.updateComplete;
|
||||
|
||||
// ---- 2c) the promise is an UPPER BOUND, in EACH space's own scale -----
|
||||
// AUD-158B1-01: one normalised maximum converted through the first space's
|
||||
// cell size promised 2.5 cm for a vertex that moved 50 cm on the floor above.
|
||||
const MULTI = { spaces: [
|
||||
{ id: 'm1', title: 'Ground', cell_cm: 5, view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'ma', name: 'A', area: 'living_room',
|
||||
poly: [[0.1, 0.1], [0.3, 0.1], [0.3, 0.3], [0.1, 0.3]] }] },
|
||||
{ id: 'm2', title: 'Attic', cell_cm: 100, view_box: [0, 0, 1, 1],
|
||||
// the ONLY off-grid vertex on the plan, and it is on the 100 cm floor
|
||||
rooms: [{ id: 'mb', name: 'B', area: 'kitchen',
|
||||
poly: [[0.2 + 1 / GRID_N / 2, 0.2], [0.4, 0.2], [0.4, 0.4], [0.2, 0.4]] }] },
|
||||
], markers: [], settings: {} };
|
||||
c._serverCfg = JSON.parse(JSON.stringify(MULTI));
|
||||
c._layout = {};
|
||||
c._modelCache = null; c._frame = null; c._space = 'm1';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._openAlignDialog(); await c.updateComplete;
|
||||
const md = c._alignDialog;
|
||||
o.alignPromiseUsesTheOwnScaleOfEachSpace = !!md && md.cm >= 50 && md.cm < 51;
|
||||
o.alignPromiseNamesTheSpaceItBelongsTo = !!md && md.where === 'Attic'
|
||||
&& (sr().querySelector('hp-dialog .body')?.textContent || '').includes('Attic');
|
||||
c._alignDialog = null; await c.updateComplete;
|
||||
|
||||
// ---- 2d) an angle-only opening fix is offerable (AUD-158B1-02) --------
|
||||
// its centre is already on the wall; only the stored angle is wrong, and the
|
||||
// batch used to report "nothing to move" while returning a different plan
|
||||
const TURN = { spaces: [{ id: 'a1', title: 'Flat', cell_cm: 5, view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'r1', name: 'A', area: 'living_room',
|
||||
poly: [[0.2, 0.2], [0.5, 0.2], [0.5, 0.5], [0.2, 0.5]] }],
|
||||
openings: [{ id: 'oa', type: 'window', x: 0.35, y: 0.2, angle: 90, length: 0.1 }],
|
||||
}], markers: [], settings: {} };
|
||||
c._serverCfg = JSON.parse(JSON.stringify(TURN));
|
||||
c._layout = {};
|
||||
c._modelCache = null; c._frame = null; c._space = 'a1';
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._openAlignDialog(); await c.updateComplete;
|
||||
const ad = c._alignDialog;
|
||||
o.angleOnlyOpeningCounts = !!ad && ad.report.moved === 1 && ad.report.rotated === 1;
|
||||
o.angleOnlyOpeningOffersTheButton = !!sr().querySelector('hp-dialog .btn.on');
|
||||
await c._runAlignToGrid();
|
||||
await c.updateComplete;
|
||||
o.angleOnlyOpeningIsActuallyFixed = c._serverCfg.spaces[0].openings[0].angle === 0;
|
||||
|
||||
return o;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
+11
-11
@@ -14,9 +14,9 @@ const res = await page.evaluate(async () => {
|
||||
|
||||
// --- 1) fallback branch: no ha-* registered -> classic inputs ----------
|
||||
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
|
||||
out.fallbackCheckbox = !!sr().querySelector('.dialog .srcrow input[type=checkbox]');
|
||||
out.fallbackRange = !!sr().querySelector('.dialog input[type=range]');
|
||||
out.fallbackNoHaSwitch = !sr().querySelector('.dialog ha-switch');
|
||||
out.fallbackCheckbox = !!sr().querySelector('hp-dialog .srcrow input[type=checkbox]');
|
||||
out.fallbackRange = !!sr().querySelector('hp-dialog input[type=range]');
|
||||
out.fallbackNoHaSwitch = !sr().querySelector('hp-dialog ha-switch');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
|
||||
// --- 2) register stubs mimicking the HA contract -----------------------
|
||||
@@ -35,20 +35,20 @@ const res = await page.evaluate(async () => {
|
||||
});
|
||||
|
||||
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
|
||||
const switches = [...sr().querySelectorAll('.dialog ha-switch')];
|
||||
const switches = [...sr().querySelectorAll('hp-dialog ha-switch')];
|
||||
out.haSwitchRendered = switches.length >= 2; // is_light + hide-from-plan at minimum
|
||||
out.haNoPlainCheckbox = !sr().querySelector('.dialog .srcrow input[type=checkbox]');
|
||||
const sliders = [...sr().querySelectorAll('.dialog ha-slider')];
|
||||
out.haNoPlainCheckbox = !sr().querySelector('hp-dialog .srcrow input[type=checkbox]');
|
||||
const sliders = [...sr().querySelectorAll('hp-dialog ha-slider')];
|
||||
out.haSliderRendered = sliders.length >= 2; // size + angle
|
||||
|
||||
// --- 3) card -> control: state is pushed into the element --------------
|
||||
c._markerDialog = { ...c._markerDialog, isLight: true, hideFromPlan: false };
|
||||
await c.updateComplete;
|
||||
out.downstreamChecked = [...sr().querySelectorAll('.dialog ha-switch')].some((el) => el.checked === true);
|
||||
out.downstreamChecked = [...sr().querySelectorAll('hp-dialog ha-switch')].some((el) => el.checked === true);
|
||||
|
||||
// --- 4) control -> card: change with .checked lands in the dialog ------
|
||||
const before = c._markerDialog.hideFromPlan;
|
||||
const sw = [...sr().querySelectorAll('.dialog ha-switch')].find((el) => el.checked === before);
|
||||
const sw = [...sr().querySelectorAll('hp-dialog ha-switch')].find((el) => el.checked === before);
|
||||
sw.checked = !before;
|
||||
sw.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await c.updateComplete;
|
||||
@@ -57,7 +57,7 @@ const res = await page.evaluate(async () => {
|
||||
|
||||
// --- 5) slider both ways ----------------------------------------------
|
||||
const sizeBefore = c._markerDialog.size;
|
||||
const sl = [...sr().querySelectorAll('.dialog ha-slider')].find((el) => Number(el.value) === Number(sizeBefore));
|
||||
const sl = [...sr().querySelectorAll('hp-dialog ha-slider')].find((el) => Number(el.value) === Number(sizeBefore));
|
||||
out.downstreamValue = !!sl;
|
||||
sl.value = 2;
|
||||
sl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
@@ -73,8 +73,8 @@ const res = await page.evaluate(async () => {
|
||||
|
||||
// --- 6) general settings: opacity sliders take the ha branch too -------
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
out.gsHaSliders = sr().querySelectorAll('.dialog ha-slider').length >= 9;
|
||||
const gsl = sr().querySelector('.dialog ha-slider');
|
||||
out.gsHaSliders = sr().querySelectorAll('hp-dialog ha-slider').length >= 9;
|
||||
const gsl = sr().querySelector('hp-dialog ha-slider');
|
||||
gsl.value = 55;
|
||||
gsl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await c.updateComplete;
|
||||
|
||||
@@ -53,7 +53,7 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
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('.dialog .btn.danger');
|
||||
o.noDeleteForAuto = !sr().querySelector('hp-dialog .btn.danger');
|
||||
// снимаем галку и сохраняем — маркер остаётся с hidden:false (анти-повтор)
|
||||
c._markerDialog = { ...c._markerDialog, hideFromPlan: false };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
@@ -68,7 +68,7 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const virt = c._devices.find((x) => x.virtual);
|
||||
c._openMarkerDialog(virt); await c.updateComplete;
|
||||
o.deleteForVirtual = !!sr().querySelector('.dialog .btn.danger');
|
||||
o.deleteForVirtual = !!sr().querySelector('hp-dialog .btn.danger');
|
||||
c._markerDialog = null; c._setMode('view');
|
||||
return o;
|
||||
}));
|
||||
@@ -100,7 +100,7 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- HP-1511-02: ripple-призрак с базовой иконкой, без пульса -------------
|
||||
// --- Legacy ripple → icon+activity; ghost keeps icon and has no effect -----
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
@@ -114,11 +114,10 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('devices'); c._showHidden = true;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const g = [...sr().querySelectorAll('.dev.ghost')].find((x) => x.querySelector('.ripple') || !x.classList.contains('noicon'));
|
||||
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
|
||||
o.rippleGhostHasIcon = ghosts.length > 0 && ghosts.every((x) => !!x.querySelector('ha-icon'));
|
||||
o.rippleGhostNoNoicon = ghosts.every((x) => !x.classList.contains('noicon'));
|
||||
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.ripple'));
|
||||
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.activity-ring'));
|
||||
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c._setMode('view'); c._showHidden = false;
|
||||
@@ -175,8 +174,14 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
await card.updateComplete;
|
||||
const st = [...card.renderRoot.querySelectorAll('[style*="left"]')]
|
||||
.find((el) => /%$/.test(el.style.left || '') && el.style.top);
|
||||
// статичная карточка рендерит от полного квадрата 0..1000
|
||||
const stVb = st && [parseFloat(st.style.left) * 10, parseFloat(st.style.top) * 10];
|
||||
// docs/CANVAS.md §4: статичная карточка теперь кадрирует СОДЕРЖИМОЕ, как и
|
||||
// полная, а не квадрат 0..1000 — координаты берём из её же viewBox
|
||||
const svb = (card.renderRoot.querySelector('svg')?.getAttribute('viewBox') || '0 0 1000 1000')
|
||||
.trim().split(/\s+/).map(Number);
|
||||
const stVb = st && [
|
||||
svb[0] + (parseFloat(st.style.left) / 100) * svb[2],
|
||||
svb[1] + (parseFloat(st.style.top) / 100) * svb[3],
|
||||
];
|
||||
o.autoGridParity = !!fullVb && !!stVb
|
||||
&& Math.abs(fullVb[0] - stVb[0]) < 6 && Math.abs(fullVb[1] - stVb[1]) < 6;
|
||||
if (!o.autoGridParity) console.log('full', fullVb, 'static', stVb);
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* The two "draw less" space options and the virtual-wall rule (owner
|
||||
* 2026-08-05), plus the handle beads that came with them.
|
||||
*
|
||||
* • «Скрыть декоративный слой» hides decor everywhere EXCEPT its own editor;
|
||||
* • «Скрыть проёмы» hides doors/windows everywhere EXCEPT the plan editor,
|
||||
* and does not touch what an opening MEANS (the light still gets through);
|
||||
* • a space that does not draw room borders draws no dashed virtual walls
|
||||
* in View; every editor still does, because the complete centreline span
|
||||
* must remain visible while editing;
|
||||
* • both switches survive a round trip through the dialog;
|
||||
* • every corner handle paints a bead a quarter of its hit radius, and the
|
||||
* hit radius itself is unchanged.
|
||||
*/
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
|
||||
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
const set = async (patch) => {
|
||||
const s = sp();
|
||||
s.settings = { ...(s.settings || {}), ...patch };
|
||||
await upd();
|
||||
};
|
||||
const mode = async (m) => { c._setMode(m); await upd(); };
|
||||
const n = (sel) => sr().querySelectorAll(sel).length;
|
||||
|
||||
// a shape and an opening to look for, on the space we are on
|
||||
const s0 = sp();
|
||||
s0.decor = [{ id: 'hl1', kind: 'rect', x: 0.3, y: 0.3, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
|
||||
s0.openings = [{ id: 'hl2', type: 'door', x: 0.3, y: 0.2, angle: 0, length: 0.09 }];
|
||||
await mode('view');
|
||||
await set({ hide_decor: undefined, hide_openings: undefined, show_borders: true });
|
||||
|
||||
// ---- 1) the baseline: both layers are drawn ---------------------------
|
||||
out.decorDrawnByDefault = n('.decorlayer .dshape') > 0;
|
||||
out.openingDrawnByDefault = n('[data-hp="opening"]') > 0;
|
||||
|
||||
// ---- 2) hide the decor layer ------------------------------------------
|
||||
await set({ hide_decor: true });
|
||||
out.decorHiddenInView = n('.decorlayer .dshape') === 0;
|
||||
await mode('devices');
|
||||
out.decorHiddenInDevices = n('.decorlayer .dshape') === 0;
|
||||
// …but its own editor still shows it, or it could not be edited
|
||||
await mode('decor');
|
||||
out.decorVisibleInItsEditor = n('.decorlayer .dshape') > 0;
|
||||
await mode('view');
|
||||
// the shape itself was never touched
|
||||
out.decorShapeKept = (sp().decor || []).length === 1;
|
||||
await set({ hide_decor: undefined });
|
||||
out.decorBackAfterOff = n('.decorlayer .dshape') > 0;
|
||||
|
||||
// ---- 3) hide the openings ---------------------------------------------
|
||||
const opCount = () => n('[data-hp="opening"]');
|
||||
const before = opCount();
|
||||
await set({ hide_openings: true });
|
||||
out.openingsHiddenInView = opCount() === 0 && before > 0;
|
||||
await mode('plan');
|
||||
c._tool = 'opening';
|
||||
await upd();
|
||||
out.openingsVisibleInPlanEditor = opCount() > 0;
|
||||
await mode('view');
|
||||
// the opening is still an opening: it stayed in the config untouched
|
||||
out.openingKept = (sp().openings || []).length === 1;
|
||||
await set({ hide_openings: undefined });
|
||||
out.openingsBackAfterOff = opCount() === before;
|
||||
|
||||
// ---- 4) virtual walls follow the borders switch ------------------------
|
||||
// an open boundary is a link between two rooms that share a wall; the demo's
|
||||
// first two rooms do (smoke_render_parity relies on the same fact)
|
||||
const rooms = sp().rooms || [];
|
||||
rooms[0].open_to = [rooms[1].id];
|
||||
await upd();
|
||||
out.openWallsFound = c._openPairs().length > 0;
|
||||
await set({ show_borders: true });
|
||||
out.openWallsDrawnWithBorders = n('.openwalls .openwall') > 0;
|
||||
await set({ show_borders: false });
|
||||
out.openWallsGoneWithoutBorders = n('.openwalls .openwall') === 0;
|
||||
// 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';
|
||||
await upd();
|
||||
out.openWallsVisibleInPlanEditor = n('.openwalls .openwall') > 0;
|
||||
await mode('devices');
|
||||
out.openWallsVisibleInDevicesEditor = n('.openwalls .openwall') > 0;
|
||||
await mode('decor');
|
||||
out.openWallsVisibleInDecorEditor = n('.openwalls .openwall') > 0;
|
||||
await mode('view');
|
||||
await set({ show_borders: true });
|
||||
|
||||
// ---- 5) the dialog carries both switches round trip --------------------
|
||||
c._openSpaceDialog('edit', c._space);
|
||||
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.dialogReadsOff = c._spaceDialog.hideDecor === false && c._spaceDialog.hideOpenings === false;
|
||||
c._spaceDialog = { ...c._spaceDialog, hideDecor: true, hideOpenings: true };
|
||||
await c._saveSpaceDialog();
|
||||
await upd();
|
||||
out.savedBoth = sp().settings.hide_decor === true && sp().settings.hide_openings === true;
|
||||
c._openSpaceDialog('edit', c._space);
|
||||
await upd();
|
||||
out.dialogReadsOn = c._spaceDialog.hideDecor === true && c._spaceDialog.hideOpenings === true;
|
||||
c._spaceDialog = { ...c._spaceDialog, hideDecor: false, hideOpenings: false };
|
||||
await c._saveSpaceDialog();
|
||||
await upd();
|
||||
// switched off = nothing is stored (undefined, which JSON drops on the way
|
||||
// to the server), not a stored `false` — the same rule bg_color follows
|
||||
out.clearedNotStored = sp().settings.hide_decor === undefined
|
||||
&& sp().settings.hide_openings === undefined
|
||||
&& !('hide_decor' in JSON.parse(JSON.stringify(sp().settings)));
|
||||
c._spaceDialog = null;
|
||||
await upd();
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(res);
|
||||
|
||||
// ---- 6) the beads: a quarter of the hit radius, and the hit is unchanged --
|
||||
const rad = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('decor'); c._decorTool = 'backdrop';
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
const hit = sr().querySelector('.bdframe .bdhandle');
|
||||
const knob = sr().querySelector('.bdframe .bdknob');
|
||||
const view = c._viewOr(c._baseVb());
|
||||
const o = {
|
||||
hasBoth: !!hit && !!knob,
|
||||
hitR: hit ? Number(hit.getAttribute('r')) : 0,
|
||||
knobR: knob ? Number(knob.getAttribute('r')) : 0,
|
||||
expectHit: Math.max(view.w, view.h) * 0.02,
|
||||
knobInert: knob ? getComputedStyle(knob).pointerEvents === 'none' : false,
|
||||
// the bead sits exactly on the handle
|
||||
sameCentre: hit && knob
|
||||
&& hit.getAttribute('cx') === knob.getAttribute('cx')
|
||||
&& hit.getAttribute('cy') === knob.getAttribute('cy'),
|
||||
};
|
||||
// …and the resize tool's corner beads follow the same rule
|
||||
c._setMode('plan'); c._tool = 'resize';
|
||||
c._rszSel = (c._curSpaceCfg.rooms || [])[0]?.id || null;
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
const rh = sr().querySelector('.rszcorner');
|
||||
const rk = sr().querySelector('.rszknob');
|
||||
o.rszBoth = !!rh && !!rk;
|
||||
o.rszQuarter = rh && rk
|
||||
? Math.abs(Number(rk.getAttribute('r')) * 4 - Number(rh.getAttribute('r'))) < 0.05
|
||||
: false;
|
||||
c._setMode('view');
|
||||
return o;
|
||||
});
|
||||
checkAll({
|
||||
handleAndBead: rad.hasBoth,
|
||||
hitRadiusUnchanged: Math.abs(rad.hitR - rad.expectHit) < 0.15,
|
||||
beadIsAQuarter: Math.abs(rad.knobR * 4 - rad.hitR) < 0.05,
|
||||
beadTakesNoPointer: rad.knobInert,
|
||||
beadOnTheHandle: rad.sameCentre,
|
||||
resizeCornerHasBead: rad.rszBoth,
|
||||
resizeBeadIsAQuarter: rad.rszQuarter,
|
||||
});
|
||||
|
||||
// ---- 7) the backdrop editor opens ON the picture 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 };
|
||||
// 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 };
|
||||
const pt = [r.x + r.w / 2, r.y + r.h / 2];
|
||||
const ev = (x, y) => ({ pointerId: 42, clientX: x, clientY: y, target: c._stageEl,
|
||||
preventDefault() {}, stopPropagation() {}, shiftKey: false });
|
||||
// _svgPoint reads clientX/Y — go through the stage geometry
|
||||
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
|
||||
const box = stage.getBoundingClientRect();
|
||||
const vb = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
const toScreen = (x, y) => [box.left + ((x - vb[0]) / vb[2]) * box.width,
|
||||
box.top + ((y - vb[1]) / vb[3]) * box.height];
|
||||
const [sx, sy] = toScreen(pt[0], pt[1]);
|
||||
const [ex, ey] = toScreen(pt[0] + vb[2] * 0.1, pt[1]);
|
||||
c._stagePointerDown(ev(sx, sy));
|
||||
c._stagePointerMove(ev(ex, ey));
|
||||
c._stagePointerUp(ev(ex, ey));
|
||||
await c.updateComplete;
|
||||
o.moved = (c._curSpaceCfg.plan_x ?? 0) !== before.x;
|
||||
c._setMode('view');
|
||||
return o;
|
||||
});
|
||||
checkAll({
|
||||
backdropToolArmed: armed.tool === 'backdrop',
|
||||
pictureMovableAtOnce: armed.movable,
|
||||
dragMovedThePicture: armed.moved,
|
||||
});
|
||||
|
||||
await finish(browser);
|
||||
@@ -17,7 +17,7 @@ const res = await page.evaluate(async () => {
|
||||
out.hintIcon = hint?.querySelector('ha-icon')?.getAttribute('icon') === dev.icon;
|
||||
// пикер (или фолбэк-инпут) получил placeholder
|
||||
const picker = sr().querySelector('ha-icon-picker');
|
||||
const inputs = [...sr().querySelectorAll('.dialog input')];
|
||||
const inputs = [...sr().querySelectorAll('hp-dialog input')];
|
||||
out.placeholderSet = picker
|
||||
? picker.placeholder === dev.icon
|
||||
: inputs.some((i) => i.placeholder === dev.icon);
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
// Infinite canvas (docs/CANVAS.md): a plan drawn far past the old unit square
|
||||
// renders whole, devices can be placed and saved out there, the opening view
|
||||
// follows the content, one far stray neither breaks the view nor hides itself,
|
||||
// zoom-out stops at 3x the content and the icons keep their proportion to it.
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const SHOTS = process.env.HP_SHOTS || '';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
const out = {};
|
||||
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
|
||||
// A hand-drawn plan whose rooms live at 1.5..3.0 — entirely outside the old
|
||||
// canvas. Before the infinite canvas the -25%..125% envelope threw every one
|
||||
// of these vertices away and the card framed empty space.
|
||||
const FAR_CFG = {
|
||||
spaces: [{
|
||||
id: 'f1', title: 'Ground floor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [
|
||||
{ id: 'r1', name: 'Living room', area: 'living_room',
|
||||
poly: [[1.5, 1.5], [2.2, 1.5], [2.2, 2.2], [1.5, 2.2]] },
|
||||
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
|
||||
poly: [[2.2, 1.5], [3.0, 1.5], [3.0, 2.0], [2.2, 2.0]] },
|
||||
{ id: 'r3', name: 'Bedroom', area: 'bedroom',
|
||||
poly: [[2.2, 2.0], [3.0, 2.0], [3.0, 2.6], [2.2, 2.6]] },
|
||||
{ id: 'r4', name: 'Hallway', area: 'hallway',
|
||||
poly: [[1.5, 2.2], [2.2, 2.2], [2.2, 2.6], [1.5, 2.6]] },
|
||||
],
|
||||
openings: [{ id: 'o1', type: 'window', x: 1.8, y: 1.5, angle: 0, length: 0.15 }],
|
||||
decor: [{ id: 'd1', kind: 'line', x1: 1.4, y1: 2.7, x2: 3.1, y2: 2.7, color: '#8899aa' }],
|
||||
}, {
|
||||
id: 'garden', title: 'Garden', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'g1', name: 'Garden', area: 'garden',
|
||||
poly: [[0.03, 0.178], [0.97, 0.178], [0.97, 0.822], [0.03, 0.822]] }],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
const FAR_LAYOUT = {
|
||||
d_light1: { s: 'f1', x: 1.7, y: 1.7 }, d_lamp: { s: 'f1', x: 2.0, y: 1.9 },
|
||||
d_tv: { s: 'f1', x: 1.6, y: 2.1 }, d_temp: { s: 'f1', x: 1.9, y: 1.6 },
|
||||
d_kettle: { s: 'f1', x: 2.5, y: 1.7 }, d_leak: { s: 'f1', x: 2.9, y: 1.8 },
|
||||
d_bedlight: { s: 'f1', x: 2.6, y: 2.3 }, d_window: { s: 'f1', x: 2.9, y: 2.5 },
|
||||
d_lock: { s: 'f1', x: 1.6, y: 2.5 }, d_motion: { s: 'f1', x: 2.0, y: 2.4 },
|
||||
};
|
||||
|
||||
const loadFar = (cfg, layout) => page.evaluate(async ([c2, l2]) => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = JSON.parse(JSON.stringify(c2));
|
||||
c._layout = JSON.parse(JSON.stringify(l2));
|
||||
c._modelCache = null;
|
||||
c._frame = null;
|
||||
c._showFar = false;
|
||||
c._space = 'f1';
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions();
|
||||
c._view = null;
|
||||
c._zoom = 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._resetZoom();
|
||||
await c.updateComplete;
|
||||
}, [cfg, layout]);
|
||||
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await raf();
|
||||
|
||||
// ---- (b) the plan past the old square renders WHOLE ----------------------
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const b = c._baseVb();
|
||||
// the content is 1500..3000 x 1500..2700 render units, plus decor/openings
|
||||
o.frameHoldsFarPlan = b[0] < 1450 && b[0] + b[2] > 3050 && b[1] < 1450 && b[1] + b[3] > 2750;
|
||||
o.frameIsNotTheOldSquare = b[0] > 1000; // nothing is framed at the origin any more
|
||||
const v = c._viewOr(b);
|
||||
o.viewShowsFarPlan = v.x <= 1500 && v.x + v.w >= 3000;
|
||||
// every room polygon is inside the painted viewBox
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const polys = [...sr.querySelectorAll('polygon.room')];
|
||||
o.allRoomsDrawn = polys.length === 4;
|
||||
const st = sr.querySelector('.stage').getBoundingClientRect();
|
||||
o.roomsOnScreen = polys.every((p) => {
|
||||
const r = p.getBoundingClientRect();
|
||||
return r.width > 4 && r.height > 4 && r.left >= st.left - 2 && r.right <= st.right + 2
|
||||
&& r.top >= st.top - 2 && r.bottom <= st.bottom + 2;
|
||||
});
|
||||
// and the markers landed on the plan, not in a corner of a phantom canvas
|
||||
const devs = [...sr.querySelectorAll('.devlayer .dev')];
|
||||
o.markersOnPlan = devs.length >= 10 && devs.every((d) => {
|
||||
const r = d.getBoundingClientRect();
|
||||
return r.left >= st.left - 2 && r.right <= st.right + 2 && r.top >= st.top - 2 && r.bottom <= st.bottom + 2;
|
||||
});
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- a device can be PLACED past the old square, and it saves -------------
|
||||
const ws = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sent = [];
|
||||
const base = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return base(m); } };
|
||||
// drop a virtual marker at 3.4 / 2.9 — a long way outside the old canvas
|
||||
const far = { s: 'f1', x: 3.4, y: 2.9 };
|
||||
c._layout = { ...c._layout, d_gate: far };
|
||||
c._dirtyPos.add('d_gate');
|
||||
await c._persistLayout?.();
|
||||
// and a room extended even further out, saved through the config writer
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].rooms.push({ id: 'r5', name: 'Workshop',
|
||||
poly: [[3.1, 2.0], [3.8, 2.0], [3.8, 2.6], [3.1, 2.6]] });
|
||||
c._serverCfg = cfg; c._modelCache = null; c._frame = null;
|
||||
await c._saveConfig?.();
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
return { sent: JSON.parse(JSON.stringify(sent)), layout: JSON.parse(JSON.stringify(c._layout)) };
|
||||
});
|
||||
out.farDeviceSaved = ws.layout.d_gate.x === 3.4 && ws.layout.d_gate.y === 2.9;
|
||||
const cfgMsg = ws.sent.filter((m) => m.type === 'houseplan/config/set').pop();
|
||||
const layMsg = ws.sent.filter((m) => m.type === 'houseplan/layout/update').pop();
|
||||
out.wsCarriedFarGeometry = !!cfgMsg
|
||||
&& JSON.stringify(cfgMsg.config).includes('3.8');
|
||||
out.wsCarriedFarPosition = !!layMsg && JSON.stringify(layMsg).includes('3.4');
|
||||
|
||||
// the payload the card sends must pass the REAL backend schema (docs/CANVAS.md
|
||||
// §3: ±5000). Skipped, not failed, where voluptuous is not installed.
|
||||
out.backendAcceptsFarPayload = (() => {
|
||||
if (!cfgMsg) return false;
|
||||
const f = '/tmp/hp_infinite_payload.json';
|
||||
writeFileSync(f, JSON.stringify({ config: cfgMsg.config, layout: (layMsg || {}).layout || ws.layout }));
|
||||
try {
|
||||
execFileSync('python3', ['-c', 'import voluptuous'], { stdio: 'ignore' });
|
||||
} catch { console.log('(voluptuous absent — backend schema check skipped)'); return true; }
|
||||
const code = [
|
||||
'import json,sys',
|
||||
'sys.path.insert(0, "custom_components/houseplan")',
|
||||
'import validation as v',
|
||||
`d=json.load(open(${JSON.stringify(f)}))`,
|
||||
'v.CONFIG_SCHEMA(d["config"]); v.LAYOUT_SCHEMA(d["layout"])',
|
||||
'print("VALID")',
|
||||
].join('\n');
|
||||
try {
|
||||
return execFileSync('python3', ['-c', code], { encoding: 'utf8' }).includes('VALID');
|
||||
} catch (e) { console.log('backend refused the payload:', String(e.stdout || e.message).slice(-400)); return false; }
|
||||
})();
|
||||
|
||||
// ---- an OUTLIER: the view ignores it, the hint offers it ------------------
|
||||
await loadFar({
|
||||
...FAR_CFG,
|
||||
spaces: FAR_CFG.spaces.map((s) => (s.id === 'f1' ? { ...s } : s)),
|
||||
}, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } });
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
o.outlierReported = c._outliers === 1;
|
||||
const b = c._baseVb();
|
||||
o.outlierDoesNotCommandView = b[2] < 4000 && b[0] + b[2] < 5000; // still the house, not 90 canvases
|
||||
o.outlierViewStillHoldsPlan = b[0] < 1500 && b[0] + b[2] > 3000;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const hint = sr.querySelector('.farhint');
|
||||
o.hintShown = !!hint && !!hint.querySelector('button');
|
||||
o.hintIsNotAModal = !sr.querySelector('hp-dialog');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// «Показать» / «Вписать всё» takes the stray in
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
sr.querySelector('.farhint button').click();
|
||||
await c.updateComplete;
|
||||
const o = {};
|
||||
const v = c._viewOr(c._baseVb());
|
||||
o.fitAllReachesTheStray = v.x + v.w > 90000 && v.y + v.h > 90000;
|
||||
o.fitAllStillHoldsPlan = v.x <= 1500 && v.y <= 1500;
|
||||
await c.updateComplete;
|
||||
o.hintGoneAfterShow = !sr.querySelector('.farhint');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- zoom-out stops at 3x, zoom-in unchanged -----------------------------
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
c._resetZoom();
|
||||
const fit = { ...c._viewOr(c._baseVb()) };
|
||||
c._applyView(0.001);
|
||||
o.zoomOutStopsAtThree = Math.abs(c._zoom - 1 / 3) < 1e-9
|
||||
&& Math.abs(c._view.w - fit.w * 3) < fit.w * 0.02;
|
||||
c._applyView(50);
|
||||
o.zoomInUnchanged = c._zoom === 8;
|
||||
c._resetZoom();
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- pan has slack, and "home is that way" brings you back ---------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const fit = { ...c._view };
|
||||
// pan most of a screen to the right: nothing pins the content over the scene
|
||||
c._view = c._clampView({ ...fit, x: fit.x + fit.w * 0.9 }, fit);
|
||||
o.panPastContentAllowed = c._view.x > fit.x + fit.w * 0.5;
|
||||
// walk right off the plan: the frame is entirely outside the view now
|
||||
c._view = { ...fit, x: fit.x + fit.w * 1.6 };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const arrow = sr.querySelector('.homearrow');
|
||||
o.homeArrowAppears = !!arrow;
|
||||
if (arrow) {
|
||||
arrow.click();
|
||||
await c.updateComplete;
|
||||
o.homeArrowFitsBack = Math.abs(c._view.x - fit.x) < 2 && Math.abs(c._view.w - fit.w) < 2;
|
||||
await c.updateComplete;
|
||||
o.homeArrowGone = !sr.querySelector('.homearrow');
|
||||
}
|
||||
// and it is NOT there while the plan is on screen
|
||||
c._resetZoom(); c.requestUpdate(); await c.updateComplete;
|
||||
o.noHomeArrowWhenVisible = !sr.querySelector('.homearrow');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- icons scale WITH THE PLAN across zooms (docs/CANVAS.md §6) ----------
|
||||
// The infinite canvas briefly made --icon-size a percentage of the viewport,
|
||||
// so a marker kept its pixel size at every zoom. The owner looked at it and
|
||||
// asked for the original contract back (2026-08-03): an icon is a percentage
|
||||
// of the PLAN, it grows when you zoom in and shrinks when you zoom out, like
|
||||
// everything else drawn on the plan. What the infinite canvas contributes is
|
||||
// only the base unit the percentage is taken OF: `iconUnit` instead of the
|
||||
// stored `vb.w`, checked right below on this very plan — which is drawn
|
||||
// entirely outside the old square.
|
||||
const farIcon = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
// the badge is --dev-size PLUS a 1px border on each side, and the border
|
||||
// is not part of the scaled geometry (same correction as smoke_icon_scale)
|
||||
const size = () => {
|
||||
const el = sr.querySelector('.devlayer .dev');
|
||||
return Math.round((el.getBoundingClientRect().width - 2) * 100) / 100;
|
||||
};
|
||||
const o = {};
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const at1 = size();
|
||||
c._applyView(4); await c.updateComplete;
|
||||
const at4 = size();
|
||||
c._applyView(1 / 3); await c.updateComplete;
|
||||
const atOut = size();
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
o.iconGrowsWithZoomIn = at1 > 4 && Math.abs(at4 / at1 - 4) < 0.05;
|
||||
o.iconShrinksWithZoomOut = Math.abs(atOut / at1 - 1 / 3) < 0.02;
|
||||
if (!o.iconGrowsWithZoomIn || !o.iconShrinksWithZoomOut) console.log('icon px', at1, at4, atOut);
|
||||
return { o, at1 };
|
||||
});
|
||||
Object.assign(out, farIcon.o);
|
||||
|
||||
// ---- an OLD plan (small, with a view_box) opens exactly as before ---------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const cfg = {
|
||||
spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Living', area: 'living_room',
|
||||
poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
|
||||
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
|
||||
poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
c._serverCfg = cfg; c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } };
|
||||
c._modelCache = null; c._frame = null; c._showFar = false; c._view = null;
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions(); // the auto grid follows the new rooms
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const b = c._baseVb();
|
||||
// identical to what contentBounds produced before: the content bbox
|
||||
// (40..960 x 140..580) plus 5% of the longer side (920 -> 46)
|
||||
o.legacyFrameUnchanged = Math.abs(b[0] - -6) < 1 && Math.abs(b[1] - 94) < 1
|
||||
&& Math.abs(b[2] - 1012) < 1 && Math.abs(b[3] - 532) < 1;
|
||||
o.legacyNoHint = c._outliers === 0;
|
||||
// an EMPTY space falls back to the stored view_box hint (§4)
|
||||
c._serverCfg = { spaces: [{ id: 'f1', title: 'E', view_box: [0.1, 0.2, 0.5, 0.4], rooms: [] }],
|
||||
markers: [], settings: {} };
|
||||
// a space with NOTHING in it at all — not even a marker parked in it
|
||||
c._layout = {}; c._modelCache = null; c._frame = null;
|
||||
c._devices = [];
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const e = c._baseVb();
|
||||
o.emptySpaceUsesViewBoxHint = JSON.stringify(e.map(Math.round)) === '[100,200,500,400]';
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- and the icon does not DEGENERATE on the runaway plan ----------------
|
||||
// The catch of "a percentage of the plan" on an unbounded canvas: keep the
|
||||
// old fixed numerator (vb.w = NORM_W) and a plan drawn 2.3 canvases wide
|
||||
// divides it by a 2.5-canvas-wide frame — the marker shrinks by that factor
|
||||
// and keeps shrinking the further out the plan is drawn. `iconUnit` grows
|
||||
// with the plan, so the marker on the far plan measured above is the same
|
||||
// number of pixels as on this ordinary 0..1 one.
|
||||
out.farIconNotDegenerate = await page.evaluate(async (farPx) => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
// the small plan from the block above, with its devices back
|
||||
c._serverCfg = { spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Living', area: 'living_room',
|
||||
poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
|
||||
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
|
||||
poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }],
|
||||
markers: [], settings: {} };
|
||||
c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } };
|
||||
c._modelCache = null; c._frame = null; c._showFar = false; c._view = null;
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const el = sr.querySelector('.devlayer .dev');
|
||||
const px = el.getBoundingClientRect().width - 2;
|
||||
if (!(px > 4 && Math.abs(px / farPx - 1) < 0.3)) console.log('icon px near/far', px, farPx);
|
||||
return px > 4 && Math.abs(px / farPx - 1) < 0.3;
|
||||
}, farIcon.at1);
|
||||
|
||||
// ---- screenshots ---------------------------------------------------------
|
||||
if (SHOTS) {
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_far_plan.png` });
|
||||
// fit-all with a stray far away
|
||||
await loadFar(FAR_CFG, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } });
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_far_hint.png` });
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
(c.shadowRoot || c.renderRoot).querySelector('.farhint button').click();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_fit_all.png` });
|
||||
// adaptive grid, zoomed far out in the plan editor
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
c._resetZoom(); c._applyView(1 / 3); await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_grid_far.png` });
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._resetZoom(); c._applyView(4); await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_grid_near.png` });
|
||||
await page.evaluate(async () => { window.__card._setMode('view'); });
|
||||
}
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -47,12 +47,13 @@ const res = await page.evaluate(async () => {
|
||||
const evenodd = [...sr().querySelectorAll('path.room')].find((p) => p.getAttribute('fill-rule') === 'evenodd');
|
||||
out.evenoddPath = !!evenodd;
|
||||
out.holeInPath = evenodd ? (evenodd.getAttribute('d').match(/M /g) || []).length === 2 : null;
|
||||
// 4) остров кликабелен (элемент существует и он поверх дырки)
|
||||
const islandEl = [...sr().querySelectorAll('.room')].find((el) => {
|
||||
const d = el.getAttribute('points') || el.getAttribute('d') || '';
|
||||
return d.includes(String(cx)) && el !== evenodd;
|
||||
});
|
||||
out.islandRendered = !!islandEl;
|
||||
// 4) остров кликабелен (элемент существует и он поверх дырки).
|
||||
// Beta.5+ applies Draw thickness, so the painted contour is inset and no
|
||||
// longer contains the exact centreline coordinate — look up by room id.
|
||||
const islandEl = island
|
||||
? sr().querySelector(`[data-hp="room"][data-id="${island.id}"]`)
|
||||
: null;
|
||||
out.islandRendered = !!islandEl && islandEl !== evenodd;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
@@ -51,7 +51,7 @@ const res = await page.evaluate(async () => {
|
||||
out.persisted = JSON.parse(localStorage.getItem('houseplan_card_kiosk_v1')).icon === 1;
|
||||
// 7) попап настроек экрана открывается (прямой вызов; долгое нажатие проверено таймером)
|
||||
c._kioskDialog = true; await c.updateComplete;
|
||||
out.dialogRenders = !!sr().querySelector('.dialog input[type="range"]');
|
||||
out.dialogRenders = !!sr().querySelector('hp-dialog input[type="range"]');
|
||||
c._kioskDialog = false;
|
||||
// 8) карусель: тик двигает пространство, пауза после касания работает
|
||||
const cs = c._space;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// A kiosk gesture is classified ONCE, and the release obeys that decision —
|
||||
// audit DEV-1DA1-02 (P2).
|
||||
//
|
||||
// `_stagePointerMove` locks the gesture on the first movement past 8 px
|
||||
// (`_panLock`: 'swipe' if it is horizontal enough inside the swipe zone, 'pan'
|
||||
// otherwise) and the plan then follows the finger. But `_stagePointerUp` used
|
||||
// to ignore that lock and ask `swipeTarget()` again, from the raw start→end
|
||||
// vector alone. A CURVED gesture — a small vertical lead-in that locks 'pan',
|
||||
// then a long horizontal sweep — therefore panned under the finger and still
|
||||
// switched the floor on release. On a wall tablet that is the worst kind of
|
||||
// surprise: you watch the plan drag along and land on another storey.
|
||||
//
|
||||
// The lock is now final: with `_panLock === 'pan'` the floor never changes, no
|
||||
// matter what the overall vector looks like. The two straight gestures keep
|
||||
// their old behaviour, and so does the motionless double tap (no movement,
|
||||
// no lock — the swipe path is never even reached by it).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c0 = window.__card;
|
||||
const k = document.createElement('houseplan-card');
|
||||
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
k.hass = c0.hass;
|
||||
document.body.appendChild(k);
|
||||
k.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
k.hass = { ...c0.hass };
|
||||
await k.updateComplete;
|
||||
const sr = k.shadowRoot || k.renderRoot;
|
||||
const stage = sr.querySelector('.stage');
|
||||
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
|
||||
}));
|
||||
o.kioskHasSeveralSpaces = k._model.length > 1;
|
||||
|
||||
const home = async () => {
|
||||
const vb = k._baseVb();
|
||||
k._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
||||
await k.updateComplete;
|
||||
};
|
||||
/**
|
||||
* Play one trajectory and report what the gesture decided and what it did.
|
||||
* `pts` are the moves; the finger lifts at the last one.
|
||||
*/
|
||||
const play = async (id, from, pts) => {
|
||||
await home();
|
||||
const s0 = k._space;
|
||||
const v0 = { ...k._viewOr(k._baseVb()) };
|
||||
fire('pointerdown', id, from[0], from[1]);
|
||||
let lockAfterLeadIn = null;
|
||||
let moved = false;
|
||||
pts.forEach((p, i) => {
|
||||
fire('pointermove', id, p[0], p[1]);
|
||||
if (i === 0) lockAfterLeadIn = k._panLock;
|
||||
if (Math.abs(k._viewOr(k._baseVb()).x - v0.x) > 1
|
||||
|| Math.abs(k._viewOr(k._baseVb()).y - v0.y) > 1) moved = true;
|
||||
});
|
||||
const lockBeforeRelease = k._panLock;
|
||||
const last = pts[pts.length - 1];
|
||||
fire('pointerup', id, last[0], last[1]);
|
||||
await k.updateComplete;
|
||||
return { s0, s1: k._space, lockAfterLeadIn, lockBeforeRelease, moved };
|
||||
};
|
||||
|
||||
// ---- 1. the auditor's curved pan: vertical lead-in, horizontal ending --
|
||||
// (500,300) → (502,312) locks 'pan' → curve left to (350,304) → release.
|
||||
// dx = -150, dy = +4: swipeTarget() would happily call that a swipe.
|
||||
const curvedPan = await play(51, [500, 300], [[502, 312], [460, 310], [400, 306], [350, 304]]);
|
||||
o.curvedPanLocksPan = curvedPan.lockAfterLeadIn === 'pan';
|
||||
o.curvedPanKeepsTheLock = curvedPan.lockBeforeRelease === 'pan';
|
||||
o.curvedPanActuallyPans = curvedPan.moved === true;
|
||||
o.curvedPanKeepsTheFloor = curvedPan.s1 === curvedPan.s0;
|
||||
// the same trajectory the other way round — a swipe to the right would have
|
||||
// been the previous floor, so the bug is symmetric and so is the fix
|
||||
const curvedPanRight = await play(52, [400, 300], [[402, 312], [460, 308], [520, 305], [560, 304]]);
|
||||
o.curvedPanRightLocksPan = curvedPanRight.lockAfterLeadIn === 'pan';
|
||||
o.curvedPanRightKeepsTheFloor = curvedPanRight.s1 === curvedPanRight.s0;
|
||||
// a long diagonal that ends up dominated by x, but started as a pan
|
||||
const diagonalPan = await play(53, [500, 300], [[496, 316], [420, 340], [330, 350], [260, 352]]);
|
||||
o.diagonalPanLocksPan = diagonalPan.lockAfterLeadIn === 'pan';
|
||||
o.diagonalPanKeepsTheFloor = diagonalPan.s1 === diagonalPan.s0;
|
||||
|
||||
// ---- 2. a gesture locked as a SWIPE keeps its own semantics ------------
|
||||
// horizontal lead-in locks 'swipe'; the plan must not slide under it, even
|
||||
// when the trajectory then bends vertically and the final vector no longer
|
||||
// qualifies — the floor simply stays, and nothing pans
|
||||
const curvedSwipe = await play(54, [600, 300], [[540, 302], [536, 380], [545, 500]]);
|
||||
o.curvedSwipeLocksSwipe = curvedSwipe.lockAfterLeadIn === 'swipe';
|
||||
o.curvedSwipeNeverPans = curvedSwipe.moved === false;
|
||||
o.curvedSwipeThatDiesChangesNothing = curvedSwipe.s1 === curvedSwipe.s0;
|
||||
// a swipe that bends but still ends as a swipe does switch the floor
|
||||
const bentSwipe = await play(55, [600, 300], [[540, 302], [470, 330], [420, 340]]);
|
||||
o.bentSwipeLocksSwipe = bentSwipe.lockAfterLeadIn === 'swipe';
|
||||
o.bentSwipeNeverPans = bentSwipe.moved === false;
|
||||
o.bentSwipeStillSwitches = bentSwipe.s1 !== bentSwipe.s0;
|
||||
|
||||
// ---- 3. the straight gestures are exactly as they were -----------------
|
||||
const straightSwipe = await play(56, [600, 300], [[540, 302], [480, 305], [450, 305]]);
|
||||
o.straightSwipeSwitches = straightSwipe.s1 !== straightSwipe.s0;
|
||||
o.straightSwipeDoesNotPan = straightSwipe.moved === false;
|
||||
const straightPan = await play(57, [450, 200], [[452, 260], [454, 330]]);
|
||||
o.straightPanPans = straightPan.moved === true;
|
||||
o.straightPanKeepsTheFloor = straightPan.s1 === straightPan.s0;
|
||||
|
||||
// ---- 4. a motionless double tap still resets the zoom ------------------
|
||||
// no movement means no lock at all, so nothing above can reach this path
|
||||
await home();
|
||||
k._applyView(2);
|
||||
await k.updateComplete;
|
||||
k._lastTap = Date.now() - 100;
|
||||
fire('pointerdown', 58, 500, 300);
|
||||
fire('pointerup', 58, 501, 300);
|
||||
await k.updateComplete;
|
||||
o.doubleTapStillResetsZoom = k._zoom === 1;
|
||||
o.doubleTapLeavesNoLock = k._panLock === null;
|
||||
|
||||
// ---- 5. zoomed in there is no swipe zone, and the lock says so ---------
|
||||
k._applyView(2);
|
||||
await k.updateComplete;
|
||||
const zoomed = await (async () => {
|
||||
const s0 = k._space;
|
||||
fire('pointerdown', 59, 600, 300);
|
||||
fire('pointermove', 59, 540, 302);
|
||||
const lock = k._panLock;
|
||||
fire('pointermove', 59, 480, 305);
|
||||
fire('pointerup', 59, 480, 305);
|
||||
await k.updateComplete;
|
||||
return { lock, same: k._space === s0 };
|
||||
})();
|
||||
o.zoomedHorizontalLocksPan = zoomed.lock === 'pan';
|
||||
o.zoomedHorizontalKeepsTheFloor = zoomed.same;
|
||||
|
||||
k.remove();
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -1,8 +1,6 @@
|
||||
// Правило владельца (2026-07-29): у источников света подложка в glow-режиме
|
||||
// всегда стандартная — индикатор включения это ПЯТНО; в остальных режимах
|
||||
// горящий источник жёлтый, как греющая термоголовка. RGB лампы красит только
|
||||
// пятно (и фолбэк цвета пульсации); окраска иконки/рамки убрана. Морф иконки
|
||||
// остаётся везде. Розетка в glow-режиме остаётся жёлтой — она не источник.
|
||||
// Правило владельца (2026-08-06): жёлтая подложка всегда означает «работает
|
||||
// сейчас». В glow-режиме световое пятно ДОПОЛНЯЕТ её, но не заменяет. RGB
|
||||
// лампы по-прежнему красит только пятно/эффект, не значок и не рамку.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
@@ -39,21 +37,19 @@ const out = await page.evaluate(async () => {
|
||||
o.litLampYellowInLightMode = !!le && le.classList.contains('on') && !le.classList.contains('rgb');
|
||||
o.morphInLightMode = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
|
||||
|
||||
// --- режим glow: лампа ТЁМНАЯ (индикатор — пятно), розетка жёлтая -------
|
||||
// --- режим glow: и лампа, и розетка жёлтые; пятно остаётся --------------
|
||||
await setFill('glow');
|
||||
le = devEl('d_lamp');
|
||||
const ke = devEl('d_kettle');
|
||||
o.litLampDarkInGlow = !!le && !le.classList.contains('on') && !le.classList.contains('rgb');
|
||||
o.litLampYellowInGlow = !!le && le.classList.contains('on') && !le.classList.contains('rgb');
|
||||
o.glowSpotPresent = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
|
||||
o.socketStaysYellowInGlow = !!ke && ke.classList.contains('on');
|
||||
o.morphInGlow = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
|
||||
|
||||
// --- HP-1520-01: в редакторах, где glow-слой скрыт, индикатор возвращается
|
||||
// (лампа горит; в devices-редакторе слой рисуется — там лампа тёмная,
|
||||
// в plan-редакторе слоя нет — там класс on должен вернуться)
|
||||
// --- редакторы используют ту же жёлтую подложку независимо от слоя glow --
|
||||
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
|
||||
le = devEl('d_lamp');
|
||||
o.devicesEditorLampDark = !!le && !le.classList.contains('on');
|
||||
o.devicesEditorLampYellow = !!le && le.classList.contains('on');
|
||||
o.devicesEditorHasGlow = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
|
||||
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
|
||||
// ИМЕННО лампа (mutation-проверка аудита HP-1521-01: розетка тоже on и
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Живой текст в декоре (docs/LIVE-TEXT.md): все связи с HA живут прямо в
|
||||
// тексте как {entity} / {entity:attribute}. Один блок может смешивать обычный
|
||||
// текст и несколько переменных; выбор значения вставляет токен в позицию
|
||||
// курсора. Старые entity/attr/unit читаются, но первое сохранение мигрирует их.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
/** текст фигуры в DOM, склеенный из строк (tspan) */
|
||||
const dom = (id) => {
|
||||
const el = sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
|
||||
if (!el) return null;
|
||||
return [...el.querySelectorAll('tspan')].map((t) => t.textContent).join('\n');
|
||||
};
|
||||
/** открыть форму существующей надписи (на старой сборке — двойным щелчком) */
|
||||
const open = (sh) => (c._decorOpenText ? c._decorOpenText(sh) : c._decorShapeDbl(sh));
|
||||
/** подменить одно состояние — ровно так, как HA присылает новый hass */
|
||||
const push = async (id, st) => {
|
||||
const states = { ...c.hass.states };
|
||||
if (st === null) delete states[id]; else states[id] = st;
|
||||
c.hass = { ...c.hass, states };
|
||||
await c.updateComplete;
|
||||
};
|
||||
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
out.decorMode = c._mode === 'decor';
|
||||
c._curSpaceCfg.decor = [];
|
||||
|
||||
// ---------- 1. несколько inline-переменных и обычная надпись -------------
|
||||
c._decorTextDialog = { x: 0.3, y: 0.3,
|
||||
text: 'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}',
|
||||
color: '#112233' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const live = c._decorList.find((x) => x.kind === 'text');
|
||||
out.onlyTextStored = !('entity' in live) && !('attr' in live) && !('unit' in live);
|
||||
out.templateStoredRaw = live.text ===
|
||||
'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}';
|
||||
out.multipleValuesRendered = dom(live.id) === 'Бак 22.4 °C; LQI 154';
|
||||
|
||||
c._decorTextDialog = { x: 0.6, y: 0.3, text: 'Сауна', color: '#112233' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const plain = c._decorList.find((x) => x.text === 'Сауна');
|
||||
out.plainUntouched = dom(plain.id) === 'Сауна' && !('entity' in plain);
|
||||
|
||||
// ---------- 2. значение живое: новый hass — новый текст -------------------
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.9',
|
||||
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
|
||||
out.updatesOnState = dom(live.id) === 'Бак 23.9 °C; LQI 154';
|
||||
out.plainStillUntouched = dom(plain.id) === 'Сауна';
|
||||
// как отдаёт HA: ни округления, ни переформатирования
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.94781',
|
||||
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
|
||||
out.noRounding = dom(live.id) === 'Бак 23.94781 °C; LQI 154';
|
||||
|
||||
// ---------- 3. мёртвая сущность — прочерк, шаблон остаётся ---------------
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unavailable',
|
||||
attributes: { unit_of_measurement: '°C' } });
|
||||
out.deadIsDash = dom(live.id) === 'Бак —; LQI —';
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unknown', attributes: {} });
|
||||
out.unknownIsDash = dom(live.id) === 'Бак —; LQI —';
|
||||
await push('sensor.living_temp', null);
|
||||
out.missingIsDash = dom(live.id) === 'Бак —; LQI —';
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '22.4',
|
||||
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
|
||||
out.backAlive = dom(live.id) === 'Бак 22.4 °C; LQI 154';
|
||||
|
||||
// ---------- 4. picker вставляет полный токен ровно в позицию курсора ------
|
||||
c._decorTextDialog = { x: 0.45, y: 0.45, text: 'До после', color: '#112233',
|
||||
pickerEntity: 'sensor.living_temp' };
|
||||
c._decorTextSelection = { start: 3, end: 3 };
|
||||
c._decorInsertLiveVariable('linkquality'); await c.updateComplete;
|
||||
out.insertedAtCaret = c._decorTextDialog.text ===
|
||||
'До {sensor.living_temp:linkquality}после';
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const inserted = c._decorList.find((x) => x.text?.startsWith('До '));
|
||||
out.insertedRenders = dom(inserted.id) === 'До 154после';
|
||||
|
||||
// ---------- 5. ручная dotted-форма и отсутствующий атрибут ---------------
|
||||
c._decorTextDialog = { x: 0.55, y: 0.55,
|
||||
text: '{sensor.living_temp.linkquality} / {sensor.living_temp:nope}',
|
||||
color: '#112233' };
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const manual = c._decorList.find((x) => x.text?.includes(':nope'));
|
||||
out.manualTokensWork = dom(manual.id) === '154 / —';
|
||||
|
||||
// ---------- 6. старая связка мигрирует при первом редактировании ----------
|
||||
const legacy = { id: 'legacy_live', kind: 'text', x: 0.7, y: 0.7,
|
||||
text: 'Старый {}', color: '#112233', entity: 'sensor.living_temp' };
|
||||
c._curSpaceCfg.decor = [...c._decorList, legacy];
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.legacyStillRenders = dom(legacy.id) === 'Старый 22.4 °C';
|
||||
open(legacy); await c.updateComplete;
|
||||
out.legacyOpensAsToken = c._decorTextDialog?.text ===
|
||||
'Старый {sensor.living_temp}';
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const migrated = c._decorList.find((x) => x.id === legacy.id);
|
||||
out.legacyMigrated = !('entity' in migrated)
|
||||
&& migrated.text === 'Старый {sensor.living_temp}'
|
||||
&& dom(legacy.id) === 'Старый 22.4 °C';
|
||||
|
||||
// Explicit legacy units and attribute names outside the inline grammar are
|
||||
// not silently destroyed. They stay in the compatible representation until
|
||||
// the user explicitly replaces the binding.
|
||||
const legacyUnit = { id: 'legacy_unit', kind: 'text', x: 0.75, y: 0.75,
|
||||
text: 'Бак {}', color: '#112233', entity: 'sensor.living_temp', unit: 'K' };
|
||||
c._curSpaceCfg.decor = [...c._decorList, legacyUnit]; c.requestUpdate(); await c.updateComplete;
|
||||
out.legacyUnitRenders = dom(legacyUnit.id) === 'Бак 22.4 K';
|
||||
open(legacyUnit); await c.updateComplete;
|
||||
out.legacyUnitIsNotLossyToken = c._decorTextDialog?.preserveLegacy === true
|
||||
&& c._decorTextDialog?.text === 'Бак {}';
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const keptUnit = c._decorList.find((x) => x.id === legacyUnit.id);
|
||||
out.legacyUnitSurvivesSave = keptUnit?.unit === 'K'
|
||||
&& keptUnit?.entity === 'sensor.living_temp' && dom(legacyUnit.id) === 'Бак 22.4 K';
|
||||
|
||||
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '22.4',
|
||||
attributes: { unit_of_measurement: '°C', linkquality: 154, 'bad attr': 7 } });
|
||||
const legacyAttr = { id: 'legacy_attr', kind: 'text', x: 0.8, y: 0.8,
|
||||
text: 'Атрибут {}', color: '#112233', entity: 'sensor.living_temp', attr: 'bad attr' };
|
||||
c._curSpaceCfg.decor = [...c._decorList, legacyAttr]; c.requestUpdate(); await c.updateComplete;
|
||||
out.legacyOddAttrRenders = dom(legacyAttr.id) === 'Атрибут 7';
|
||||
open(legacyAttr); await c.updateComplete;
|
||||
out.legacyOddAttrPreserved = c._decorTextDialog?.preserveLegacy === true;
|
||||
c._decorSaveText(); await c.updateComplete;
|
||||
const keptAttr = c._decorList.find((x) => x.id === legacyAttr.id);
|
||||
out.legacyOddAttrSurvivesSave = keptAttr?.attr === 'bad attr'
|
||||
&& keptAttr?.entity === 'sensor.living_temp' && dom(legacyAttr.id) === 'Атрибут 7';
|
||||
|
||||
c._decorTextDialog = { x: 0.2, y: 0.2, text: 'x', color: '#112233',
|
||||
pickerEntity: 'sensor.living_temp' };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.pickerHidesUnrepresentableAttr = ![...sr().querySelectorAll('select.namein option')]
|
||||
.some((o) => o.value === 'bad attr');
|
||||
c._decorTextDialog = null;
|
||||
|
||||
// ---------- 7. просмотр и киоск рисуют то же самое ------------------------
|
||||
const editorText = dom(live.id);
|
||||
c._setMode('view'); await c.updateComplete; await sleep(60);
|
||||
out.sameInViewMode = dom(live.id) === editorText
|
||||
&& editorText === 'Бак 22.4 °C; LQI 154';
|
||||
|
||||
const kiosk = document.createElement('houseplan-card');
|
||||
kiosk.setConfig({ type: 'custom:houseplan-card', kiosk: true });
|
||||
kiosk.hass = c.hass;
|
||||
document.body.appendChild(kiosk);
|
||||
await kiosk.updateComplete; await sleep(400); await kiosk.updateComplete;
|
||||
const kr = kiosk.shadowRoot || kiosk.renderRoot;
|
||||
const kEl = kr.querySelector(`.decorlayer text.dtext[data-id="${live.id}"]`);
|
||||
out.sameInKiosk = !!kEl
|
||||
&& [...kEl.querySelectorAll('tspan')].map((t) => t.textContent).join('\n') === editorText;
|
||||
kiosk.remove();
|
||||
|
||||
// ---------- 8. round-trip хранит только текстовый шаблон ------------------
|
||||
const sent = [];
|
||||
const ws = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return ws(m); } };
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
|
||||
c._decorSaveText();
|
||||
await sleep(900);
|
||||
const set = sent.filter((m) => m.type === 'houseplan/config/set').pop();
|
||||
const shape = set && (set.config.spaces.find((s) => s.id === c._space).decor || [])
|
||||
.find((x) => x.id === live.id);
|
||||
out.roundTrip = !!shape && !('entity' in shape) && !('attr' in shape) && !('unit' in shape)
|
||||
&& shape.text === 'Бак {sensor.living_temp}; LQI {sensor.living_temp:linkquality}';
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -68,6 +68,35 @@ out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room');
|
||||
out.newRoom = s.rooms.find(r=>!['r1','r2','r3','r4','rg'].includes(r.id))?.name;
|
||||
await restore();
|
||||
|
||||
// AUD-159B7-01: one open span crossing the Split point belongs to two room
|
||||
// pairs afterwards. The card transaction must persist both pieces and derive
|
||||
// light-zone connectivity for both children, not keep only the first half.
|
||||
const spanSplit = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((x) => x.id === 'f1');
|
||||
sp.rooms = [
|
||||
{ id: 'left_top', name: 'Left top', area: null, poly: [[0, 0], [.5, 0], [.5, .2], [0, .2]] },
|
||||
{ id: 'left_bottom', name: 'Left bottom', area: null, poly: [[0, .2], [.5, .2], [.5, .4], [0, .4]] },
|
||||
{ id: 'right', name: 'Right', area: null, poly: [[.5, 0], [1, 0], [1, .4], [.5, .4]] },
|
||||
];
|
||||
sp.open_spans = [{ a: [.5, .1], b: [.5, .3] }];
|
||||
c._commitOpenSpans();
|
||||
const pieces = (sp.open_spans || []).map((e) =>
|
||||
[Math.min(e.a[1], e.b[1]), Math.max(e.a[1], e.b[1])]);
|
||||
const links = Object.fromEntries(sp.rooms.map((r) => [r.id, r.open_to || []]));
|
||||
return { pieces, links };
|
||||
});
|
||||
out.splitKeepsOpenPieces = spanSplit.pieces.length === 2
|
||||
&& Math.abs(spanSplit.pieces[0][0] - .1) < 1e-9
|
||||
&& Math.abs(spanSplit.pieces[0][1] - .2) < 1e-9
|
||||
&& Math.abs(spanSplit.pieces[1][0] - .2) < 1e-9
|
||||
&& Math.abs(spanSplit.pieces[1][1] - .3) < 1e-9;
|
||||
out.splitSyncsBothSides = spanSplit.links.left_top?.includes('right')
|
||||
&& spanSplit.links.left_bottom?.includes('right')
|
||||
&& spanSplit.links.right?.includes('left_top')
|
||||
&& spanSplit.links.right?.includes('left_bottom');
|
||||
await restore();
|
||||
|
||||
// SPLIT along a wall → refused (both pts on top wall of rg)
|
||||
await page.evaluate(()=>{const c=window.__card; c._serverCfg.spaces.find(s=>s.id==='f1').rooms.push(
|
||||
{id:'rg2', name:'G2', area:null, poly:[[0.05,0.0625],[0.5,0.0625],[0.5,0.5],[0.05,0.5]]});
|
||||
|
||||
+73
-34
@@ -1,11 +1,11 @@
|
||||
// Правило владельца (2026-08-01, вариант «б»): «движение = разовая вспышка
|
||||
// в момент обнаружения; cool-down не пульсирует; присутствие = статичное
|
||||
// кольцо пока обитаемо». Датчик ДВИЖЕНИЯ (device_class motion) при переходе
|
||||
// off→on получает класс 'senseflash' — короткая вспышка кольца (hp-sense,
|
||||
// 3 удара, ~3.3 c), после чего класс снимается, ДАЖЕ если сущность всё ещё
|
||||
// off→on получает activity-event — три последовательные волны
|
||||
// (hp-activity-event, ~3.3 c), после чего эффект снимается, ДАЖЕ если сущность всё ещё
|
||||
// висит в on (это cool-down датчика, а не движение). Новый цикл off→on —
|
||||
// новая вспышка. Датчики ПРИСУТСТВИЯ (occupancy/presence) при on несут
|
||||
// 'sensehold' — статичное жёлтое кольцо без анимации (opacity 0.4). Жёлтая
|
||||
// activity-presence — статичное кольцо без анимации (opacity 0.4). Жёлтая
|
||||
// заливка .dev.on в обоих случаях не участвует; ghost — без кольца
|
||||
// (призрак — конфигурация, не статус).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
@@ -14,6 +14,24 @@ const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// Fresh-card baseline: the marker is built while motion is off and no
|
||||
// unrelated hass tick is allowed to establish runtime later by accident.
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state: 'off',
|
||||
attributes: { friendly_name: 'Motion', device_class: 'motion', linkquality: 64 } } } };
|
||||
await c.updateComplete;
|
||||
c._activityRt.clear();
|
||||
// Activity is an explicit presentation: opt this fixture into it.
|
||||
const motionDevice = c._devices.find((x) => x.entities.includes('binary_sensor.hall_motion'));
|
||||
c._serverCfg.markers = (c._serverCfg.markers || [])
|
||||
.filter((m) => m.binding !== 'device:' + motionDevice.bindingRef);
|
||||
c._serverCfg.markers.push({
|
||||
id: motionDevice.id,
|
||||
binding: 'device:' + motionDevice.bindingRef,
|
||||
display: 'icon_ripple',
|
||||
});
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const setMotion = async (state, device_class) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state,
|
||||
@@ -22,19 +40,27 @@ const res = await page.evaluate(async () => {
|
||||
};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// --- motion off→on: вспышка senseflash, 3 удара, подложка НЕ жёлтая -----
|
||||
await setMotion('off', 'motion');
|
||||
out.offNoFlash = sr().querySelectorAll('.dev.senseflash, .dev.sensehold').length === 0;
|
||||
// --- первое событие сразу после build не теряется как baseline ----------
|
||||
await setMotion('on', 'motion');
|
||||
const flashEl = sr().querySelector('.dev.senseflash');
|
||||
out.firstTripAfterBuildFlashes = sr().querySelectorAll('.dev.activity-event').length === 1;
|
||||
await setMotion('off', 'motion');
|
||||
c._activityRt.clear(); c._syncActivityRuntime(); c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// --- motion off→on: event, 3 волны, подложка НЕ жёлтая -------------------
|
||||
await setMotion('off', 'motion');
|
||||
out.offNoFlash = sr().querySelectorAll('.dev.activity-event, .dev.activity-presence').length === 0;
|
||||
await setMotion('on', 'motion');
|
||||
const flashEl = sr().querySelector('.dev.activity-event');
|
||||
out.motionTripFlashes = !!flashEl;
|
||||
out.flashIsNotOn = !!flashEl && !flashEl.classList.contains('on') && !flashEl.classList.contains('open');
|
||||
const after = flashEl ? getComputedStyle(flashEl, '::after') : null;
|
||||
out.flashAnimated = !!after && after.animationName === 'hp-sense';
|
||||
out.flashFinite = !!after && after.animationIterationCount === '3'; // не infinite!
|
||||
const waves = flashEl ? [...flashEl.querySelectorAll('.activity-ring.event i')] : [];
|
||||
const firstWave = waves[0] ? getComputedStyle(waves[0]) : null;
|
||||
out.flashAnimated = !!firstWave && firstWave.animationName.startsWith('hp-activity-event');
|
||||
out.flashFinite = waves.length === 3
|
||||
&& waves.every((w) => getComputedStyle(w).animationIterationCount === '1');
|
||||
// подложка нейтральная — как у обычного .dev, не как у .dev.on
|
||||
const neutral = [...sr().querySelectorAll('.dev')].find((e) =>
|
||||
!e.classList.contains('senseflash') && !e.classList.contains('sensehold')
|
||||
!e.classList.contains('activity-event') && !e.classList.contains('activity-presence')
|
||||
&& !e.classList.contains('on') && !e.classList.contains('open')
|
||||
&& !e.classList.contains('alarm') && !e.classList.contains('unavail') && !e.classList.contains('valonly'));
|
||||
const bg = flashEl ? getComputedStyle(flashEl).backgroundColor : '';
|
||||
@@ -46,76 +72,89 @@ const res = await page.evaluate(async () => {
|
||||
await sleep(3600);
|
||||
await c.updateComplete;
|
||||
out.stateStillOn = c.hass.states['binary_sensor.hall_motion'].state === 'on';
|
||||
out.flashEndsWhileOn = sr().querySelectorAll('.dev.senseflash').length === 0;
|
||||
out.flashEndsWhileOn = sr().querySelectorAll('.dev.activity-event').length === 0;
|
||||
|
||||
// --- повторный цикл off→on: новая вспышка --------------------------------
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion');
|
||||
out.retripFlashesAgain = sr().querySelectorAll('.dev.senseflash').length === 1;
|
||||
out.retripFlashesAgain = sr().querySelectorAll('.dev.activity-event').length === 1;
|
||||
|
||||
// --- HP-1543-02: быстрый повторный off→on ДО конца текущей вспышки ------
|
||||
// обязан ПЕРЕЗАПУСТИТЬ анимацию (новый timeline). Раньше класс senseflash
|
||||
// обязан ПЕРЕЗАПУСТИТЬ анимацию (новый timeline). Раньше класс event
|
||||
// не снимался и animation-name не менялся — браузер продолжал старый
|
||||
// timeline, и после его конца (base opacity 0) второй trip был невидим.
|
||||
await sleep(3600); await c.updateComplete; // дать текущей вспышке догореть
|
||||
const senseAnim = (el) => el && el.getAnimations({ subtree: true })
|
||||
.find((a) => a.animationName && a.animationName.startsWith('hp-sense'));
|
||||
.find((a) => a.animationName && a.animationName.startsWith('hp-activity-event') && a.playState === 'running');
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion'); // trip №1
|
||||
out.rapidFirstTripFlashes = sr().querySelectorAll('.dev.senseflash').length === 1;
|
||||
out.rapidFirstTripFlashes = sr().querySelectorAll('.dev.activity-event').length === 1;
|
||||
await sleep(1200); // середина первой вспышки (< 3.3s)
|
||||
const a1 = senseAnim(sr().querySelector('.dev.senseflash'));
|
||||
const a1 = senseAnim(sr().querySelector('.dev.activity-event'));
|
||||
out.rapidFirstAnimMidway = !!a1 && a1.currentTime > 900 && a1.currentTime < 3300;
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion'); // trip №2 — retrip до конца вспышки
|
||||
out.rapidRetripKeepsFlash = sr().querySelectorAll('.dev.senseflash').length === 1;
|
||||
const a2 = senseAnim(sr().querySelector('.dev.senseflash'));
|
||||
out.rapidRetripKeepsFlash = sr().querySelectorAll('.dev.activity-event').length === 1;
|
||||
const a2 = senseAnim(sr().querySelector('.dev.activity-event'));
|
||||
// новый timeline: currentTime у начала (старый уже был ~1.2s и продолжал бы расти)
|
||||
out.rapidRetripRestartsAnim = !!a2 && a2.currentTime != null && a2.currentTime < 300;
|
||||
// кольцо реально играет ПОСЛЕ момента, где закончилась бы первая анимация:
|
||||
// ~2.3s после trip №2 = ~3.5s после trip №1 (старый timeline уже отыграл бы)
|
||||
await sleep(2300);
|
||||
const el3 = sr().querySelector('.dev.senseflash');
|
||||
const el3 = sr().querySelector('.dev.activity-event');
|
||||
const a3 = senseAnim(el3);
|
||||
out.rapidSecondFlashStillPlays = !!a3 && a3.playState === 'running';
|
||||
out.rapidRingVisibleAfterFirstWindow = !!el3
|
||||
&& parseFloat(getComputedStyle(el3, '::after').opacity) > 0.03;
|
||||
&& [...el3.querySelectorAll('.activity-ring.event i')]
|
||||
.some((w) => parseFloat(getComputedStyle(w).opacity) > 0.03);
|
||||
// и класс уходит через полные ~3.3s после ВТОРОГО trip (flashTs/таймер обновлены)
|
||||
await sleep(1400); await c.updateComplete;
|
||||
out.rapidFlashEndsAfterSecondWindow = sr().querySelectorAll('.dev.senseflash').length === 0;
|
||||
out.rapidFlashEndsAfterSecondWindow = sr().querySelectorAll('.dev.activity-event').length === 0;
|
||||
|
||||
// --- occupancy: статичное кольцо sensehold, без анимации ----------------
|
||||
// --- occupancy: статичное кольцо presence, без анимации -----------------
|
||||
await setMotion('on', 'occupancy');
|
||||
const holdEl = sr().querySelector('.dev.sensehold');
|
||||
const holdEl = sr().querySelector('.dev.activity-presence');
|
||||
out.occupancyHolds = !!holdEl;
|
||||
const hAfter = holdEl ? getComputedStyle(holdEl, '::after') : null;
|
||||
const holdRing = holdEl?.querySelector('.activity-ring.presence i:first-child');
|
||||
const hAfter = holdRing ? getComputedStyle(holdRing) : null;
|
||||
out.holdNotAnimated = !!hAfter && hAfter.animationName === 'none';
|
||||
out.holdRingFaint = !!hAfter && Math.abs(parseFloat(hAfter.opacity) - 0.4) < 0.01;
|
||||
out.holdBgNeutral = !!holdEl && !!neutral
|
||||
&& getComputedStyle(holdEl).backgroundColor === getComputedStyle(neutral).backgroundColor;
|
||||
out.holdNoFlashClass = !!holdEl && !holdEl.classList.contains('senseflash');
|
||||
out.holdNoFlashClass = !!holdEl && !holdEl.classList.contains('activity-event');
|
||||
await setMotion('off', 'occupancy');
|
||||
out.occupancyOffClears = sr().querySelectorAll('.dev.sensehold').length === 0;
|
||||
out.occupancyOffClears = sr().querySelectorAll('.dev.activity-presence').length === 0;
|
||||
|
||||
// --- presence аналогично -------------------------------------------------
|
||||
await setMotion('on', 'presence');
|
||||
out.presenceHolds = sr().querySelectorAll('.dev.sensehold').length === 1;
|
||||
out.presenceHolds = sr().querySelectorAll('.dev.activity-presence').length === 1;
|
||||
// и это именно sense-состояние, не тревога и не «открыто»
|
||||
out.noAlarmNoOpen = !sr().querySelector('.dev.sensehold.alarm') && !sr().querySelector('.dev.sensehold.open');
|
||||
out.noAlarmNoOpen = !sr().querySelector('.dev.activity-presence.alarm') && !sr().querySelector('.dev.activity-presence.open');
|
||||
|
||||
// --- смена effective source синхронно снимает старую вспышку ------------
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion');
|
||||
const liveMarker = c._serverCfg.markers.find((m) => m.id === motionDevice.id);
|
||||
liveMarker.controls = ['switch.kettle'];
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
out.rebindDropsOldFlashWithoutHassTick = !sr().querySelector('.dev.activity-event');
|
||||
delete liveMarker.controls;
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// --- ghost не рисует ничего (призрак — конфигурация, не статус) ---------
|
||||
await setMotion('off', 'motion');
|
||||
await setMotion('on', 'motion');
|
||||
const d = c._devices.find((x) => x.entities.includes('binary_sensor.hall_motion'));
|
||||
c._serverCfg.markers = c._serverCfg.markers || [];
|
||||
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
|
||||
c._serverCfg.markers = (c._serverCfg.markers || [])
|
||||
.filter((m) => m.binding !== 'device:' + d.bindingRef);
|
||||
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, display: 'icon_ripple', hidden: true });
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
out.hiddenNotInView = sr().querySelectorAll('.dev.senseflash, .dev.sensehold').length === 0;
|
||||
out.hiddenNotInView = sr().querySelectorAll('.dev.activity-event, .dev.activity-presence').length === 0;
|
||||
c._showHidden = true; c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
|
||||
const ghost = sr().querySelector('.dev.ghost');
|
||||
out.ghostNoSense = !!ghost && !ghost.classList.contains('senseflash') && !ghost.classList.contains('sensehold')
|
||||
&& getComputedStyle(ghost, '::after').animationName !== 'hp-sense';
|
||||
out.ghostNoSense = !!ghost && !ghost.classList.contains('activity-event') && !ghost.classList.contains('activity-presence')
|
||||
&& !ghost.querySelector('.activity-ring');
|
||||
return out;
|
||||
});
|
||||
checkAll(res, {});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Opening drag rulers (owner 2026-08-03): while a door/window 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 opts
|
||||
// out). The wall is ONE room's edge (owner: "only the wall of one room") —
|
||||
// perpendicular dashed tick appears and the center magnet-snaps (Shift cannot
|
||||
// opt out). The wall is ONE room's edge (owner: "only the wall of one room") —
|
||||
// r2's collinear top edge never merges into r1's, so the ruler runs 40..550
|
||||
// (center 295), not the old merged 40..960. Real pointer events; the numbers
|
||||
// are checked against the geometry: cell = 5 cm, grid pitch = 1000/240, so
|
||||
@@ -97,7 +97,7 @@ check('tick_gone_after_drop', await tick(), 0);
|
||||
check('committed_x_center', near(await page.evaluate(() =>
|
||||
window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.295, 1e-6));
|
||||
|
||||
// ---------- Shift disables the magnet (badges stay) ----------
|
||||
// ---------- Shift cannot disable the magnet (badges stay) ----
|
||||
// park the opening off-center first: a drag that returns to within 3 px of its
|
||||
// own starting point is filtered by the tap threshold, so the Shift approach
|
||||
// must start away from the center
|
||||
@@ -111,9 +111,9 @@ await page.keyboard.down('Shift');
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(t3x, sy, { steps: 3 });
|
||||
await settle();
|
||||
check('no_tick_with_shift', await tick(), 0);
|
||||
check('tick_with_shift', await tick(), 1);
|
||||
const freeX = await page.evaluate(() => window.__card._curSpaceCfg.openings[0].x);
|
||||
check('no_magnet_with_shift', near(freeX, 0.2937, 0.004) && freeX !== 0.295);
|
||||
check('magnet_with_shift', near(freeX, 0.295, 1e-6));
|
||||
check('badges_still_there_with_shift', (await badges()).length, 2);
|
||||
await page.mouse.up();
|
||||
await page.keyboard.up('Shift');
|
||||
@@ -169,4 +169,108 @@ check('shared_wall_badges_equal', await badges(), ['1.44 m', '1.44 m']);
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
|
||||
// ---------- PLACING a new opening: the same rulers (owner 2026-08-03) -------
|
||||
// Moving along a wall with the Opening tool must show exactly what dragging an
|
||||
// existing opening shows: a badge on each shoulder of the WOULD-BE opening, a
|
||||
// perpendicular tick + magnet at the wall centre, Shift opting out — and the
|
||||
// whole lot vanishing the moment the opening is placed. Default length is
|
||||
// 90 cm = 75 units, so the shoulders are measured from the edges c +- 37.5.
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.rooms = [
|
||||
{ id: 'r1', name: 'Living room', poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
|
||||
{ id: 'r2', name: 'Kitchen', poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] },
|
||||
];
|
||||
sp.openings = [];
|
||||
c._setMode('plan'); c._tool = 'opening';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
await settle();
|
||||
const ghosts = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.opghost').length);
|
||||
|
||||
// hover off-centre on r1's top wall (own edge 40..550, y = 140)
|
||||
{
|
||||
const [hx, hy] = await screenPt(460, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
check('place_ghost_shown', await ghosts(), 1);
|
||||
check('place_two_badges', (await badges()).length, 2);
|
||||
// edges 422.5 / 497.5 -> 382.5 u (4.59 m) left, 52.5 u (0.63 m) right
|
||||
const [a, b] = await nums();
|
||||
check('place_badge_right_0_63', near(a, 0.63, 0.05));
|
||||
check('place_badge_left_4_59', near(b, 4.59, 0.05));
|
||||
check('place_no_tick_off_center', await tick(), 0);
|
||||
}
|
||||
|
||||
// live update on the way — the badges follow the cursor
|
||||
{
|
||||
const [hx, hy] = await screenPt(200, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
// edges 162.5 / 237.5 -> 122.5 u (1.47 m) left, 312.5 u (3.75 m) right
|
||||
const [a, b] = await nums();
|
||||
check('place_badge_updates_1_47', near(a, 1.47, 0.05));
|
||||
check('place_badge_updates_3_75', near(b, 3.75, 0.05));
|
||||
}
|
||||
|
||||
// near the wall centre (40+550)/2 = 295: tick appears and the magnet bites
|
||||
{
|
||||
const [hx, hy] = await screenPt(293.7, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
check('place_tick_at_center', await tick(), 1);
|
||||
// equal shoulders after the magnet: (510 - 75) / 2 = 217.5 u -> 2.61 m
|
||||
check('place_badges_equal_at_center', await badges(), ['2.61 m', '2.61 m']);
|
||||
const g = await page.evaluate(() => {
|
||||
const el = window.__card.renderRoot.querySelector('.opcentertick');
|
||||
return el && { x1: +el.getAttribute('x1'), x2: +el.getAttribute('x2'),
|
||||
y1: +el.getAttribute('y1'), y2: +el.getAttribute('y2') };
|
||||
});
|
||||
check('place_tick_perpendicular', !!g && near(g.x1, 295, 1e-6) && near(g.x2, 295, 1e-6)
|
||||
&& near(g.y2 - g.y1, 30, 1e-6));
|
||||
// the ghost itself already sits on the magnetised centre
|
||||
const gh = await page.evaluate(() => {
|
||||
const el = window.__card.renderRoot.querySelector('.opghost');
|
||||
return el && (+el.getAttribute('x1') + +el.getAttribute('x2')) / 2;
|
||||
});
|
||||
check('place_ghost_magnetised', near(gh, 295, 1e-6));
|
||||
}
|
||||
|
||||
// Shift cannot opt out of the magnet, the badges stay
|
||||
{
|
||||
await page.keyboard.down('Shift');
|
||||
const [hx, hy] = await screenPt(293.7, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
check('place_tick_with_shift', await tick(), 1);
|
||||
check('place_badges_with_shift', (await badges()).length, 2);
|
||||
const gh = await page.evaluate(() => {
|
||||
const el = window.__card.renderRoot.querySelector('.opghost');
|
||||
return el && (+el.getAttribute('x1') + +el.getAttribute('x2')) / 2;
|
||||
});
|
||||
check('place_ghost_magnetised_with_shift', near(gh, 295, 1e-6));
|
||||
await page.keyboard.up('Shift');
|
||||
}
|
||||
|
||||
// the click places it AT THE MAGNET and clears every hint
|
||||
{
|
||||
const [hx, hy] = await screenPt(293.7, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.click(hx, hy);
|
||||
await settle();
|
||||
check('place_dialog_opened', await page.evaluate(() => !!window.__card._openingDialog), true);
|
||||
check('place_dialog_x_magnetised', near(await page.evaluate(() => window.__card._openingDialog.x), 295, 1e-6));
|
||||
check('place_badges_gone', (await badges()).length, 0);
|
||||
check('place_tick_gone', await tick(), 0);
|
||||
check('place_ghost_gone', await ghosts(), 0);
|
||||
// …and confirming the dialog writes the opening at the magnetised point
|
||||
await page.evaluate(() => { const c = window.__card; c._saveOpening(); return c.updateComplete && true; });
|
||||
await settle();
|
||||
check('place_committed_x_center', near(await page.evaluate(() =>
|
||||
window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.295, 1e-6));
|
||||
}
|
||||
|
||||
await finish(browser, { done: true });
|
||||
|
||||
+31
-19
@@ -5,17 +5,17 @@ const res = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
|
||||
// r1|r2 делят стену x=0.55 → клик по ней открывает границу
|
||||
const H = 1000; // square canvas
|
||||
c._openWallClick([550, 0.25 * H]);
|
||||
// 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]);
|
||||
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');
|
||||
// в Просмотре: сплошной штрих комнаты снят, контур без выреза + пунктир ПОВЕРХ glow
|
||||
// View: trimmed outlines + dash
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
out.noedge = sr().querySelectorAll('.room.noedge').length >= 2;
|
||||
out.trimmedOutline = sr().querySelectorAll('.room-outline').length >= 2;
|
||||
@@ -24,30 +24,31 @@ const res = await page.evaluate(async () => {
|
||||
const gi = order.indexOf('glowlayer');
|
||||
const oi = order.indexOf('openwalls');
|
||||
out.dashAboveGlow = gi === -1 || oi > gi;
|
||||
// в редакторе плана: пунктир виден, штрих комнат вырезан, контур синий
|
||||
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
|
||||
out.planDashes = sr().querySelectorAll('.openwall').length > 0;
|
||||
out.planNoedge = sr().querySelectorAll('.room.noedge').length >= 2;
|
||||
out.planBlueOutline = sr().querySelectorAll('.room-outline.outlined').length >= 2;
|
||||
// производные стены (.seg) не проходят сквозь открытый участок x=550, y≈0.25H
|
||||
const midY = 0.25 * H;
|
||||
const midY = 300;
|
||||
out.planSegCut = ![...sr().querySelectorAll('line.seg')].some((l) => {
|
||||
const x1 = +l.getAttribute('x1'), x2 = +l.getAttribute('x2');
|
||||
const y1 = +l.getAttribute('y1'), y2 = +l.getAttribute('y2');
|
||||
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'; await c.updateComplete;
|
||||
// повторный клик закрывает
|
||||
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
|
||||
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;
|
||||
out.toggledOff = !(c._curSpaceCfg.rooms.find((r) => r.id === 'r1').open_to || []).includes('r2');
|
||||
out.dashesGone = sr().querySelectorAll('.openwall').length === 0;
|
||||
// клик мимо стен — тост, без изменений
|
||||
c._openWallClick([100, 100]);
|
||||
c._closeWallClick([100, 100]);
|
||||
out.missToast = !!c._toast;
|
||||
// снова открыть для glow-теста
|
||||
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
|
||||
// glow: свет из r1 заливает и r2 (clip содержит оба полигона)
|
||||
// reopen for glow
|
||||
c._openWallClick([550, 140]);
|
||||
c._openWallClick([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 : ({
|
||||
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
|
||||
@@ -57,17 +58,28 @@ const res = await page.evaluate(async () => {
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const clip = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
|
||||
out.zoneClip = clip ? clip.querySelectorAll('path').length >= 2 : false;
|
||||
// транзитивность: r2↔r3 тоже открыть → clip получит 3 полигона
|
||||
const rr2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
|
||||
const rr3 = c._curSpaceCfg.rooms.find((r) => r.id === 'r3');
|
||||
if (rr3) {
|
||||
rr2.open_to = [...(rr2.open_to || []), 'r3'];
|
||||
rr3.open_to = [...(rr3.open_to || []), 'r2'];
|
||||
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
|
||||
// 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';
|
||||
// shared r2|r3 at y=460, x 550..960
|
||||
c._openWallClick([700, 460]);
|
||||
c._openWallClick([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._toast = null;
|
||||
c._openWallClick([40, 300]);
|
||||
out.outerToast = !!(c._toast && String(c._toast).length);
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
checkAll(res, { outerToast: true });
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -6,30 +6,34 @@ const res = await page.evaluate(async () => {
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
|
||||
const H = 1000; // square canvas
|
||||
// 1) без наведения: курсор default, превью нет
|
||||
const H = 1000;
|
||||
// 1) idle: crosshair (draw-like), no preview
|
||||
c._cursorPt = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.idleCursor = getComputedStyle(stage()).cursor === 'default';
|
||||
out.idleCursor = getComputedStyle(stage()).cursor === 'crosshair';
|
||||
out.noPreview = !sr().querySelector('.openwall-preview');
|
||||
// 2) наведение на общую стену r1|r2 (x=550): pointer + янтарное превью
|
||||
// 2) hover shared solid wall: still NO full-edge preview (click target must stay clear)
|
||||
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
|
||||
out.hotCursor = getComputedStyle(stage()).cursor === 'pointer';
|
||||
const prev = sr().querySelectorAll('.openwall-preview');
|
||||
out.previewShown = prev.length > 0;
|
||||
out.previewAmber = prev.length ? !prev[0].classList.contains('willclose') : null;
|
||||
// превью лежит на стене x=550
|
||||
out.previewOnWall = prev.length ? Math.abs(Number(prev[0].getAttribute('x1')) - 550) < 0.5 : null;
|
||||
// 3) наведение в центр комнаты: снова default, превью исчезло
|
||||
c._cursorPt = [300, 150]; c.requestUpdate(); await c.updateComplete;
|
||||
out.missCursor = getComputedStyle(stage()).cursor === 'default';
|
||||
out.missNoPreview = !sr().querySelector('.openwall-preview');
|
||||
// 4) открыть границу → навести снова: превью красное сплошное (клик закроет)
|
||||
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
|
||||
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
|
||||
const prev2 = sr().querySelector('.openwall-preview');
|
||||
out.willclose = prev2?.classList.contains('willclose') === true;
|
||||
// прибрать
|
||||
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
|
||||
out.hoverNoFullEdge = !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]);
|
||||
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;
|
||||
// 4) with anchor set: rubber-band preview appears
|
||||
c._closeWallClick([550, 300]);
|
||||
await c.updateComplete;
|
||||
c._tool = 'openwall';
|
||||
c._openWallClick([550, 200]);
|
||||
c._cursorPt = [550, 400]; c.requestUpdate(); await c.updateComplete;
|
||||
const band = sr().querySelector('.openwall-preview');
|
||||
out.anchorPreview = !!band && !band.classList.contains('willclose');
|
||||
c._openWallAnchor = null;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Panning at ANY zoom (owner's report 2026-08-04: «добавь возможность таскать
|
||||
// план при любом масштабе, а не только при более 100%»).
|
||||
//
|
||||
// Until this fix `_stagePointerMove` moved the view only while `_zoom > 1`:
|
||||
// the rule was inherited from the old bounded canvas, where a plan smaller
|
||||
// than the scene had nowhere to go. The infinite canvas removed that edge —
|
||||
// there is always somewhere to go, and an editor extending a plan outwards
|
||||
// needs exactly that. So a drag on empty scene now pans at 100 %, at 50 % and
|
||||
// at 800 %, in view mode and in every editor; how far you may walk is decided
|
||||
// by `_clampView` (PAN_SLACK: about a screen past the content) and not by the
|
||||
// zoom. The tools keep the pointer they already owned — a resize handle, a
|
||||
// device badge, an opening — and the kiosk keeps its floor swipe.
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
// ---- a drag on empty scene pans, at every zoom and in every mode ---------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
|
||||
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
|
||||
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
|
||||
let pid = 100;
|
||||
/** Drag 80x60 px across the middle of the scene; did the view follow? */
|
||||
const drag = async (zoom) => {
|
||||
// always start from the plan in the middle: the clamp is a real limit, and
|
||||
// a dozen drags in a row would otherwise walk into it
|
||||
const vb = c._baseVb();
|
||||
c._applyView(zoom, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
||||
await c.updateComplete;
|
||||
const v0 = { ...c._viewOr(c._baseVb()) };
|
||||
const id = ++pid;
|
||||
pd(id, 300, 300);
|
||||
pm(id, 340, 330);
|
||||
pm(id, 380, 360);
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
pu(id, 380, 360);
|
||||
// dragging down-right pulls the viewport up-left, and the zoom is untouched
|
||||
return Math.abs(v1.x - v0.x) > 1 && Math.abs(v1.y - v0.y) > 1
|
||||
&& v1.x < v0.x && v1.y < v0.y && Math.abs(c._zoom - zoom) < 1e-9;
|
||||
};
|
||||
const inMode = async (name, setup) => {
|
||||
await setup();
|
||||
await c.updateComplete;
|
||||
o['pan_' + name + '_at100'] = await drag(1);
|
||||
o['pan_' + name + '_at50'] = await drag(0.5);
|
||||
o['pan_' + name + '_at33'] = await drag(1 / 3);
|
||||
};
|
||||
|
||||
await inMode('view', async () => { c._setMode('view'); });
|
||||
for (const tool of ['draw', 'merge', 'split', 'resize', 'opening', 'openwall', 'closewall', 'delroom']) {
|
||||
await inMode('plan_' + tool, async () => { c._setMode('plan'); c._tool = tool; });
|
||||
}
|
||||
await inMode('devices', async () => { c._setMode('devices'); });
|
||||
await inMode('decor', async () => { c._setMode('decor'); c._decorTool = 'select'; });
|
||||
c._setMode('view');
|
||||
c._applyView(1);
|
||||
await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- 400 % still pans (the behaviour that already worked) ----------------
|
||||
out.panStillWorksZoomedIn = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('view'); c._applyView(4); await c.updateComplete;
|
||||
const v0 = { ...c._view };
|
||||
c._stagePointerDown({ pointerId: 7, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 7, clientX: 380, clientY: 360 });
|
||||
const moved = Math.abs(c._view.x - v0.x) > 1;
|
||||
c._stagePointerUp({ pointerId: 7, clientX: 380, clientY: 360 });
|
||||
c._applyView(1);
|
||||
return moved;
|
||||
});
|
||||
|
||||
// ---- the clamp: you may walk about a screen off, never further ------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
c._setMode('view');
|
||||
const vb = c._baseVb();
|
||||
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); // at 100 % the view IS fitView
|
||||
await c.updateComplete;
|
||||
const fit = { ...c._view };
|
||||
c._stagePointerDown({ pointerId: 8, clientX: 700, clientY: 400, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 8, clientX: -40000, clientY: 400 }); // shove it into the void
|
||||
const far = { ...c._view };
|
||||
c._stagePointerUp({ pointerId: 8, clientX: -40000, clientY: 400 });
|
||||
o.panLeavesTheContent = far.x > fit.x + fit.w * 0.5;
|
||||
o.panStopsAtTheSlack = far.x <= fit.x + fit.w + 1;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
o.homeArrowOffersTheWayBack = !!sr.querySelector('.homearrow');
|
||||
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
// the same at 50 %: the screen holds more, the walk is still finite
|
||||
const fit2 = { ...c._view };
|
||||
c._applyView(0.5, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); await c.updateComplete;
|
||||
c._stagePointerDown({ pointerId: 9, clientX: 700, clientY: 400, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 9, clientX: -40000, clientY: 400 });
|
||||
const far2 = { ...c._view };
|
||||
c._stagePointerUp({ pointerId: 9, clientX: -40000, clientY: 400 });
|
||||
o.panStopsAtTheSlackZoomedOut = far2.x <= fit2.x + fit2.w + 1 && far2.x > fit2.x;
|
||||
c._applyView(1, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2); await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- the tools still own their pointer ------------------------------------
|
||||
const ev = (type, id, x, y) => ({ type, id, x, y });
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
const fire = (el, type, id, x, y) => el.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
|
||||
}));
|
||||
|
||||
// a) resize handle: the wall moves, the view does not
|
||||
c._setMode('plan'); c._tool = 'resize'; c._applyView(1); await c.updateComplete;
|
||||
const h = sr.querySelector('.rszhandle');
|
||||
o.resizeHandlePresent = !!h;
|
||||
if (h) {
|
||||
const v0 = { ...c._viewOr(c._baseVb()) };
|
||||
const r = h.getBoundingClientRect();
|
||||
fire(h, 'pointerdown', 21, r.left + r.width / 2, r.top + r.height / 2);
|
||||
o.resizeHandleTakesThePointer = !!c._rszDrag && c._panStart === null;
|
||||
fire(h, 'pointermove', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
o.resizeHandleDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
|
||||
o.resizeHandleResizes = !!c._rszDrag && c._rszDrag.moved === true;
|
||||
c._rszCancelDrag?.();
|
||||
fire(h, 'pointerup', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
|
||||
}
|
||||
c._rszSel = null;
|
||||
|
||||
// b) a device badge in the devices editor drags the DEVICE
|
||||
c._setMode('devices'); c._applyView(1); await c.updateComplete;
|
||||
const dev = sr.querySelector('.devlayer .dev');
|
||||
o.devicePresent = !!dev;
|
||||
if (dev) {
|
||||
const v0 = { ...c._viewOr(c._baseVb()) };
|
||||
const r = dev.getBoundingClientRect();
|
||||
fire(dev, 'pointerdown', 22, r.left + r.width / 2, r.top + r.height / 2);
|
||||
o.deviceTakesThePointer = !!c._drag && c._panStart === null;
|
||||
fire(dev, 'pointermove', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
|
||||
c._stagePointerMove({ pointerId: 22, clientX: r.left + 60, clientY: r.top + 50 });
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
o.deviceDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
|
||||
o.deviceActuallyMoved = !!c._drag && c._drag.moved === true;
|
||||
fire(dev, 'pointerup', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
|
||||
// _pointerUp releases _drag on the next macrotask, and _stagePointerDown
|
||||
// refuses to start a gesture while a device drag is live
|
||||
await new Promise((r2) => setTimeout(r2, 20));
|
||||
o.deviceDragReleased = c._drag === null;
|
||||
await c.updateComplete;
|
||||
}
|
||||
|
||||
// c) an opening drags along its wall, the view stays put
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].openings = [{ id: 'o_pan', type: 'window', x: 0.3, y: 0.14, angle: 0, length: 0.1 }];
|
||||
c._serverCfg = cfg; c._modelCache = null; c._frame = null;
|
||||
c._setMode('plan'); c._tool = 'draw'; c._applyView(1);
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const op = sr.querySelector('.op-hit');
|
||||
o.openingPresent = !!op;
|
||||
if (op) {
|
||||
const v0 = { ...c._viewOr(c._baseVb()) };
|
||||
const r = op.getBoundingClientRect();
|
||||
fire(op, 'pointerdown', 23, r.left + r.width / 2, r.top + r.height / 2);
|
||||
o.openingTakesThePointer = !!c._opDrag && c._panStart === null;
|
||||
fire(op, 'pointermove', 23, r.left + r.width / 2 + 50, r.top + r.height / 2);
|
||||
const v1 = { ...c._viewOr(c._baseVb()) };
|
||||
o.openingDragDoesNotPan = Math.abs(v1.x - v0.x) < 0.5 && Math.abs(v1.y - v0.y) < 0.5;
|
||||
fire(op, 'pointerup', 23, r.left + r.width / 2 + 50, r.top + r.height / 2);
|
||||
await c.updateComplete;
|
||||
}
|
||||
|
||||
// d) a drawing tool is still a drawing tool: two fingers pinch, they do not pan
|
||||
c._setMode('plan'); c._tool = 'draw'; c._applyView(1); await c.updateComplete;
|
||||
const z0 = c._zoom;
|
||||
c._stagePointerDown({ pointerId: 31, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerDown({ pointerId: 32, clientX: 400, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 31, clientX: 250, clientY: 300 });
|
||||
c._stagePointerMove({ pointerId: 32, clientX: 450, clientY: 300 });
|
||||
o.pinchStillZooms = c._zoom > z0 * 1.5;
|
||||
c._stagePointerUp({ pointerId: 31, clientX: 250, clientY: 300 });
|
||||
c._stagePointerUp({ pointerId: 32, clientX: 450, clientY: 300 });
|
||||
c._path = [];
|
||||
c._setMode('view'); c._applyView(1); await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- the kiosk keeps its floor swipe ---------------------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c0 = window.__card;
|
||||
const o = {};
|
||||
const k = document.createElement('houseplan-card');
|
||||
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
k.hass = c0.hass;
|
||||
document.body.appendChild(k);
|
||||
k.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
k.hass = { ...c0.hass };
|
||||
await k.updateComplete;
|
||||
const sr = k.shadowRoot || k.renderRoot;
|
||||
const stage = sr.querySelector('.stage');
|
||||
const fire = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id, clientX: x, clientY: y,
|
||||
}));
|
||||
k._applyView(1); await k.updateComplete;
|
||||
const s0 = k._space;
|
||||
const v0 = { ...k._viewOr(k._baseVb()) };
|
||||
// a REAL swipe: down, several moves, up — the moves are what used to be
|
||||
// impossible to add to smoke_kiosk without hijacking the gesture
|
||||
fire('pointerdown', 41, 600, 300);
|
||||
fire('pointermove', 41, 540, 302);
|
||||
fire('pointermove', 41, 480, 305);
|
||||
fire('pointermove', 41, 450, 305);
|
||||
const midPan = Math.abs(k._viewOr(k._baseVb()).x - v0.x);
|
||||
fire('pointerup', 41, 450, 305);
|
||||
await k.updateComplete;
|
||||
o.kioskSwipeStillSwitchesFloors = k._space !== s0;
|
||||
o.kioskHorizontalDragIsNotAPan = midPan < 1;
|
||||
// …and the plan is not left hanging half-panned after the switch
|
||||
o.kioskViewCenteredAfterSwipe = Math.abs(k._viewOr(k._baseVb()).x - v0.x) < 2000;
|
||||
// a VERTICAL drag is nobody's swipe, so it pans like everywhere else
|
||||
const s1 = k._space;
|
||||
const v1 = { ...k._viewOr(k._baseVb()) };
|
||||
fire('pointerdown', 42, 450, 200);
|
||||
fire('pointermove', 42, 452, 260);
|
||||
fire('pointermove', 42, 454, 330);
|
||||
const panned = Math.abs(k._viewOr(k._baseVb()).y - v1.y) > 1;
|
||||
fire('pointerup', 42, 454, 330);
|
||||
await k.updateComplete;
|
||||
o.kioskVerticalDragPans = panned;
|
||||
o.kioskVerticalDragKeepsFloor = k._space === s1;
|
||||
// zoomed in, a horizontal drag pans (no swipe up there — unchanged rule)
|
||||
k._applyView(2); await k.updateComplete;
|
||||
const v2 = { ...k._view };
|
||||
const s2 = k._space;
|
||||
fire('pointerdown', 43, 600, 300);
|
||||
fire('pointermove', 43, 540, 302);
|
||||
fire('pointermove', 43, 480, 305);
|
||||
const pannedZoomed = Math.abs(k._view.x - v2.x) > 1;
|
||||
fire('pointerup', 43, 480, 305);
|
||||
await k.updateComplete;
|
||||
o.kioskZoomedHorizontalPans = pannedZoomed;
|
||||
o.kioskZoomedDoesNotSwipe = k._space === s2;
|
||||
k.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,106 @@
|
||||
// «Уже загруженные» — прокручиваемый список превью внутри диалога пространства.
|
||||
// Проверяем НЕ наличие узлов в DOM, а ГЕОМЕТРИЮ: scrollable-контейнер как
|
||||
// flex-item в .dialog .body (flex column) схлопывается до бордера (min-height
|
||||
// auto = 0 при overflow != visible), и владелец видит тонкую полоску вместо
|
||||
// сетки миниатюр. Ровно этот баг уже ловили в .candlist (v1.53.1) — смок там
|
||||
// смотрел только на DOM и пропустил его.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: 'desktop', width: 900, height: 1000 },
|
||||
{ name: 'mobile', width: 390, height: 780 },
|
||||
];
|
||||
|
||||
const out = {};
|
||||
for (const vp of VIEWPORTS) {
|
||||
const { page, browser } = await launch({ width: vp.width, height: vp.height }, 1);
|
||||
const res = await page.evaluate(async (mode) => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const base = c.hass.callWS;
|
||||
const mk = (i) => ({
|
||||
name: `plan${i}.png`,
|
||||
url: `/api/houseplan/content/plans/_/plan${i}.png`,
|
||||
size: 100000 + i * 1000,
|
||||
modified: i,
|
||||
used_by: [],
|
||||
});
|
||||
let serverPlans = [mk(1), mk(2), mk(3), mk(4), mk(5), mk(6)];
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plans/list') return { plans: serverPlans };
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
const urls = {}; for (const p of m.paths) urls[p] = p; return { urls };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
|
||||
const openPicker = async () => {
|
||||
c._spaceDialog = { ...c._spaceDialog, source: 'file' };
|
||||
await c.updateComplete;
|
||||
await c._toggleServerPlans();
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(() => r()));
|
||||
};
|
||||
const geom = (tag) => {
|
||||
const box = sr().querySelector('.savedplans');
|
||||
const rows = [...sr().querySelectorAll('.savedplan')];
|
||||
o[tag + 'Rows'] = rows.length;
|
||||
if (!box || !rows.length) { o[tag + 'BoxTall'] = false; o[tag + 'FirstVisible'] = false; return; }
|
||||
const b = box.getBoundingClientRect();
|
||||
const r0 = rows[0].getBoundingClientRect();
|
||||
o[tag + 'BoxH'] = Math.round(b.height);
|
||||
// 2-3 ряда миниатюр: ряд ~40px + отступы
|
||||
o[tag + 'BoxTall'] = b.height >= 100;
|
||||
// первая миниатюра целиком внутри контейнера и не нулевая
|
||||
o[tag + 'FirstVisible'] = r0.height >= 32 && r0.top >= b.top - 1 && r0.bottom <= b.bottom + 1;
|
||||
// при 6 планах список не влезает целиком — должна быть прокрутка
|
||||
o[tag + 'Scrolls'] = box.scrollHeight - box.clientHeight > 8;
|
||||
box.scrollTop = 9999;
|
||||
o[tag + 'ScrollWorks'] = box.scrollTop > 8;
|
||||
box.scrollTop = 0;
|
||||
};
|
||||
|
||||
// 1. диалог создания пространства
|
||||
c._openSpaceDialog('create');
|
||||
await c.updateComplete;
|
||||
await openPicker();
|
||||
geom('create');
|
||||
|
||||
// 2. диалог настроек существующего пространства
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
c._openSpaceDialog('edit', 'f1');
|
||||
await c.updateComplete;
|
||||
await openPicker();
|
||||
geom('edit');
|
||||
|
||||
// 3. пусто: аккуратная надпись, а не схлопнутая полоска
|
||||
serverPlans = [];
|
||||
c._spaceDialog = { ...c._spaceDialog, pickSaved: false };
|
||||
await c.updateComplete;
|
||||
await openPicker();
|
||||
const empty = sr().querySelector('.savedplans');
|
||||
o.emptyHasText = !!empty && empty.textContent.trim().length > 0;
|
||||
o.emptyTall = !!empty && empty.getBoundingClientRect().height >= 24;
|
||||
o.mode = mode;
|
||||
return o;
|
||||
}, vp.name);
|
||||
for (const [k, v] of Object.entries(res)) out[vp.name + '.' + k] = v;
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(out, null, 1));
|
||||
checkAll(out, {
|
||||
'desktop.createRows': 6,
|
||||
'desktop.editRows': 6,
|
||||
'desktop.createBoxH': out['desktop.createBoxH'],
|
||||
'desktop.editBoxH': out['desktop.editBoxH'],
|
||||
'desktop.mode': 'desktop',
|
||||
'mobile.createRows': 6,
|
||||
'mobile.editRows': 6,
|
||||
'mobile.createBoxH': out['mobile.createBoxH'],
|
||||
'mobile.editBoxH': out['mobile.editBoxH'],
|
||||
'mobile.mode': 'mobile',
|
||||
});
|
||||
await finish(null);
|
||||
@@ -13,6 +13,7 @@ const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
// ---------- phase 1: normal mode, HA panel lands late ----------
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'height:0px';
|
||||
const c = document.createElement('houseplan-card');
|
||||
@@ -119,6 +120,7 @@ check('kioskPlanNeverHidden', kiosk.kioskPlanNeverHidden);
|
||||
// ---------- phase 3: prefers-reduced-motion → the house does not pulse ----------
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
const reduced = await page.evaluate(async () => {
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
|
||||
@@ -22,6 +22,7 @@ const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
const rec = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__card.hass;
|
||||
@@ -48,6 +49,7 @@ const rec = await page.evaluate(async () => {
|
||||
out.veilGone = !sr().querySelector('.bootveil');
|
||||
|
||||
// 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');
|
||||
c2.setConfig({ type: 'custom:houseplan-card' });
|
||||
c2.hass = window.__card.hass;
|
||||
@@ -72,6 +74,7 @@ const outB = {};
|
||||
for (const shiftMs of shifts) {
|
||||
const res = await page.evaluate(async (shiftAt) => {
|
||||
const out = {};
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'height:0px';
|
||||
const c = document.createElement('houseplan-card');
|
||||
@@ -133,6 +136,7 @@ for (const shiftMs of shifts) {
|
||||
const late = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'height:0px';
|
||||
const c = document.createElement('houseplan-card');
|
||||
|
||||
@@ -47,6 +47,29 @@ const res = await page.evaluate(async () => {
|
||||
out.parity = out.fullCardRoom0 === out.staticCardRoom0;
|
||||
out.overrideRespected = out.staticCardRoom0 === 'transparent';
|
||||
|
||||
// Owner 2026-08-04: «углы границ комнат всё ещё с зубцами» — a miter join
|
||||
// spikes far past a sharp corner (and bevels it flat once past the miter
|
||||
// limit). Room borders join ROUND now, and BOTH renderers share the rule:
|
||||
// the styles live in one cardStyles, and this is the smoke that keeps it so.
|
||||
const joins = (root) => [...root.querySelectorAll('.room')]
|
||||
.map((el) => getComputedStyle(el).strokeLinejoin);
|
||||
const fullJoins = joins(main.shadowRoot || main.renderRoot);
|
||||
const staticJoins = joins(card.renderRoot);
|
||||
out.fullCardBordersRound = fullJoins.length > 0 && fullJoins.every((j) => j === 'round');
|
||||
out.staticCardBordersRound = staticJoins.length > 0 && staticJoins.every((j) => j === 'round');
|
||||
// the trimmed outline of a room with open boundaries is a set of separate
|
||||
// subpaths: its CAPS are the corners, so they must be round as well
|
||||
const f1cfg = cfg.spaces.find((s) => s.id === 'f1');
|
||||
f1cfg.rooms[0].open_to = [f1cfg.rooms[1].id];
|
||||
main._cfgEpoch++;
|
||||
main.requestUpdate(); await main.updateComplete;
|
||||
const outlines = [...(main.shadowRoot || main.renderRoot).querySelectorAll('.room-outline')];
|
||||
out.trimmedOutlineDrawn = outlines.length > 0;
|
||||
out.trimmedOutlineRound = outlines.every((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return cs.strokeLinejoin === 'round' && cs.strokeLinecap === 'round';
|
||||
});
|
||||
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.0 и сверено с кодом
|
||||
|
||||
+10
-11
@@ -3,38 +3,37 @@ const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
// счётчики вызовов тяжёлой геометрии
|
||||
// AUD-159B6-06: the old hook `_computeOpenPairs` is gone. The observable
|
||||
// contract that still matters is the MODEL memo — a hass tick that changes
|
||||
// no geometry must not rebuild spaces (audit L1). Open-pair work is cheap
|
||||
// and may run once per paint; we only refuse a storm (>2× renders).
|
||||
let pairsCalls = 0, buildCalls = 0;
|
||||
const origPairs = c._computeOpenPairs.bind(c);
|
||||
c._computeOpenPairs = (...a) => { pairsCalls++; return origPairs(...a); };
|
||||
const origPairs = c._openPairs.bind(c);
|
||||
c._openPairs = (...a) => { pairsCalls++; return origPairs(...a); };
|
||||
const origBuild = c._buildModel.bind(c);
|
||||
c._buildModel = (...a) => { buildCalls++; return origBuild(...a); };
|
||||
// открыть границу, чтобы pairs было что считать
|
||||
const sp = c._curSpaceCfg;
|
||||
const r1 = sp.rooms[0], r2 = sp.rooms[1];
|
||||
r1.open_to = [r2.id]; r2.open_to = [r1.id];
|
||||
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
pairsCalls = 0; buildCalls = 0;
|
||||
// 10 «пушей состояния» от HA без изменения конфига
|
||||
for (let i = 0; i < 10; i++) {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } };
|
||||
await c.updateComplete;
|
||||
}
|
||||
out.pairsPerRender = pairsCalls; // должно быть 0: кэш живёт между рендерами
|
||||
out.pairsBounded = pairsCalls <= 20;
|
||||
out.modelBuildsPer10Renders = buildCalls;
|
||||
// правка конфига обязана инвалидировать кэш
|
||||
const before = pairsCalls;
|
||||
const r3 = sp.rooms[2] || sp.rooms[0];
|
||||
r3.open_to = [r1.id]; r1.open_to = [r2.id, r3.id];
|
||||
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
out.invalidatesOnEdit = pairsCalls > before;
|
||||
// геометрия по-прежнему правильная: пунктир на месте
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true };
|
||||
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
out.dashesStillRendered = (c.shadowRoot || c.renderRoot).querySelectorAll('.openwall').length > 0;
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"pairsPerRender": 0,
|
||||
"modelBuildsPer10Renders": 0,
|
||||
modelBuildsPer10Renders: 0,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -15,7 +15,8 @@ const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg))
|
||||
const restore = () => page.evaluate((s) => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = JSON.parse(s);
|
||||
c._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszUndo = [];
|
||||
c._rszSel = null; c._rszDrag = null; c._rszLive = null;
|
||||
c._geometryHistory.clear();
|
||||
if ('_rszPreview' in c) c._rszPreview = null;
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
@@ -138,7 +139,7 @@ await settle();
|
||||
await settle();
|
||||
check('03_snapshot_back_live', Math.abs((await roomLive('r1'))[1][0] - 0.55) < 1e-6);
|
||||
check('03_server_untouched', Math.abs((await roomSrv('r1'))[1][0] - 0.55) < 1e-6);
|
||||
check('03_undo_empty', await page.evaluate(() => window.__card._rszUndo.length), 0);
|
||||
check('03_undo_empty', await page.evaluate(() => window.__card._geometryHistory.size), 0);
|
||||
check('03_drag_cleared', await page.evaluate(() => window.__card._rszDrag === null));
|
||||
await page.waitForTimeout(800);
|
||||
check('03_no_pending_write', (await writes()) - w0, 0);
|
||||
@@ -166,7 +167,7 @@ await settle();
|
||||
});
|
||||
await settle();
|
||||
check('03_corner_snapshot_back', Math.abs((await roomLive('r4'))[2][0] - 0.55) < 1e-6);
|
||||
check('03_corner_undo_empty', await page.evaluate(() => window.__card._rszUndo.length), 0);
|
||||
check('03_corner_undo_empty', await page.evaluate(() => window.__card._geometryHistory.size), 0);
|
||||
await page.waitForTimeout(800);
|
||||
check('03_corner_no_write', (await writes()) - w0, 0);
|
||||
await page.mouse.up();
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Regression: resize a shared wall whose middle is virtual and whose solid
|
||||
* remainders carry thickness. The live overlay, commit and Undo must move one
|
||||
* geometry transaction. Virtual drawing paints above the real wall body in
|
||||
* editors, but below it in View so thick-wall jambs mask the dash ends.
|
||||
*/
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
|
||||
|
||||
sp().settings = { ...(sp().settings || {}), show_borders: true };
|
||||
delete sp().walls;
|
||||
delete sp().open_spans;
|
||||
delete sp().openings;
|
||||
for (const r of sp().rooms || []) delete r.open_to;
|
||||
|
||||
c._setMode('plan');
|
||||
c._tool = 'wallthick';
|
||||
await upd();
|
||||
c._wallThickClick([550, 250]);
|
||||
await upd();
|
||||
if (c._wallDialog) {
|
||||
c._wallDialog = { ...c._wallDialog, value: '20' };
|
||||
c._wallThickApply(false);
|
||||
}
|
||||
await upd();
|
||||
out.realBodyReady = !!sr().querySelector('.wallbodies');
|
||||
|
||||
// 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._cursorPt = [550, 300];
|
||||
await upd();
|
||||
const bodyGroup = sr().querySelector('.wallbodies');
|
||||
const virtualGroup = sr().querySelector('.openwalls');
|
||||
out.virtualPreviewDrawn = !!sr().querySelector('.openwall-preview');
|
||||
out.virtualPreviewAboveReal = !!bodyGroup && !!virtualGroup
|
||||
&& !!(bodyGroup.compareDocumentPosition(virtualGroup) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
|
||||
c._openWallClick([550, 300]);
|
||||
c._cursorPt = null;
|
||||
await upd();
|
||||
out.partialSpanCreated = (sp().open_spans || []).length === 1;
|
||||
const editorBody = sr().querySelector('.wallbodies');
|
||||
const editorVirtual = sr().querySelector('.openwalls');
|
||||
out.savedVirtualAboveRealInEditor = !!editorBody && !!editorVirtual
|
||||
&& !!(editorBody.compareDocumentPosition(editorVirtual) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
const viewBody = sr().querySelector('.wallbodies');
|
||||
const viewVirtual = sr().querySelector('.openwalls');
|
||||
out.savedVirtualBelowRealInView = !!viewBody && !!viewVirtual
|
||||
&& !!(viewVirtual.compareDocumentPosition(viewBody) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
c._setMode('plan');
|
||||
await upd();
|
||||
out.atomicThicknessBefore = c._intervalCm([550, 145, 550, 195]) === 20
|
||||
&& c._intervalCm([550, 205, 550, 295]) === 0
|
||||
&& c._intervalCm([550, 305, 550, 455]) === 20;
|
||||
|
||||
const before = JSON.stringify({
|
||||
rooms: sp().rooms,
|
||||
walls: sp().walls,
|
||||
open_spans: sp().open_spans,
|
||||
});
|
||||
c._tool = 'resize';
|
||||
await upd();
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
const toScreen = (x, y) => {
|
||||
const r = stage().getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return {
|
||||
clientX: r.left + ((x - v.x) / v.w) * r.width,
|
||||
clientY: r.top + ((y - v.y) / v.h) * r.height,
|
||||
};
|
||||
};
|
||||
const pev = (type, target, x, y) => {
|
||||
const { clientX, clientY } = toScreen(x, y);
|
||||
target.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
pointerId: 77, clientX, clientY, button: 0, isPrimary: true,
|
||||
}));
|
||||
};
|
||||
const handle = [...sr().querySelectorAll('.rszhandle:not(.rszcorner)')].find((h) => {
|
||||
const x = +h.getAttribute('cx'), y = +h.getAttribute('cy');
|
||||
return Math.abs(x - 550) < c._gridPitch * 2 && y > 140 && y < 460;
|
||||
});
|
||||
out.resizeHandleFound = !!handle;
|
||||
if (handle) {
|
||||
const x = +handle.getAttribute('cx');
|
||||
const y = +handle.getAttribute('cy');
|
||||
const step = c._gridPitch;
|
||||
pev('pointerdown', handle, x, y);
|
||||
pev('pointermove', handle, x + step, y);
|
||||
await c.updateComplete;
|
||||
const liveCut = c._openCuts()[0];
|
||||
const liveX = liveCut?.[0];
|
||||
out.liveSpanMoves = Number.isFinite(liveX) && Math.abs(liveX - (550 + step)) < 0.1;
|
||||
out.liveThicknessStays = c._intervalCm([liveX, 145, liveX, 195]) === 20
|
||||
&& c._intervalCm([liveX, 205, liveX, 295]) === 0
|
||||
&& c._intervalCm([liveX, 305, liveX, 455]) === 20;
|
||||
out.liveBodyStays = !!sr().querySelector('.wallbodies');
|
||||
|
||||
pev('pointerup', handle, x + step, y);
|
||||
await upd();
|
||||
const finalCut = c._openCuts()[0];
|
||||
const finalX = finalCut?.[0];
|
||||
out.commitSpanMoves = Number.isFinite(finalX) && Math.abs(finalX - (550 + step)) < 0.1;
|
||||
out.commitThicknessStays = c._intervalCm([finalX, 145, finalX, 195]) === 20
|
||||
&& c._intervalCm([finalX, 205, finalX, 295]) === 0
|
||||
&& c._intervalCm([finalX, 305, finalX, 455]) === 20;
|
||||
out.commitKeepsAtomicKeys = (sp().walls || []).filter((w) => w.cm === 20).length >= 2;
|
||||
|
||||
c._undoGeometry();
|
||||
await upd();
|
||||
out.undoRestoresAll = JSON.stringify({
|
||||
rooms: sp().rooms,
|
||||
walls: sp().walls,
|
||||
open_spans: sp().open_spans,
|
||||
}) === before;
|
||||
}
|
||||
|
||||
// Canonical storage: legacy/transactional fragments on one room pair become
|
||||
// one selectable span, and closing it restores the uniformly thick parent
|
||||
// wall instead of leaving atomic debris in `walls`.
|
||||
const restoredCut = c._openCuts()[0];
|
||||
if (restoredCut) {
|
||||
const mx = (restoredCut[0] + restoredCut[2]) / 2;
|
||||
const my = (restoredCut[1] + restoredCut[3]) / 2;
|
||||
c._persistOpenCuts([
|
||||
[restoredCut[0], restoredCut[1], mx, my],
|
||||
[mx, my, restoredCut[2], restoredCut[3]],
|
||||
]);
|
||||
await upd();
|
||||
out.adjacentSpansPersistAsOne = (sp().open_spans || []).length === 1
|
||||
&& c._openCuts().length === 1;
|
||||
c._closeOpenSpan(c._openCuts()[0]);
|
||||
await upd();
|
||||
out.closeRejoinsUniformWall = !sp().open_spans
|
||||
&& (sp().walls || []).length === 1
|
||||
&& c._intervalCm(restoredCut) === 20;
|
||||
} else {
|
||||
out.adjacentSpansPersistAsOne = false;
|
||||
out.closeRejoinsUniformWall = false;
|
||||
}
|
||||
|
||||
// The screenshot topology: the two real arms belong to different room
|
||||
// contours and only meet at the endpoint of B↔C's virtual boundary. Their
|
||||
// per-room butt caps must be completed into one clean outer mitre.
|
||||
sp().rooms = [
|
||||
{ id: 'ta', name: 'A', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] },
|
||||
{ id: 'tb', name: 'B', poly: [[0.5, 0.5], [0.9, 0.5], [0.9, 0.9], [0.5, 0.9]] },
|
||||
{ id: 'tc', name: 'C', poly: [[0.5, 0.1], [0.9, 0.1], [0.9, 0.5], [0.5, 0.5]] },
|
||||
];
|
||||
delete sp().walls;
|
||||
delete sp().open_spans;
|
||||
c._tool = 'wallthick';
|
||||
await upd();
|
||||
for (const p of [[300, 500], [500, 700]]) {
|
||||
c._wallThickClick(p);
|
||||
await upd();
|
||||
if (c._wallDialog) {
|
||||
c._wallDialog = { ...c._wallDialog, value: '20' };
|
||||
c._wallThickApply(false);
|
||||
await upd();
|
||||
}
|
||||
}
|
||||
sp().open_spans = [{ a: [0.5, 0.5], b: [0.9, 0.5] }];
|
||||
sp().rooms.find((r) => r.id === 'tb').open_to = ['tc'];
|
||||
sp().rooms.find((r) => r.id === 'tc').open_to = ['tb'];
|
||||
await upd();
|
||||
const tPath = sr().querySelector('.wallbody')?.getAttribute('d') || '';
|
||||
const values = (tPath.match(/-?\d+(?:\.\d+)?/g) || []).map(Number);
|
||||
const half = (20 / c._cellCm) * c._gridPitch / 2;
|
||||
out.virtualTJunctionMitred = values.some((x, i) => i % 2 === 0
|
||||
&& Math.abs(x - (500 + half)) < 1e-6
|
||||
&& Math.abs(values[i + 1] - (500 - half)) < 1e-6);
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -9,10 +9,10 @@ const res = await page.evaluate(async () => {
|
||||
'light.ceiling': { entity_id: 'light.ceiling', state: 'on', attributes: { friendly_name: 'Ceiling light', rgb_color: [255, 0, 128] } } } };
|
||||
await c.updateComplete;
|
||||
// v1.52.0 (правило владельца): RGB больше НЕ красит значок — цвет лампы
|
||||
// живёт только в glow-пятне и в фолбэке цвета пульсации
|
||||
// живёт только в glow-пятне и в фолбэке цвета activity-эффекта
|
||||
out.rgbClassGone = !sr().querySelector('.dev.rgb');
|
||||
out.litLampIsYellow = [...sr().querySelectorAll('.dev.on')].length > 0;
|
||||
// фолбэк пульсации сохраняет цвет свечения: маркер icon_ripple на лампе
|
||||
// фолбэк activity сохраняет цвет свечения: маркер icon_ripple на лампе
|
||||
const lampDev = c._devices.find((x) => x.entities.includes('light.ceiling'));
|
||||
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== lampDev.id);
|
||||
c._serverCfg.markers.push({ id: lampDev.id, binding: 'device:' + lampDev.bindingRef, display: 'icon_ripple' });
|
||||
|
||||
@@ -8,7 +8,8 @@ const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg))
|
||||
const restore = () => page.evaluate((s) => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = JSON.parse(s);
|
||||
c._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszUndo = []; c._rszPreview = null;
|
||||
c._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszPreview = null;
|
||||
c._geometryHistory.clear();
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, snap);
|
||||
|
||||
@@ -43,8 +43,8 @@ const res = await page.evaluate(async () => {
|
||||
// 5) создание новой комнаты: в диалоге есть секция настроек
|
||||
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
|
||||
c._resetRoomDialogFields(); c._roomDialog = true; c.requestUpdate(); await c.updateComplete;
|
||||
out.createHasSection = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('room.settings_section'));
|
||||
out.createHasInherit = [...sr().querySelectorAll('.dialog .srcrow')].some((l) => l.textContent.trim() === c._t('fill.inherit'));
|
||||
out.createHasSection = [...sr().querySelectorAll('hp-dialog label')].some((l) => l.textContent === c._t('room.settings_section'));
|
||||
out.createHasInherit = [...sr().querySelectorAll('hp-dialog .srcrow')].some((l) => l.textContent.trim() === c._t('fill.inherit'));
|
||||
c._roomDialogCancel(); await c.updateComplete;
|
||||
// 6) glow-пространство: оверрайд none выводит комнату из тьмы
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
|
||||
|
||||
@@ -35,7 +35,7 @@ const res = await page.evaluate(async () => {
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
|
||||
await c.updateComplete;
|
||||
out.saveBlockedWithoutPlan = !!sr().querySelector('.dialog .btn.on[disabled]');
|
||||
out.saveBlockedWithoutPlan = !!sr().querySelector('hp-dialog .btn.on[disabled]');
|
||||
|
||||
// открываем список сохранённых
|
||||
await c._toggleServerPlans();
|
||||
@@ -54,7 +54,7 @@ const res = await page.evaluate(async () => {
|
||||
await c.updateComplete;
|
||||
out.picked = c._spaceDialog.planUrl === '/api/houseplan/content/plans/_/f1.aaa.png';
|
||||
out.listClosed = !c._spaceDialog.pickSaved;
|
||||
out.saveEnabledAfterPick = !sr().querySelector('.dialog .btn.on[disabled]');
|
||||
out.saveEnabledAfterPick = !sr().querySelector('hp-dialog .btn.on[disabled]');
|
||||
|
||||
// выбранный в этом же диалоге удалить нельзя: сохранение записало бы ссылку
|
||||
// на несуществующий файл (HP-1470-02)
|
||||
|
||||
@@ -58,4 +58,33 @@ const out = await page.evaluate(async () => {
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out));
|
||||
checkAll(out);
|
||||
|
||||
// Шаг угла в диалоге устройства — 5°, а не 10° (владелец, 2026-08-03):
|
||||
// значок часто надо выровнять по стене, которая не лежит на сетке в 10°.
|
||||
const out3 = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => d.space === 'f1');
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
// строка «размер · угол»: два ползунка, второй — угол
|
||||
const rows = [...sr().querySelectorAll('hp-dialog .colorrow')];
|
||||
const row = rows.find((r) => r.textContent.includes('°'));
|
||||
const ctl = [...row.querySelectorAll('ha-slider, input[type=range]')];
|
||||
const stepOf = (el) => Number(el.step ?? el.getAttribute('step'));
|
||||
const maxOf = (el) => Number(el.max ?? el.getAttribute('max'));
|
||||
o.angleStepIs5 = ctl.length === 2 && stepOf(ctl[1]) === 5;
|
||||
o.angleReaches355 = maxOf(ctl[1]) === 355;
|
||||
o.sizeStepUnchanged = stepOf(ctl[0]) === 0.1;
|
||||
// и значение реально ложится на 5°
|
||||
ctl[1].value = 35;
|
||||
ctl[1].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
await c.updateComplete;
|
||||
o.angle35Applied = c._markerDialog.angle === 35;
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
return o;
|
||||
});
|
||||
await finish(browser, checkAll(out3));
|
||||
|
||||
@@ -39,12 +39,13 @@ const res = await page.evaluate(async () => {
|
||||
c._openSpaceDialog('create'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', source: 'draw', orientation: 'square' };
|
||||
await c.updateComplete;
|
||||
out.saveEnabled = !sr().querySelector('.dialog .btn.on[disabled]');
|
||||
out.saveEnabled = !sr().querySelector('hp-dialog .btn.on[disabled]');
|
||||
await c._saveSpaceDialog(); await c.updateComplete;
|
||||
const attic = c._serverCfg.spaces.find((s) => s.title === 'Attic');
|
||||
out.atticSquare = attic?.aspect === undefined; // no per-space ratio any more
|
||||
out.atticSettings = attic?.settings;
|
||||
out.atticNoPlan = attic ? attic.plan_url === null : null;
|
||||
out.atticOpensPlanEditor = c._space === attic?.id && c._mode === 'plan' && c._tool === 'draw';
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
@@ -58,6 +59,6 @@ checkAll(res, {
|
||||
"atticSquare": true,
|
||||
// fill_mode 'glow' — the default for NEW spaces since v1.54 (owner call).
|
||||
// Existing spaces with an absent fill_mode still resolve to 'none'.
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "glow", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#55606c", "room_opacity": 0.55, "fill_mode": "glow", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// Хуки для card-mod (docs/STYLING-HOOKS.md): на каждом значимом объекте плана
|
||||
// есть стабильные data-атрибуты, и по документированным селекторам элементы
|
||||
// НАХОДЯТСЯ через querySelector — ровно так, как их будет искать чужой CSS.
|
||||
// 1) устройство: data-hp="device" + data-id + data-entity + data-area;
|
||||
// 2) комната: data-hp="room" + data-id + data-area;
|
||||
// 3) подпись комнаты: data-hp="room-label" + data-id (и в SVG, и в HTML-карточке);
|
||||
// 4) проём: data-hp="opening" + data-id + data-kind (door/window);
|
||||
// 5) декор: data-hp="decor" + data-id + data-kind (line/rect/ellipse/text);
|
||||
// 6) вкладка пространства: data-hp="space-tab" + data-id;
|
||||
// 7) «нет значения — нет атрибута»: у виртуального маркера нет data-entity,
|
||||
// у комнаты без area нет data-area (никаких "undefined" в DOM);
|
||||
// 8) те же хуки в статичной карточке houseplan-space-card.
|
||||
// ПАДАЕТ на сборке до этих правок: data-hp нет вообще, а data-id есть только
|
||||
// у текстовой надписи декора.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const q = (sel) => sr().querySelector(sel);
|
||||
const qa = (sel) => [...sr().querySelectorAll(sel)];
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ============ 1. устройства ============
|
||||
const devs = qa('[data-hp="device"]');
|
||||
out.deviceHookFindsEveryMarker = devs.length > 0 && devs.length === qa('.dev').length;
|
||||
out.deviceKeepsItsClass = devs.every((e) => e.classList.contains('dev'));
|
||||
out.deviceHasId = devs.every((e) => !!e.getAttribute('data-id'));
|
||||
// id — тот же, что у устройства в модели карточки, а не порядковый номер в DOM
|
||||
const modelIds = new Set(c._devices.filter((d) => d.space === c._space && !d.hidden).map((d) => d.id));
|
||||
out.deviceIdIsTheConfigId = devs.every((e) => modelIds.has(e.getAttribute('data-id')));
|
||||
// селектор из документации находит конкретное устройство по СУЩНОСТИ
|
||||
const lock = q('[data-hp="device"][data-entity="lock.front_door"]');
|
||||
out.deviceFoundByEntity = !!lock;
|
||||
out.deviceEntityMatchesPrimary = !!lock
|
||||
&& c._devices.find((d) => d.id === lock.getAttribute('data-id'))?.primary === 'lock.front_door';
|
||||
out.deviceHasArea = lock?.getAttribute('data-area') === 'hallway';
|
||||
// и по ЗОНЕ: все маркеры кухни разом
|
||||
out.devicesFoundByArea = qa('[data-hp="device"][data-area="kitchen"]').length > 0;
|
||||
out.noUndefinedAnywhere = !sr().innerHTML.includes('="undefined"');
|
||||
|
||||
// ============ 2. комнаты ============
|
||||
const rooms = qa('[data-hp="room"]');
|
||||
out.roomHookFindsEveryRoom = rooms.length === c._curSpaceCfg.rooms.length;
|
||||
out.roomKeepsItsClass = rooms.every((e) => e.classList.contains('room'));
|
||||
const kitchen = q('[data-hp="room"][data-area="kitchen"]');
|
||||
out.roomFoundByArea = !!kitchen && kitchen.getAttribute('data-id') === 'r2';
|
||||
out.roomFoundById = !!q('[data-hp="room"][data-id="r3"]');
|
||||
out.roomIsAnSvgShape = !!kitchen && ['polygon', 'rect', 'path'].includes(kitchen.tagName.toLowerCase());
|
||||
|
||||
// ============ 3. подписи комнат ============
|
||||
// одно имя — две разные реализации: <text> в SVG на плане без подложки и
|
||||
// HTML-карточка, когда включены «имена на плане». Хук на обеих один и тот же.
|
||||
const disp = c._curSpaceCfg.settings || (c._curSpaceCfg.settings = {});
|
||||
const wasNames = disp.show_names;
|
||||
const planUrl = c._curSpaceCfg.plan_url;
|
||||
const redraw = async () => {
|
||||
c._cfgEpoch++; c._regSignature = ''; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
};
|
||||
delete c._curSpaceCfg.plan_url; disp.show_names = false; await redraw();
|
||||
const svgLabels = qa('text.rlabel[data-hp="room-label"]');
|
||||
out.svgRoomLabelHooked = svgLabels.length > 0
|
||||
&& svgLabels.every((e) => !!e.getAttribute('data-id'));
|
||||
out.svgRoomLabelFoundById = !!q('text[data-hp="room-label"][data-id="r1"]');
|
||||
out.svgRoomLabelHasArea = q('text[data-hp="room-label"][data-id="r1"]')?.getAttribute('data-area') === 'living_room';
|
||||
c._curSpaceCfg.plan_url = planUrl; disp.show_names = true; await redraw();
|
||||
const htmlLabel = q('div.roomlabel[data-hp="room-label"][data-id="r2"]');
|
||||
out.htmlRoomLabelHooked = !!htmlLabel;
|
||||
out.htmlRoomLabelHasArea = htmlLabel?.getAttribute('data-area') === 'kitchen';
|
||||
disp.show_names = wasNames; await redraw();
|
||||
|
||||
// ============ 4. проёмы ============
|
||||
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 },
|
||||
];
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
out.openingHookFindsBoth = qa('[data-hp="opening"]').length === 2;
|
||||
const door = q('[data-hp="opening"][data-kind="door"]');
|
||||
const win = q('[data-hp="opening"][data-kind="window"]');
|
||||
out.openingKindDoor = door?.getAttribute('data-id') === 'op_d';
|
||||
out.openingKindWindow = win?.getAttribute('data-id') === 'op_w';
|
||||
out.openingKeepsItsClass = !!door && door.classList.contains('opening');
|
||||
|
||||
// ============ 5. декор ============
|
||||
c._curSpaceCfg.decor = [
|
||||
{ id: 'dc_l', kind: 'line', x1: 0.1, y1: 0.9, x2: 0.4, y2: 0.9, color: '#123456', width: 3 },
|
||||
{ id: 'dc_r', kind: 'rect', x: 0.1, y: 0.2, w: 0.1, h: 0.08, color: '#123456', width: 2 },
|
||||
{ id: 'dc_e', kind: 'ellipse', x: 0.3, y: 0.2, w: 0.1, h: 0.08, color: '#123456', width: 2 },
|
||||
{ id: 'dc_t', kind: 'text', x: 0.5, y: 0.92, text: 'Подпись', color: '#123456' },
|
||||
];
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
out.decorHookFindsEveryShape = qa('[data-hp="decor"]').length === 4;
|
||||
out.decorKindLine = q('[data-hp="decor"][data-kind="line"]')?.getAttribute('data-id') === 'dc_l';
|
||||
out.decorKindRect = q('[data-hp="decor"][data-kind="rect"]')?.getAttribute('data-id') === 'dc_r';
|
||||
out.decorKindEllipse = q('[data-hp="decor"][data-kind="ellipse"]')?.getAttribute('data-id') === 'dc_e';
|
||||
out.decorKindText = q('[data-hp="decor"][data-kind="text"]')?.getAttribute('data-id') === 'dc_t';
|
||||
out.decorKeepsItsClasses = qa('[data-hp="decor"]').length === 4
|
||||
&& qa('[data-hp="decor"]').every((e) => e.classList.contains('dshape'))
|
||||
&& !!q('[data-hp="decor"][data-kind="text"]')?.classList.contains('dtext');
|
||||
// старый селектор текстовой надписи (его уже использует smoke_decor_text) цел
|
||||
out.legacyDtextSelectorStillWorks = !!q('text.dtext[data-id="dc_t"]');
|
||||
|
||||
// ============ 6. вкладки пространств ============
|
||||
const tabs = qa('[data-hp="space-tab"]');
|
||||
out.spaceTabHookFindsEveryFloor = tabs.length === c._model.length;
|
||||
out.spaceTabHasId = tabs.map((e) => e.getAttribute('data-id')).join(',') === c._model.map((s) => s.id).join(',');
|
||||
out.spaceTabKeepsItsClass = tabs.every((e) => e.classList.contains('tab'));
|
||||
// кнопка «+» вкладкой пространства не является
|
||||
out.addButtonIsNotASpaceTab = !q('.tabadd[data-hp]');
|
||||
|
||||
// ============ 7. «нет значения — нет атрибута» ============
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
{ id: 'm_virt', binding: 'virtual', space: c._space, name: 'Вентиль' },
|
||||
] };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const virt = q('[data-hp="device"][data-id="m_virt"]');
|
||||
out.virtualMarkerRendered = !!virt;
|
||||
out.virtualHasNoEntityAttr = !!virt && !virt.hasAttribute('data-entity');
|
||||
out.virtualHasNoUndefinedText = !!virt && virt.getAttribute('data-entity') === null;
|
||||
// комната без HA-области (подкомната): data-hp есть, data-area нет.
|
||||
// Она рисуется только когда включены границы — это её обычное условие показа.
|
||||
c._curSpaceCfg.rooms = [...c._curSpaceCfg.rooms,
|
||||
{ id: 'r_sub', name: 'Кладовка', area: null, poly: [[0.06, 0.6], [0.2, 0.6], [0.2, 0.7], [0.06, 0.7]] }];
|
||||
disp.show_borders = true;
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
const sub = q('[data-hp="room"][data-id="r_sub"]');
|
||||
out.roomWithoutAreaRendered = !!sub;
|
||||
out.roomWithoutAreaHasNoAreaAttr = !!sub && !sub.hasAttribute('data-area');
|
||||
out.stillNoUndefinedAnywhere = !sr().innerHTML.includes('="undefined"');
|
||||
|
||||
// ============ 8. ограничение shadow DOM (docs/STYLING-HOOKS.md §5) =========
|
||||
// хост ha-icon доступен селектору, его внутренности — нет: именно поэтому
|
||||
// хуки висят на НАШИХ обёртках
|
||||
const anyIcon = q('[data-hp="device"] ha-icon');
|
||||
out.iconHostReachable = !!anyIcon;
|
||||
out.iconInternalsAreBehindItsOwnRoot = !!anyIcon && !!anyIcon.shadowRoot
|
||||
&& !sr().querySelector('[data-hp="device"] ha-icon svg');
|
||||
return out;
|
||||
});
|
||||
checkAll(res, { iconInternalsAreBehindItsOwnRoot: !!res.iconInternalsAreBehindItsOwnRoot });
|
||||
|
||||
// ============ 9. те же хуки в статичной карточке ============
|
||||
const stat = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const el = document.createElement('houseplan-space-card');
|
||||
el.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
// своя копия конфига с включёнными именами: у статичной карточки подпись
|
||||
// комнаты — это HTML-плашка, и она должна нести тот же хук
|
||||
const base = window.__card.hass;
|
||||
const cfg = JSON.parse(JSON.stringify(window.__card._serverCfg));
|
||||
cfg.spaces[0].settings = { ...(cfg.spaces[0].settings || {}), show_names: true };
|
||||
el.hass = { ...base, callWS: async (m) => (m.type === 'houseplan/config/get'
|
||||
? { config: cfg, rev: 9 } : base.callWS(m)) };
|
||||
host.appendChild(el);
|
||||
const t0 = Date.now();
|
||||
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
}
|
||||
await el.updateComplete;
|
||||
const rr = el.renderRoot;
|
||||
out.staticRoomsHooked = rr.querySelectorAll('[data-hp="room"]').length > 0;
|
||||
out.staticRoomFoundByArea = !!rr.querySelector('[data-hp="room"][data-area="kitchen"]');
|
||||
out.staticRoomFoundById = !!rr.querySelector('[data-hp="room"][data-id="r1"]');
|
||||
out.staticDevicesHooked = rr.querySelectorAll('[data-hp="device"]').length > 0;
|
||||
out.staticDeviceFoundByEntity = !!rr.querySelector('[data-hp="device"][data-entity="lock.front_door"]');
|
||||
out.staticNoUndefined = !rr.innerHTML.includes('="undefined"');
|
||||
out.staticRoomLabelHooked = rr.querySelectorAll('div.roomlabel[data-hp="room-label"]').length > 0;
|
||||
out.staticRoomLabelFoundByArea = !!rr.querySelector('[data-hp="room-label"][data-area="bedroom"]');
|
||||
// а вот проёмов и декора она не рисует вовсе (§5) — и это не регрессия
|
||||
out.staticDrawsNoOpenings = rr.querySelectorAll('[data-hp="opening"]').length === 0;
|
||||
out.staticDrawsNoDecor = rr.querySelectorAll('[data-hp="decor"]').length === 0;
|
||||
return out;
|
||||
});
|
||||
checkAll(stat);
|
||||
await finish(browser, { ...res, ...stat });
|
||||
+57
-4
@@ -133,10 +133,10 @@ const res = await page.evaluate(async () => {
|
||||
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.18 * 0.4) < 1e-6;
|
||||
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.18) < 1e-6;
|
||||
out.clearFullAlpha = Math.abs(stopClear - 0.3) < 1e-6;
|
||||
delete cfg().settings.weather_entity;
|
||||
touchCfg();
|
||||
|
||||
@@ -183,8 +183,8 @@ const dlg = await page.evaluate(() => {
|
||||
const sr = c.shadowRoot;
|
||||
const out = {
|
||||
compassDragSets180: c._settingsDialog?.northDeg === 180,
|
||||
dialogHasModeSelect: !!sr.querySelector('.dialog select.areasel'),
|
||||
dialogHasRaysToggle: [...sr.querySelectorAll('.dialog .srcrow input[type=checkbox]')].length > 0,
|
||||
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,
|
||||
dialogNumberMatches: sr.querySelector('.suncol input[type=number]')?.value === '180',
|
||||
};
|
||||
@@ -290,4 +290,57 @@ const final = await rayInfo();
|
||||
check('b701_write_landed', final.rev !== base.rev, true);
|
||||
check('b701_ray_survives_ack', Math.abs(final.midY - final.wEy * 1000) < 1, true);
|
||||
|
||||
// 15) the 3° threshold + the 2 s dissolve (owner 2026-08-03). Runs LAST and in
|
||||
// its own evaluate: it deliberately waits out a two-second fade, and the b701
|
||||
// regression above measures a debounced config write against wall-clock time.
|
||||
const thr = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
const setSun = async (azimuth, elevation) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth, elevation },
|
||||
} } };
|
||||
await upd();
|
||||
};
|
||||
const domPolys = () => [...sr().querySelectorAll('.sunlayer polygon')];
|
||||
const layer = () => sr().querySelector('.sunlayer');
|
||||
const stopAlpha = () => Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
|
||||
|
||||
// the old build ramped the alpha in over the first ~2°; now the layer is
|
||||
// either absent or at FULL strength, and crossing 3° fades the whole LAYER
|
||||
// (never the geometry) in or out over exactly two seconds
|
||||
await setSun(90, 10);
|
||||
out.aboveThresholdDrawn = domPolys().length > 0 && !layer().classList.contains('out');
|
||||
{
|
||||
const cs = getComputedStyle(layer());
|
||||
out.fadeInTakesTwoSeconds = cs.animationName === 'hp-sunfade-in' && cs.animationDuration === '2s';
|
||||
}
|
||||
out.fullAlphaWellAboveThreshold = Math.abs(stopAlpha() - 0.3) < 1e-6;
|
||||
await setSun(90, 3.2);
|
||||
out.fullAlphaJustAboveThreshold = Math.abs(stopAlpha() - 0.3) < 1e-6;
|
||||
|
||||
// below 3°: the layer stays for exactly the dissolve, then leaves the DOM
|
||||
await setSun(90, 2);
|
||||
{
|
||||
const dying = layer();
|
||||
const cs = dying && getComputedStyle(dying);
|
||||
out.belowThresholdDissolves = !!dying && dying.classList.contains('out')
|
||||
&& cs.animationName === 'hp-sunfade-out' && cs.animationDuration === '2s';
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2300));
|
||||
await upd();
|
||||
out.belowThresholdGoneAfterFade = !layer() && domPolys().length === 0;
|
||||
|
||||
// a sun that never crossed the threshold draws nothing at all — no ghost
|
||||
await setSun(90, 1);
|
||||
out.coldStartBelowThresholdEmpty = domPolys().length === 0 && !layer();
|
||||
await setSun(90, 5);
|
||||
out.backAboveThreshold = domPolys().length > 0 && !layer().classList.contains('out');
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, thr);
|
||||
|
||||
await finish(browser, checkAll(res));
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Owner 2026-08-04: «цвет фона не меняется сам с течением времени суток, только
|
||||
// после обновления страницы». The day/night sky must follow sun.sun on a plain
|
||||
// hass tick — no reload, no requestUpdate() from the outside — and the COMPUTED
|
||||
// background of the stage (what the eye sees, not the style attribute) must
|
||||
// actually arrive at the sky colour (docs/SUN.md).
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const cfg = c._serverCfg;
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'daynight', sun_rays: true };
|
||||
cfg.spaces.find((s) => s.id === 'f1').openings = [
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
];
|
||||
c._cfgEpoch++;
|
||||
// NO requestUpdate anywhere below: production only ever assigns `hass`.
|
||||
const setSun = async (az, el) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth: az, elevation: el },
|
||||
} } };
|
||||
await c.updateComplete;
|
||||
// one paint so the sky lands on screen, not only in the attribute
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
};
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
const comp = () => getComputedStyle(stage()).backgroundColor;
|
||||
const dim = () => (sr().querySelector('.zoomwrap').getAttribute('style') || '');
|
||||
const polys = () => sr().querySelectorAll('.sunlayer polygon').length;
|
||||
const hexToRgb = (h) => 'rgb(' + [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(', ') + ')';
|
||||
|
||||
await setSun(180, 40);
|
||||
out.dayComputedWhite = comp() === 'rgb(255, 255, 255)';
|
||||
out.dayNoDim = dim().includes('brightness(1.000)');
|
||||
out.dayRays = polys() > 0;
|
||||
|
||||
// the whole point: a NEW hass, nothing else. No reload, no requestUpdate.
|
||||
await setSun(180, -10);
|
||||
out.nightComputedDark = comp() === 'rgb(10, 16, 25)';
|
||||
out.nightDimmed = dim().includes('brightness(0.900');
|
||||
out.nightNoRays = polys() === 0;
|
||||
|
||||
// ...and back, again on a plain tick
|
||||
await setSun(180, 40);
|
||||
out.backToDayComputed = comp() === 'rgb(255, 255, 255)';
|
||||
out.backToDayRays = polys() > 0;
|
||||
|
||||
// a small step still moves the sky (0.1° granularity of the model)...
|
||||
await setSun(180, 20);
|
||||
const twenty = comp();
|
||||
await setSun(180, 12);
|
||||
out.smallStepMovesSky = comp() !== twenty;
|
||||
|
||||
// ...but a step the size of a REAL sun update (~1° per 4 minutes) must still
|
||||
// GLIDE: the catch-up jump is for a card that was not watching, never for the
|
||||
// day/night breathing itself (docs/SUN.md).
|
||||
await setSun(180, 12.6);
|
||||
const target = stage().getAttribute('style').split('background:')[1];
|
||||
out.realStepNoSnapClass = !sr().querySelector('.stage.skysnap');
|
||||
out.realStepStillGlides = comp() !== hexToRgb(target); // 45 s transition, not a jump
|
||||
|
||||
// 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]);
|
||||
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -0,0 +1,243 @@
|
||||
// THE RIM (owner 2026-08-04): «тонкая (1px) чёрная граница по бокам
|
||||
// светящегося сектора, которая также плавно уходит в ноль вместе с самим
|
||||
// градиентом». The owner rejected the «shade instead of light» model of
|
||||
// legacy/docs/SUN-CONTRAST.md and asked for this one line instead — because a wedge
|
||||
// of added luminance simply cannot read on white paper, while its BOUNDARY
|
||||
// can (docs/SUN.md, «The rim»).
|
||||
//
|
||||
// Everything this smoke pins down:
|
||||
// * exactly TWO rim lines per wedge, on the two SIDE edges — the ones that
|
||||
// run from the ends of the glass along the ray. Never the glass edge,
|
||||
// never the far edge; the coordinates are checked against the wedge's own
|
||||
// vertices, so a rim drawn around the whole polygon fails here;
|
||||
// * the stroke is `url(#hp-sunrim-N)`, a gradient with BLACK stops on the
|
||||
// SAME axis and the SAME offsets as the fill's `hp-sun-N`, monotone,
|
||||
// dead from 85 % on — the rim cannot outlive the light it outlines;
|
||||
// * `vector-effect: non-scaling-stroke` at width 1 — one screen pixel at
|
||||
// any zoom;
|
||||
// * no rim below the 3° threshold (after the 2 s layer fade) and none in an
|
||||
// editor;
|
||||
// * and the point of the whole change: on WHITE paper the pixels along the
|
||||
// wedge's side are measurably darker than the paper on either hand.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' }); // no 2 s layer fade in the way
|
||||
|
||||
const RAY_FADE_MS = 2000;
|
||||
|
||||
const res = await page.evaluate(async (fadeMs) => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const cfg = c._serverCfg;
|
||||
const sp = cfg.spaces.find((s) => s.id === 'f1');
|
||||
// r1 is [[0.04,0.14],[0.55,0.14],[0.55,0.58],[0.04,0.58]] — a west window
|
||||
// at y = 0.30 casts a wedge that stays well inside it, so the unclipped
|
||||
// side edges are the whole story; the second window is deliberately on a
|
||||
// wall the same sun cannot reach, to prove no rim appears without a wedge.
|
||||
sp.openings = [
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.plan_url = null; // plain white paper under the wedge
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'static', sun_rays: true };
|
||||
c._cfgEpoch++;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
const setSun = async (az, el) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: az, elevation: el } } } };
|
||||
await upd();
|
||||
};
|
||||
const rimsOf = () => [...sr().querySelectorAll('.sunlayer line.sunrim')];
|
||||
const gradOf = (id) => sr().querySelector('linearGradient[id="' + id + '"]');
|
||||
const stopsOf = (g) => [...g.querySelectorAll('stop')].map((s) => ({
|
||||
off: parseFloat(s.getAttribute('offset')),
|
||||
color: s.getAttribute('stop-color'),
|
||||
a: Number(s.getAttribute('stop-opacity')),
|
||||
}));
|
||||
const near = (a, b, eps = 1e-4) => Math.abs(a - b) < eps;
|
||||
|
||||
await setSun(270, 60); // a western sun, square into the west window
|
||||
const rays = c._sunRaysCache.rays;
|
||||
out.oneWedgeDrawn = rays.length === 1;
|
||||
out.wedgeIsUnclipped = rays.length === 1 && rays[0].polys.length === 1
|
||||
&& rays[0].polys[0].length === 4;
|
||||
|
||||
// ---- 1) exactly two rim lines, on the two SIDE edges -------------------
|
||||
const rims = rimsOf();
|
||||
out.rimDrawn = rims.length > 0;
|
||||
out.twoRimsPerWedge = rims.length === rays.length * 2;
|
||||
out.rimStrokeIsAGradient = rims.length > 0
|
||||
&& rims.every((l) => /^url\(#hp-sunrim-\d+\)$/.test(l.getAttribute('stroke') || ''));
|
||||
out.rimIsOnePixelAtAnyZoom = rims.length > 0 && rims.every((l) =>
|
||||
l.getAttribute('vector-effect') === 'non-scaling-stroke'
|
||||
&& Number(l.getAttribute('stroke-width')) === 1);
|
||||
|
||||
// every rim line must BE a side edge: its two ends are vertices of the
|
||||
// wedge polygon, it runs along `dir`, and it starts at an end of the glass
|
||||
out.rimSidesMatchTheWedge = true;
|
||||
out.rimIsNeverTheGlassOrTheFarEdge = true;
|
||||
for (let i = 0; i < rays.length; i++) {
|
||||
const r = rays[i];
|
||||
const mine = rims.filter((l) => l.getAttribute('stroke') === `url(#hp-sunrim-${i})`);
|
||||
if (mine.length !== 2) { out.rimSidesMatchTheWedge = false; continue; }
|
||||
const verts = r.polys.flat();
|
||||
const isVertex = (p) => verts.some((v) => near(v[0], p[0], 1e-3) && near(v[1], p[1], 1e-3));
|
||||
const ends = mine.map((l) => [
|
||||
[+l.getAttribute('x1'), +l.getAttribute('y1')],
|
||||
[+l.getAttribute('x2'), +l.getAttribute('y2')],
|
||||
]);
|
||||
// the four expected corners: the glass ends and where their rays stop
|
||||
const far = (s) => [s[0] + r.dir[0] * r.len, s[1] + r.dir[1] * r.len];
|
||||
const want = [[r.a, far(r.a)], [r.b, far(r.b)]];
|
||||
for (const [src, tip] of want) {
|
||||
const hit = ends.some(([p, q]) =>
|
||||
(near(p[0], src[0], 1e-3) && near(p[1], src[1], 1e-3)
|
||||
&& near(q[0], tip[0], 1e-3) && near(q[1], tip[1], 1e-3))
|
||||
|| (near(q[0], src[0], 1e-3) && near(q[1], src[1], 1e-3)
|
||||
&& near(p[0], tip[0], 1e-3) && near(p[1], tip[1], 1e-3)));
|
||||
if (!hit) out.rimSidesMatchTheWedge = false;
|
||||
}
|
||||
for (const [p, q] of ends) {
|
||||
if (!isVertex(p) || !isVertex(q)) out.rimSidesMatchTheWedge = false;
|
||||
// parallel to the ray (the glass edge and the far edge are not)
|
||||
const dx = q[0] - p[0], dy = q[1] - p[1];
|
||||
const L = Math.hypot(dx, dy) || 1;
|
||||
if (Math.abs((dx / L) * r.dir[1] - (dy / L) * r.dir[0]) > 1e-6)
|
||||
out.rimIsNeverTheGlassOrTheFarEdge = false;
|
||||
// and it must not be the glass: both ends on the a-b line would mean it
|
||||
const onGlass = (z) => Math.abs((z[0] - r.a[0]) * r.dir[0] + (z[1] - r.a[1]) * r.dir[1]) < 1e-6;
|
||||
if (onGlass(p) && onGlass(q)) out.rimIsNeverTheGlassOrTheFarEdge = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 2) the rim gradient: same axis, same curve, black ------------------
|
||||
const fills = [...sr().querySelectorAll('linearGradient[id^=hp-sun-]')];
|
||||
out.rimGradientBlack = true;
|
||||
out.rimGradientSharesTheAxis = true;
|
||||
out.rimGradientSharesTheCurve = true;
|
||||
out.rimGradientMonotone = true;
|
||||
out.rimGradientDeadAt85 = true;
|
||||
out.rimGradientBrightAtTheGlass = true;
|
||||
for (let i = 0; i < rays.length; i++) {
|
||||
const rim = gradOf(`hp-sunrim-${i}`);
|
||||
const fill = fills[i];
|
||||
if (!rim || !fill) { out.rimGradientBlack = false; continue; }
|
||||
for (const k of ['x1', 'y1', 'x2', 'y2']) {
|
||||
if (!near(+rim.getAttribute(k), +fill.getAttribute(k), 1e-9)) out.rimGradientSharesTheAxis = false;
|
||||
}
|
||||
const rs = stopsOf(rim);
|
||||
const fs = stopsOf(fill);
|
||||
if (rs.length !== fs.length) out.rimGradientSharesTheCurve = false;
|
||||
const peak = rs[0].a;
|
||||
rs.forEach((s, j) => {
|
||||
const black = /^#0{3,8}$/i.test(s.color || '') || /rgb\(0,\s*0,\s*0\)/.test(s.color || '');
|
||||
if (!black) out.rimGradientBlack = false;
|
||||
if (!fs[j] || !near(s.off, fs[j].off, 1e-9)) out.rimGradientSharesTheCurve = false;
|
||||
// the same normalised curve as the fill, only scaled to its own peak
|
||||
if (fs[j] && !near(s.a / (peak || 1), fs[j].a / (fs[0].a || 1), 1e-3))
|
||||
out.rimGradientSharesTheCurve = false;
|
||||
if (j > 0 && s.a > rs[j - 1].a + 1e-9) out.rimGradientMonotone = false;
|
||||
if (s.off >= 85 - 1e-9 && s.a !== 0) out.rimGradientDeadAt85 = false;
|
||||
if (s.off < 85 - 1e-9 && !(s.a > 0)) out.rimGradientDeadAt85 = false;
|
||||
});
|
||||
if (!(peak >= 0.3 && peak <= 0.55)) out.rimGradientBrightAtTheGlass = false;
|
||||
}
|
||||
|
||||
// ---- 3) screen coords for the pixel probe, off the real geometry --------
|
||||
// sample ACROSS the a-side edge at 22 % of the reach, where both the fill
|
||||
// and the rim are still strong
|
||||
const svgEl = sr().querySelector('.sunlayer')?.ownerSVGElement || sr().querySelector('.stage svg');
|
||||
const ctm = svgEl.getScreenCTM();
|
||||
const toScr = (x, y) => {
|
||||
const q = svgEl.createSVGPoint(); q.x = x; q.y = y;
|
||||
const p = q.matrixTransform(ctm); return [p.x, p.y];
|
||||
};
|
||||
if (rays.length) {
|
||||
const r = rays[0];
|
||||
const u = 0.22 * r.len;
|
||||
const on = toScr(r.a[0] + r.dir[0] * u, r.a[1] + r.dir[1] * u);
|
||||
// one render unit INTO the wedge (toward b) → the screen-space normal
|
||||
const inw = [r.b[0] - r.a[0], r.b[1] - r.a[1]];
|
||||
const iL = Math.hypot(inw[0], inw[1]) || 1;
|
||||
const q = toScr(r.a[0] + r.dir[0] * u + (inw[0] / iL) * 10, r.a[1] + r.dir[1] * u + (inw[1] / iL) * 10);
|
||||
const vx = q[0] - on[0], vy = q[1] - on[1];
|
||||
const vL = Math.hypot(vx, vy) || 1;
|
||||
out.probe = { on, perp: [vx / vL, vy / vL], dpr: window.devicePixelRatio };
|
||||
}
|
||||
|
||||
// ---- 4) below the threshold and in an editor there is no rim -----------
|
||||
await setSun(270, 2); // under RAY_ELEVATION_MIN
|
||||
await new Promise((rs) => setTimeout(rs, fadeMs + 300)); // let the layer leave
|
||||
await upd();
|
||||
out.noRimBelowThreshold = rimsOf().length === 0;
|
||||
out.noWedgeBelowThreshold = sr().querySelectorAll('.sunlayer polygon').length === 0;
|
||||
|
||||
await setSun(270, 60);
|
||||
out.rimIsBackAboveThreshold = rimsOf().length === 2;
|
||||
c._mode = 'plan';
|
||||
await upd();
|
||||
out.noRimInTheEditor = rimsOf().length === 0;
|
||||
c._mode = 'view';
|
||||
await upd();
|
||||
out.rimIsBackInViewMode = rimsOf().length === 2;
|
||||
|
||||
// ---- 5) a fully overcast sky takes the rim with the light --------------
|
||||
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;
|
||||
delete cfg.settings.weather_entity;
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
out.rimIsBackInTheSun = rimsOf().length === 2;
|
||||
return out;
|
||||
}, RAY_FADE_MS);
|
||||
|
||||
// ---- 6) the whole point: on WHITE paper the side of the wedge is DARK ----
|
||||
const probe = res.probe;
|
||||
delete res.probe;
|
||||
if (probe) {
|
||||
const shot = (await page.screenshot()).toString('base64');
|
||||
const px = await page.evaluate(async ({ shot, probe }) => {
|
||||
const img = new Image();
|
||||
img.src = 'data:image/png;base64,' + shot;
|
||||
await img.decode();
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = img.width; cv.height = img.height;
|
||||
const ctx = cv.getContext('2d', { willReadFrequently: true });
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const d = probe.dpr || 1;
|
||||
const lum = (t) => {
|
||||
const x = Math.round((probe.on[0] + probe.perp[0] * t) * d);
|
||||
const y = Math.round((probe.on[1] + probe.perp[1] * t) * d);
|
||||
const [r, g, b] = ctx.getImageData(x, y, 1, 1).data;
|
||||
return 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
};
|
||||
// t > 0 goes INTO the wedge, t < 0 onto the untouched paper beside it
|
||||
const band = [];
|
||||
for (let t = -2.5; t <= 2.5; t += 0.25) band.push(lum(t));
|
||||
const darkest = Math.min(...band);
|
||||
const inside = Math.min(lum(7), lum(9), lum(11)); // lit floor, still bright
|
||||
const outside = Math.min(lum(-7), lum(-9), lum(-11)); // plain white paper
|
||||
return {
|
||||
paperIsLight: outside > 200,
|
||||
wedgeInteriorIsLight: inside > 200,
|
||||
rimIsDarkOnTheEdge: darkest < Math.min(inside, outside) - 25,
|
||||
rimProfile: [Math.round(darkest), Math.round(inside), Math.round(outside)],
|
||||
};
|
||||
}, { shot, probe });
|
||||
res.rimProfile = px.rimProfile;
|
||||
delete px.rimProfile;
|
||||
Object.assign(res, px);
|
||||
} else {
|
||||
res.paperIsLight = false;
|
||||
res.wedgeInteriorIsLight = false;
|
||||
res.rimIsDarkOnTheEdge = false;
|
||||
}
|
||||
const profile = res.rimProfile;
|
||||
delete res.rimProfile;
|
||||
console.log('rim/inside/outside luminance:', JSON.stringify(profile));
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -0,0 +1,216 @@
|
||||
// Owner 2026-08-04, on the first attempt: «с лучами солнца ты сделал фигню —
|
||||
// не надо размывать их боковые грани». A shaft of sunlight has HARD sides; it
|
||||
// fades only with distance, along the ray, from the glass inward. So:
|
||||
// * the reach is still 70 % of the v1.56 curve;
|
||||
// * the gradient still spans the FULL wedge and is dead from RAY_FADE_END;
|
||||
// * the sides are SHARP — no blur filter on the wedge, none defined at all;
|
||||
// * and because the sides are sharp, the wedge must end ON an iso-alpha line
|
||||
// of that gradient: nothing is ever drawn past its end, so the old bright
|
||||
// kerb (a far edge parallel to the wall, cut while still lit) cannot come
|
||||
// back at an oblique sun.
|
||||
// DEV-EB173-01 turned the last of those into a contract of its own: the fade
|
||||
// runs along the wall's INWARD NORMAL, not along the ray. For parallel rays
|
||||
// the distance travelled from the glass is an affine function of the point, so
|
||||
// its iso-alpha lines are parallel to the WALL — which is where an honest
|
||||
// parallelogram puts its far edge. All three invariants then hold at once:
|
||||
// peak alpha across the whole pane, the same fade distance along every ray,
|
||||
// and a far edge exactly on the gradient's end. The auditor's own grazing
|
||||
// repro is re-run below with his numbers.
|
||||
// The "light never crosses a wall" clip is asserted in demo/smoke_sun.mjs
|
||||
// (wedgeClippedToRoom) — the polygons arrive from computeSunRays() already
|
||||
// intersected with the room, which is why no clip-path is needed here.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const cfg = c._serverCfg;
|
||||
const sp = cfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
];
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'static', sun_rays: true };
|
||||
c._cfgEpoch++;
|
||||
const setSun = async (az, el) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: az, elevation: el } } } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
};
|
||||
const grads = () => [...sr().querySelectorAll('linearGradient[id^=hp-sun-]')];
|
||||
const stopsOf = (g) => [...g.querySelectorAll('stop')].map((s) => [
|
||||
parseFloat(s.getAttribute('offset')), Number(s.getAttribute('stop-opacity'))]);
|
||||
// everything below is measured off the DOM gradient, exactly like the audit
|
||||
// probe: axis, the offset a point lands on, and the alpha there
|
||||
const axisOf = (g) => {
|
||||
const x1 = +g.getAttribute('x1'), y1 = +g.getAttribute('y1');
|
||||
const x2 = +g.getAttribute('x2'), y2 = +g.getAttribute('y2');
|
||||
const len = Math.hypot(x2 - x1, y2 - y1);
|
||||
return { x1, y1, dx: x2 - x1, dy: y2 - y1, len, ux: (x2 - x1) / len, uy: (y2 - y1) / len };
|
||||
};
|
||||
const offsetOf = (g, p) => {
|
||||
const a = axisOf(g);
|
||||
return ((p[0] - a.x1) * a.dx + (p[1] - a.y1) * a.dy) / (a.len * a.len);
|
||||
};
|
||||
// a bundle without the normal-axis fade must FAIL these by name, not blow up
|
||||
const nrm = (r) => r.normal || [NaN, NaN];
|
||||
const dep = (r) => (r.depth === undefined ? NaN : r.depth);
|
||||
const alphaAt = (g, off) => {
|
||||
const st = stopsOf(g).map(([o, a]) => [o / 100, a]);
|
||||
if (off <= st[0][0]) return st[0][1];
|
||||
for (let i = 1; i < st.length; i++) {
|
||||
if (off <= st[i][0]) {
|
||||
const t = (off - st[i - 1][0]) / (st[i][0] - st[i - 1][0] || 1);
|
||||
return st[i - 1][1] + t * (st[i][1] - st[i - 1][1]);
|
||||
}
|
||||
}
|
||||
return st[st.length - 1][1];
|
||||
};
|
||||
|
||||
// ---- 1) 30 % shorter: the wedge reach in window lengths ----------------
|
||||
await setSun(270, 5); // low western sun into the west window
|
||||
const oldK = (e) => 0.8 + 1.7 * Math.pow(1 - e / 90, 1.6);
|
||||
const winLen = 0.08 * 1000;
|
||||
const low = c._sunRaysCache.rays[0].len;
|
||||
out.lowSunIs70Percent = Math.abs(low - oldK(5) * 0.7 * winLen) < 1e-6;
|
||||
await setSun(180, 60);
|
||||
const high = c._sunRaysCache.rays[0].len;
|
||||
out.highSunIs70Percent = Math.abs(high - oldK(60) * 0.7 * winLen) < 1e-6;
|
||||
out.lowStillReachesFurther = low > high;
|
||||
|
||||
// ---- 2) the shaft always dissolves BEFORE its own far edge -------------
|
||||
await setSun(270, 5);
|
||||
const gs = grads();
|
||||
out.gradientsDrawn = gs.length > 0;
|
||||
// the axis is the wall's inward normal, `len · cos(incidence)` long — the
|
||||
// perpendicular depth a ray reaches after running the FULL wedge length
|
||||
out.gradientRunsAlongTheWallNormal = gs.every((g, i) => {
|
||||
const r = c._sunRaysCache.rays[i];
|
||||
const a = axisOf(g);
|
||||
return Math.abs(a.ux - nrm(r)[0]) < 1e-6 && Math.abs(a.uy - nrm(r)[1]) < 1e-6;
|
||||
});
|
||||
out.gradientSpansWholeWedge = gs.every((g, i) => {
|
||||
const r = c._sunRaysCache.rays[i];
|
||||
const cos = r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1];
|
||||
return Math.abs(axisOf(g).len - r.len * cos) < 1e-6
|
||||
&& Math.abs(dep(r) - r.len * cos) < 1e-9
|
||||
&& stopsOf(g).length > 2;
|
||||
});
|
||||
// the ONLY thing that matters about that axis: a point `source + dir·u`
|
||||
// lands on offset `u / len`, whichever ray it rode in on
|
||||
out.offsetIsDistanceAlongTheRay = gs.every((g, i) => {
|
||||
const r = c._sunRaysCache.rays[i];
|
||||
return [0, 0.3, 0.85, 1].every((u) => [r.a, r.b].every((src) => {
|
||||
const p = [src[0] + r.dir[0] * r.len * u, src[1] + r.dir[1] * r.len * u];
|
||||
return Math.abs(offsetOf(g, p) - u) < 1e-6;
|
||||
}));
|
||||
});
|
||||
out.deadWellBeforeTheEnd = gs.every((g) => {
|
||||
const st = stopsOf(g);
|
||||
const firstZero = st.find(([, a]) => a === 0);
|
||||
return !!firstZero && firstZero[0] <= 85.001; // %
|
||||
});
|
||||
out.lastStopIsZero = gs.every((g) => stopsOf(g).slice(-1)[0][1] === 0);
|
||||
out.brightAtTheGlass = gs.every((g) => stopsOf(g)[0][1] > 0.2);
|
||||
out.neverBrightensInward = gs.every((g) => {
|
||||
const st = stopsOf(g);
|
||||
return st.every(([, a], i) => i === 0 || a <= st[i - 1][1] + 1e-9);
|
||||
});
|
||||
|
||||
// ---- 3) SHARP sides: no blur on the wedge, and none defined anywhere ----
|
||||
const wedges = () => [...sr().querySelectorAll('.sunlayer polygon')];
|
||||
// walk the polygon and its ancestors up to and including .sunlayer — the
|
||||
// day/night `brightness` filter lives further up, on the zoomwrap, and is
|
||||
// none of this test's business
|
||||
const blurredChain = (el) => {
|
||||
for (let n = el; n; n = n.parentElement) {
|
||||
const attr = n.getAttribute && n.getAttribute('filter');
|
||||
if (attr && attr !== 'none') return true;
|
||||
const cs = getComputedStyle(n).filter;
|
||||
if (cs && cs !== 'none' && cs !== '') return true;
|
||||
if (n.classList && n.classList.contains('sunlayer')) break;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
out.wedgesDrawn = wedges().length > 0;
|
||||
out.everyWedgeHasSharpSides = wedges().length > 0 && wedges().every((p) => !blurredChain(p));
|
||||
out.noSoftFilterDefined = sr().querySelectorAll('filter[id^=hp-sunsoft-]').length === 0;
|
||||
out.noGaussianBlurAtAll = sr().querySelectorAll('feGaussianBlur').length === 0;
|
||||
|
||||
// ---- 4) an OBLIQUE sun: the shaft still dies of its gradient -----------
|
||||
// The sides are hard again, so the only thing that may end the wedge is the
|
||||
// gradient. That holds ONLY if the far edge is square to the RAY: with the
|
||||
// old wall-parallel edge one far corner sat at offset ~0.7 (low sun) or
|
||||
// ~0.11 (high sun) — i.e. still lit — which is exactly the bright kerb.
|
||||
// offset ALONG THE GRADIENT, i.e. depth under the wall over `len · cos`
|
||||
const tOf = (r, p) => {
|
||||
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
|
||||
return ((p[0] - mx) * nrm(r)[0] + (p[1] - my) * nrm(r)[1]) / dep(r);
|
||||
};
|
||||
const skew = (r) => {
|
||||
const mx = (r.a[0] + r.b[0]) / 2, my = (r.a[1] + r.b[1]) / 2;
|
||||
return Math.abs((r.a[0] - mx) * r.dir[0] + (r.a[1] - my) * r.dir[1]) / r.len;
|
||||
};
|
||||
out.obliqueChecked = [];
|
||||
out.sunIsReallyOblique = true;
|
||||
out.nothingDrawnPastTheGradient = true;
|
||||
for (const [az, el] of [[230, 8], [225, 55]]) {
|
||||
await setSun(az, el);
|
||||
const rays = c._sunRaysCache.rays;
|
||||
out.obliqueChecked.push(rays.length);
|
||||
if (!rays.length) { out.sunIsReallyOblique = false; continue; }
|
||||
if (!rays.some((r) => skew(r) > 1e-3)) out.sunIsReallyOblique = false;
|
||||
for (const r of rays) {
|
||||
for (const poly of r.polys) {
|
||||
for (const p of poly) if (tOf(r, p) > 1 + 1e-6) out.nothingDrawnPastTheGradient = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.obliqueSunHasWedges = out.obliqueChecked.every((n) => n > 0);
|
||||
delete out.obliqueChecked;
|
||||
|
||||
// ---- 5) DEV-EB173-01: the auditor's own grazing repro ------------------
|
||||
// West window 80 render units long, elevation 90 (nominal reach 0.56 · 80 =
|
||||
// 44.8, i.e. 70 % of the old 64), azimuth 190 — the light enters the glass
|
||||
// and travels 10° off the wall's own direction. The probe on the broken
|
||||
// build read sides 5.408 / 84.192 (ratio 15.57) and source offsets ±0.879
|
||||
// with opacity 0 at one end of the pane.
|
||||
sp.openings = [{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 }];
|
||||
c._cfgEpoch++;
|
||||
await setSun(190, 90);
|
||||
const gr = c._sunRaysCache.rays;
|
||||
out.grazingRayDrawn = gr.length === 1;
|
||||
if (gr.length === 1) {
|
||||
const r = gr[0];
|
||||
const g = grads()[0];
|
||||
out.grazingIsReallyGrazing = Math.abs(r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1] - 0.17365) < 1e-4;
|
||||
out.grazingLengthIs70Percent = Math.abs(r.len - 0.7 * (0.8 * 80)) < 1e-6
|
||||
&& Math.abs(r.len - 44.8) < 1e-6;
|
||||
// both sides of the shaft, measured off the DRAWN polygon: the depth of a
|
||||
// vertex divided by cos is how far its ray ran
|
||||
const cos = r.dir[0] * nrm(r)[0] + r.dir[1] * nrm(r)[1];
|
||||
const ran = (p) => ((p[0] - r.a[0]) * nrm(r)[0] + (p[1] - r.a[1]) * nrm(r)[1]) / cos;
|
||||
const far = r.polys[0].map(ran).filter((u) => u > 1e-6);
|
||||
out.grazingHasTwoFarCorners = far.length === 2;
|
||||
out.grazingSidesEqualWithin1Percent = far.length === 2
|
||||
&& Math.abs(far[0] - far[1]) <= 0.01 * r.len;
|
||||
out.grazingBothSidesAreTheNominalLength = far.every((u) => Math.abs(u - r.len) <= 0.01 * r.len);
|
||||
// the whole pane of glass at peak alpha (was 0 at one end)
|
||||
const peak = stopsOf(g)[0][1];
|
||||
out.grazingGlassAtOffsetZero = [r.a, r.b].every((p) => Math.abs(offsetOf(g, p)) < 1e-6);
|
||||
out.grazingGlassAtPeakAlpha = [r.a, r.b].every((p) => Math.abs(alphaAt(g, offsetOf(g, p)) - peak) < 1e-9);
|
||||
out.grazingPeakIsTheRealPeak = peak > 0.2;
|
||||
// and nothing is drawn past the gradient
|
||||
out.grazingInsideTheGradient = r.polys.every((poly) =>
|
||||
poly.every((p) => offsetOf(g, p) >= -1e-6 && offsetOf(g, p) <= 1 + 1e-6));
|
||||
}
|
||||
// a sun 2° off the wall's plane (cos 0.035 < RAY_MIN_COS) casts nothing
|
||||
await setSun(182, 90);
|
||||
out.sunAlongTheWallCastsNothing = c._sunRaysCache.rays.length === 0;
|
||||
await setSun(186, 90);
|
||||
out.sunJustClearOfTheWallStillCasts = c._sunRaysCache.rays.length === 1;
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -10,7 +10,7 @@ const res = await page.evaluate(async () => {
|
||||
// берём заведомо не-световое устройство
|
||||
const dev = c._devices.find((d) => !d.virtual && d.primary && !d.primary.startsWith('light.'));
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
const sel = [...sr().querySelectorAll('.dialog select')].find((s) =>
|
||||
const sel = [...sr().querySelectorAll('hp-dialog select')].find((s) =>
|
||||
[...s.options].some((o) => o.textContent === c._t('tap.toggle')));
|
||||
out.threeOptions = sel && sel.options.length === 4; // + «Запустить…» (2026-07-29)
|
||||
out.noAutoOption = sel && ![...sel.options].some((o) => o.value === '');
|
||||
|
||||
@@ -22,13 +22,13 @@ const out = await page.evaluate(async () => {
|
||||
// ВИДИМОСТЬ, не просто наличие в DOM: список — flex-item со скроллом и
|
||||
// однажды схлопнулся в полоску 1px при 26 кандидатах внутри (репорт
|
||||
// пользователя 2026-07-30, поймано только замером высоты)
|
||||
const listEl = () => sr().querySelector('.dialog .candlist');
|
||||
const listEl = () => sr().querySelector('hp-dialog .candlist');
|
||||
o.pickerShown = !!listEl() && listEl().getBoundingClientRect().height > 30;
|
||||
c._markerDialog = { ...c._markerDialog, runFilter: 'штор' }; await c.updateComplete;
|
||||
const cands = [...sr().querySelectorAll('.dialog .cand')].map((x) => x.textContent);
|
||||
const cands = [...sr().querySelectorAll('hp-dialog .cand')].map((x) => x.textContent);
|
||||
o.searchWorks = cands.length >= 1 && cands.some((t) => t.includes('Шторы'));
|
||||
// и найденная строка реально нарисована, а не сплющена
|
||||
const row = sr().querySelector('.dialog .cand');
|
||||
const row = sr().querySelector('hp-dialog .cand');
|
||||
o.resultRowVisible = !!row && row.getBoundingClientRect().height > 10
|
||||
&& row.getBoundingClientRect().bottom <= listEl().getBoundingClientRect().bottom + 1;
|
||||
// сохранение без цели блокируется
|
||||
@@ -37,7 +37,7 @@ const out = await page.evaluate(async () => {
|
||||
// выбираем цель + подтверждение, сохраняем
|
||||
c._markerDialog = { ...c._markerDialog, tapTarget: 'script.curtains', tapConfirm: true };
|
||||
await c.updateComplete;
|
||||
o.confirmCheckboxShown = [...sr().querySelectorAll('.dialog .srcrow')].length >= 2;
|
||||
o.confirmCheckboxShown = [...sr().querySelectorAll('hp-dialog .srcrow')].length >= 2;
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:' + d.bindingRef);
|
||||
o.markerSaved = saved?.tap_action === 'run' && saved?.tap_target === 'script.curtains' && saved?.tap_confirm === true;
|
||||
|
||||
+11
-4
@@ -20,13 +20,19 @@ const res = await page.evaluate(async () => {
|
||||
await c.updateComplete;
|
||||
out.tipTemp = c._tip?.temp;
|
||||
out.tipHasTempLine = (sr().querySelector('.tip')?.textContent || '').includes('средняя температура');
|
||||
// _roomArea consumes the rendered room geometry (0..1000), not the stored
|
||||
// normalized config polygon (0..1).
|
||||
const expectedArea = c._roomArea(c._spaceModel().rooms[0]);
|
||||
out.tipHasArea = !!expectedArea && c._tip?.meta === c._t('tip.area', { value: expectedArea });
|
||||
out.tipHasAreaLine = (sr().querySelector('.tip')?.textContent || '').includes(expectedArea);
|
||||
c._tip = null;
|
||||
// диалог: радио заливки, компактные поля, ширина
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
out.fillRadios = sr().querySelectorAll('input[name="fillmode"]').length;
|
||||
out.tempInputs = sr().querySelectorAll('.temprange .tempin').length;
|
||||
out.dialogWide = !!sr().querySelector('.dialog.wide .srcrow');
|
||||
out.dialogWidth = Math.round(sr().querySelector('.dialog').getBoundingClientRect().width);
|
||||
const hpDialog = sr().querySelector('hp-dialog');
|
||||
out.dialogWide = !!hpDialog?.hasAttribute('wide') && !!hpDialog.querySelector('.srcrow');
|
||||
out.dialogWidth = Math.round(hpDialog.shadowRoot.querySelector('.surface').getBoundingClientRect().width);
|
||||
// NaN-защита: пустой ввод не ломает границы
|
||||
const before = c._spaceDialog.tempMax;
|
||||
const fakeEv = { target: { value: '' } };
|
||||
@@ -39,13 +45,14 @@ const res = await page.evaluate(async () => {
|
||||
// '/tmp/...' указывает в несуществующий C:\tmp и смоук падал, не дойдя до
|
||||
// ассертов (портируемость, ревью 2026-07-27)
|
||||
await page.screenshot({ path: join(tmpdir(), 'houseplan_ux_dialog.png') }).catch(() => {});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
// Geometry values were re-baselined for the shared border-box hp-dialog in
|
||||
// v1.59.2; the usable wide-dialog width remains 500 px.
|
||||
checkAll(res, {
|
||||
"filledClass": 1,
|
||||
"unfilled": 3,
|
||||
"tipTemp": 22.4,
|
||||
"fillRadios": 5,
|
||||
"tempInputs": 2,
|
||||
"dialogWidth": 502,
|
||||
"dialogWidth": 500,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Значения форматирует HOME ASSISTANT (docs/STYLING-HOOKS.md §6, docs/LIVE-TEXT.md §2.1).
|
||||
// Везде, где карточка печатает состояние ОДНОЙ сущности, она зовёт
|
||||
// hass.formatEntityState (и hass.formatEntityAttributeValue для атрибута) —
|
||||
// значит, работают display_precision, локальный разделитель дробной части и
|
||||
// переводы состояний, а единица не дублируется.
|
||||
// 1) бейдж display:'value' — «68,4 %» вместо сырых «68.42 %»;
|
||||
// 2) живой текст декора — то же, плюс перевод 'on' → «Включено»;
|
||||
// 3) своя единица заменяет единицу форматтера, а не приписывается к ней;
|
||||
// 4) атрибут идёт через СВОЙ форматтер, а не через форматтер состояния;
|
||||
// 5) старый HA без форматтера — прежнее поведение слово в слово;
|
||||
// 6) плашки °/% остаются нашими: это производное значение, а не состояние.
|
||||
// ПАДАЕТ на сборке до этих правок: бейдж и живой текст печатают сырое
|
||||
// состояние, 'on' остаётся 'on', а своя единица приписывается к чужой.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const base = window.__mkHass();
|
||||
|
||||
// ---- сущности стенда: датчик с «лишними» знаками и выключатель ----
|
||||
const EXTRA = {
|
||||
'sensor.tank': { entity_id: 'sensor.tank', state: '68.42',
|
||||
attributes: { friendly_name: 'Бак', unit_of_measurement: '%' } },
|
||||
'switch.pump': { entity_id: 'switch.pump', state: 'on',
|
||||
attributes: { friendly_name: 'Насос' } },
|
||||
'climate.hall': { entity_id: 'climate.hall', state: 'heat',
|
||||
attributes: { friendly_name: 'Котёл', current_temperature: 21.53, unit_of_measurement: '°C' } },
|
||||
};
|
||||
// Стаб ровно того, что делает HA: display_precision = 1, русская локаль
|
||||
// (запятая), перевод состояний и единица ВНУТРИ результата.
|
||||
const haFormat = (st) => {
|
||||
if (st.state === 'on') return 'Включено';
|
||||
if (st.state === 'off') return 'Выключено';
|
||||
if (st.state === 'heat') return 'Нагрев';
|
||||
if (st.state === 'locked') return 'Заперто';
|
||||
const n = Number(st.state);
|
||||
if (!Number.isFinite(n)) return st.state;
|
||||
const txt = n.toFixed(1).replace('.', ',');
|
||||
const u = st.attributes?.unit_of_measurement;
|
||||
return u ? `${txt} ${u}` : txt;
|
||||
};
|
||||
const haAttr = (st, a) => String(st.attributes?.[a]).replace('.', ',');
|
||||
const mk = (withFormatter) => {
|
||||
const h = {
|
||||
...base,
|
||||
states: { ...base.states, ...EXTRA },
|
||||
entities: { ...base.entities,
|
||||
'sensor.tank': { entity_id: 'sensor.tank', platform: 'demo' },
|
||||
'switch.pump': { entity_id: 'switch.pump', platform: 'demo' },
|
||||
'climate.hall': { entity_id: 'climate.hall', platform: 'demo' } },
|
||||
};
|
||||
if (withFormatter) { h.formatEntityState = haFormat; h.formatEntityAttributeValue = haAttr; }
|
||||
else { delete h.formatEntityState; delete h.formatEntityAttributeValue; }
|
||||
return h;
|
||||
};
|
||||
const apply = async (withFormatter) => {
|
||||
c.hass = mk(withFormatter);
|
||||
c._regSignature = ''; c._cfgEpoch++; c._maybeRebuildDevices(); c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
};
|
||||
|
||||
// ================= 1. бейдж «значение вместо значка» =================
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
{ id: 'm_tank', binding: 'entity:sensor.tank', space: c._space, display: 'value' },
|
||||
] };
|
||||
await apply(true);
|
||||
const badge = () => sr().querySelector('[data-hp="device"][data-id="m_tank"] .valtext');
|
||||
out.badgeRendered = !!badge();
|
||||
out.badgeIsHaFormatted = badge()?.textContent.trim() === '68,4 %';
|
||||
out.badgeUnitNotDoubled = !/%\s*%/.test(badge()?.textContent || '');
|
||||
// старый HA без форматтера — ровно прежняя строка
|
||||
await apply(false);
|
||||
out.badgeFallsBackToRawState = badge()?.textContent.trim() === '68.42 %';
|
||||
await apply(true);
|
||||
|
||||
// ================= 2. живой текст декора =================
|
||||
c._curSpaceCfg.decor = [
|
||||
{ id: 'lt_val', kind: 'text', x: 0.2, y: 0.92, text: 'Бак {}', color: '#123456', entity: 'sensor.tank' },
|
||||
{ id: 'lt_unit', kind: 'text', x: 0.5, y: 0.92, text: '{}', color: '#123456', entity: 'sensor.tank', unit: 'проц.' },
|
||||
{ id: 'lt_state', kind: 'text', x: 0.8, y: 0.92, text: 'Насос: {}', color: '#123456', entity: 'switch.pump' },
|
||||
{ id: 'lt_attr', kind: 'text', x: 0.2, y: 0.96, text: '{}', color: '#123456', entity: 'climate.hall', attr: 'current_temperature' },
|
||||
{ id: 'lt_attru', kind: 'text', x: 0.5, y: 0.96, text: '{}', color: '#123456', entity: 'climate.hall', attr: 'current_temperature', unit: '°C' },
|
||||
];
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
const txt = (id) => sr().querySelector(`text.dtext[data-id="${id}"]`)?.textContent.trim();
|
||||
out.liveTextIsHaFormatted = txt('lt_val') === 'Бак 68,4 %';
|
||||
out.liveTextUnitNotDoubled = txt('lt_val') === 'Бак 68,4 %' && !/%\s*%/.test(txt('lt_val'));
|
||||
out.liveTextOwnUnitReplacesTheFormatters = txt('lt_unit') === '68,4 проц.';
|
||||
out.liveTextStateIsTranslated = txt('lt_state') === 'Насос: Включено';
|
||||
// атрибут идёт через СВОЙ форматтер: иначе тут было бы «Нагрев»
|
||||
out.attrUsesTheAttributeFormatter = txt('lt_attr') === '21,53';
|
||||
out.attrDoesNotInheritTheEntityUnit = txt('lt_attr') === '21,53';
|
||||
out.attrTakesAnExplicitUnit = txt('lt_attru') === '21,53 °C';
|
||||
// без форматтера — прежнее поведение слово в слово
|
||||
await apply(false);
|
||||
out.liveTextFallsBackToRawState = txt('lt_val') === 'Бак 68.42 %';
|
||||
out.liveTextFallbackTranslatesNothing = txt('lt_state') === 'Насос: on';
|
||||
out.attrFallsBackToTheRawAttribute = txt('lt_attr') === '21.53';
|
||||
await apply(true);
|
||||
|
||||
// ================= 3. инфо-карточка =================
|
||||
const dev = c._devices.find((d) => d.primary === 'lock.front_door');
|
||||
c._infoCard = dev; c.requestUpdate(); await c.updateComplete;
|
||||
const rowVals = [...sr().querySelectorAll('.entlist .entrow')]
|
||||
.map((r) => r.querySelector('.entbtn, .ev')?.textContent.trim());
|
||||
out.infoCardUsesTheFormatter = rowVals.includes('Заперто');
|
||||
out.infoCardPrintsNoRawState = !rowVals.includes('locked');
|
||||
c._infoCard = null; c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// ================= 4. плашки °/% остались нашими =================
|
||||
// термометр в режиме «значение» по-прежнему показывает компактное «22.4°»:
|
||||
// это ПРОИЗВОДНОЕ показание (среднее по датчикам зоны / атрибут климата),
|
||||
// а не состояние одной сущности, и весь план читается как один прибор
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
{ id: 'd_temp', binding: 'device:d_temp', display: 'value' },
|
||||
] };
|
||||
await apply(true);
|
||||
const tempBadge = [...sr().querySelectorAll('.dev.valonly .valtext')].map((e) => e.textContent.trim());
|
||||
out.tempPlateKeepsItsCompactForm = tempBadge.includes('22.4°');
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Wall thickness (docs/WALL-THICKNESS.md): tool, hatch body, floor area drops
|
||||
* with thickness, opening cuts, door inner-face offset, shared once, clear→line,
|
||||
* degrade/rekey, real resize+undo keeps walls.
|
||||
*/
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
|
||||
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
const floorAreaOf = (id) => {
|
||||
const rooms = c._spaceModel().rooms;
|
||||
const walls = sp().walls || [];
|
||||
if (!walls.length) {
|
||||
const r = rooms.find((x) => x.id === id);
|
||||
const p = r?.poly;
|
||||
if (!p) return 0;
|
||||
let s = 0;
|
||||
for (let i = 0; i < p.length; i++) {
|
||||
const a = p[i], b = p[(i + 1) % p.length];
|
||||
s += a[0] * b[1] - b[0] * a[1];
|
||||
}
|
||||
return Math.abs(s) / 2;
|
||||
}
|
||||
const el = sr().querySelector(`[data-hp="room"][data-id="${id}"]`);
|
||||
const pts = el?.getAttribute('points');
|
||||
if (pts) {
|
||||
const poly = pts.trim().split(/\s+/).map((t) => t.split(',').map(Number));
|
||||
let s = 0;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i], b = poly[(i + 1) % poly.length];
|
||||
s += a[0] * b[1] - b[0] * a[1];
|
||||
}
|
||||
return Math.abs(s) / 2;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
sp().settings = { ...(sp().settings || {}), show_borders: true, hide_openings: undefined };
|
||||
delete sp().walls;
|
||||
delete sp().openings;
|
||||
for (const r of sp().rooms || []) delete r.open_to;
|
||||
|
||||
const areaBefore = floorAreaOf('r1');
|
||||
|
||||
c._setMode('plan');
|
||||
c._tool = 'wallthick';
|
||||
await upd();
|
||||
out.toolArmed = c._tool === 'wallthick';
|
||||
out.toolBtn = !!sr().querySelector('.editbar .btn.on ha-icon[icon="mdi:wall"]')
|
||||
|| [...sr().querySelectorAll('.editbar .btn')].some((b) => b.classList.contains('on') && c._tool === 'wallthick');
|
||||
|
||||
const outer = [50, 250];
|
||||
c._cursorPt = outer;
|
||||
await upd();
|
||||
out.hover = !!sr().querySelector('.wallthick-hover');
|
||||
|
||||
c._wallThickClick(outer);
|
||||
await upd();
|
||||
out.dialogOpen = !!c._wallDialog && !!sr().querySelector('.wallthick-dlg');
|
||||
|
||||
c._wallDialog = { ...c._wallDialog, value: '20' };
|
||||
c._wallThickApply(false);
|
||||
await upd();
|
||||
out.bodyDrawn = sr().querySelectorAll('[data-hp="wall"]').length >= 1;
|
||||
const areaAfter = floorAreaOf('r1');
|
||||
out.areaShrinks = areaAfter > 0 && areaAfter < areaBefore - 1;
|
||||
|
||||
// A sun shaft starts at the two room-side corners of a thick opening, not
|
||||
// on the wall centreline and not at an incidence-narrowed pseudo-aperture.
|
||||
sp().openings = [{
|
||||
id: 'wtSun', type: 'window', x: 0.04, y: 0.25, angle: 90, length: 0.09,
|
||||
}];
|
||||
sp().settings = { ...(sp().settings || {}), north_deg: 0, sun_rays: true };
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 240, elevation: 60 },
|
||||
} } };
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
const sunRay = (c._sunRaysCache?.rays || []).find((r) => r.openingId === 'wtSun');
|
||||
const sunDepth = (20 / c._cellCm) * c._gridPitch;
|
||||
const ys = sunRay ? [sunRay.a[1], sunRay.b[1]].sort((a, b) => a - b) : [];
|
||||
out.sunStartsInnerFace = !!sunRay
|
||||
&& Math.abs(sunRay.a[0] - (40 + sunDepth / 2)) < 1e-6
|
||||
&& Math.abs(sunRay.b[0] - (40 + sunDepth / 2)) < 1e-6;
|
||||
out.sunStartsInnerCorners = !!sunRay
|
||||
&& Math.abs(ys[0] - 205) < 1e-6 && Math.abs(ys[1] - 295) < 1e-6;
|
||||
c._setMode('plan');
|
||||
c._tool = 'wallthick';
|
||||
await upd();
|
||||
|
||||
c._tool = 'wallthick';
|
||||
c._wallThickClick([550, 250]);
|
||||
await upd();
|
||||
c._wallDialog = { ...c._wallDialog, value: '25' };
|
||||
c._wallThickApply(false);
|
||||
await upd();
|
||||
const shared = [...sr().querySelectorAll('[data-hp="wall"]')];
|
||||
out.sharedOnce = shared.length === 1;
|
||||
|
||||
sp().openings = [{
|
||||
id: 'wt1', type: 'door', x: 0.55, y: 0.25, angle: 90, length: 0.09, flip_v: false,
|
||||
}];
|
||||
await upd();
|
||||
out.doorDrawn = !!sr().querySelector('[data-hp="opening"][data-kind="door"]');
|
||||
const bodyD = shared[0]?.getAttribute('d') || sr().querySelector('[data-hp="wall"]')?.getAttribute('d') || '';
|
||||
out.openingCutsSlab = (bodyD.match(/\bM\b/g) || []).length >= 2;
|
||||
|
||||
const g = sr().querySelector('[data-hp="opening"][data-kind="door"] g g');
|
||||
const inner = [...(g?.querySelectorAll('g') || [])].find((n) => {
|
||||
const t = n.getAttribute('transform') || '';
|
||||
return /translate\(([^)]+)\)/.test(t) && !/translate\(\s*0\s+0\s*\)/.test(t);
|
||||
});
|
||||
out.doorInnerFace = !!inner || (sp().walls || []).some((w) => w.cm === 25);
|
||||
|
||||
sp().settings = { ...(sp().settings || {}), hide_openings: true, show_borders: true };
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
const cutD = sr().querySelector('[data-hp="wall"]')?.getAttribute('d') || '';
|
||||
out.hideKeepsCut = sr().querySelectorAll('[data-hp="opening"]').length === 0
|
||||
&& (cutD.match(/\bM\b/g) || []).length >= 2;
|
||||
|
||||
c._setMode('plan');
|
||||
c._tool = 'wallthick';
|
||||
await upd();
|
||||
c._wallThickClick(outer);
|
||||
await upd();
|
||||
c._wallDialog = { ...c._wallDialog, value: '' };
|
||||
c._wallThickApply(false);
|
||||
await upd();
|
||||
out.cleared = !(sp().walls || []).some((w) => w.cm === 20);
|
||||
|
||||
sp().walls = [...(sp().walls || []), { key: '9.99,9.99@1.5708', cm: 15 }];
|
||||
c._dropLegacySegments();
|
||||
out.degrade = !(sp().walls || []).some((w) => w.key.startsWith('9.99'));
|
||||
|
||||
// Opening a stretch takes TWO clicks since v1.59.0-beta.6: anchor, then the
|
||||
// second point on the same shared wall. A span over y=150..350 must refuse
|
||||
// thickness at y=250, and the solid remainder above/below must keep its own.
|
||||
c._tool = 'openwall';
|
||||
c._openWallClick([550, 150]);
|
||||
c._openWallClick([550, 350]);
|
||||
await upd();
|
||||
out.spanOpened = ((sp().open_spans || []).length === 1);
|
||||
c._tool = 'wallthick';
|
||||
c._toast = null;
|
||||
c._wallThickClick([550, 250]);
|
||||
await upd();
|
||||
out.openRefused = !!c._toast && !c._wallDialog;
|
||||
// the closed parts of the same wall still carry the 25 cm they had
|
||||
out.solidRemainderKeepsCm = c._intervalCm([550, 140, 550, 148]) === 25
|
||||
&& c._intervalCm([550, 350, 550, 460]) === 25
|
||||
&& c._intervalCm([550, 200, 550, 300]) === 0;
|
||||
|
||||
c._tool = 'closewall';
|
||||
c._closeWallClick([550, 250]);
|
||||
await upd();
|
||||
out.spanClosed = !(sp().open_spans || []).length;
|
||||
delete sp().openings;
|
||||
c._tool = 'wallthick';
|
||||
c._wallThickClick([550, 250]);
|
||||
await upd();
|
||||
if (c._wallDialog) {
|
||||
c._wallDialog = { ...c._wallDialog, value: '30' };
|
||||
c._wallThickApply(false);
|
||||
await upd();
|
||||
}
|
||||
out.rekeyReady = !!(sp().walls || []).find((w) => w.cm === 30);
|
||||
|
||||
// Real resize: drag shared-wall handle one grid step, then undo (AUD-159B4-01).
|
||||
c._setMode('plan');
|
||||
c._tool = 'resize';
|
||||
c._wallDialog = null;
|
||||
await upd();
|
||||
const stageEl = () => sr().querySelector('.stage');
|
||||
const toScreen = (x, y) => {
|
||||
const r = stageEl().getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return {
|
||||
clientX: r.left + ((x - v.x) / v.w) * r.width,
|
||||
clientY: r.top + ((y - v.y) / v.h) * r.height,
|
||||
};
|
||||
};
|
||||
const pev = (type, target, x, y) => {
|
||||
if (!target) return;
|
||||
const { clientX, clientY } = toScreen(x, y);
|
||||
target.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
pointerId: 42, clientX, clientY, button: 0, isPrimary: true,
|
||||
}));
|
||||
};
|
||||
const handles = [...sr().querySelectorAll('.rszhandle:not(.rszcorner)')];
|
||||
let sharedHandle = null;
|
||||
for (const h of handles) {
|
||||
const cx = +h.getAttribute('cx'), cy = +h.getAttribute('cy');
|
||||
if (Math.abs(cx - 550) < c._gridPitch * 2 && cy > 150 && cy < 450) {
|
||||
sharedHandle = h;
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.resizeEdgeFound = !!sharedHandle;
|
||||
const wallsBefore = JSON.stringify(sp().walls || []);
|
||||
if (sharedHandle) {
|
||||
const cx = +sharedHandle.getAttribute('cx');
|
||||
const cy = +sharedHandle.getAttribute('cy');
|
||||
const step = c._gridPitch;
|
||||
pev('pointerdown', sharedHandle, cx, cy);
|
||||
pev('pointermove', sharedHandle, cx + step, cy);
|
||||
pev('pointerup', sharedHandle, cx + step, cy);
|
||||
await upd();
|
||||
out.resizeKeeps = (sp().walls || []).some((w) => w.cm === 30);
|
||||
c._undoGeometry();
|
||||
await upd();
|
||||
out.resizeUndoRestores = (sp().walls || []).some((w) => w.cm === 30)
|
||||
&& JSON.stringify(sp().walls || []) === wallsBefore;
|
||||
} else {
|
||||
c._dropLegacySegments();
|
||||
out.resizeKeeps = (sp().walls || []).some((w) => w.cm === 30);
|
||||
out.resizeUndoRestores = false;
|
||||
}
|
||||
|
||||
out.pixelProbe = !!sr().querySelector('.wallbody');
|
||||
|
||||
// AUD-159B7-04: the same screen-depth policy owns both cards. At 1 cm the
|
||||
// hatch must be suppressed in full and static views; at 20 cm both restore
|
||||
// it. The static card measures its actual stage width via ResizeObserver.
|
||||
c._setMode('view');
|
||||
const parityCfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
const paritySp = parityCfg.spaces.find((x) => x.id === c._space);
|
||||
paritySp.settings = { ...(paritySp.settings || {}), show_borders: true };
|
||||
paritySp.walls = (paritySp.walls || []).map((w) => ({ ...w, cm: 1 }));
|
||||
c._serverCfg = parityCfg;
|
||||
await upd();
|
||||
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const baseCall = c.hass.callWS.bind(c.hass);
|
||||
const staticHass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: parityCfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: c._layout || {}, rev: 1 };
|
||||
return baseCall(m);
|
||||
} };
|
||||
const staticCard = document.createElement('houseplan-space-card');
|
||||
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
|
||||
staticCard.hass = staticHass;
|
||||
document.body.appendChild(staticCard);
|
||||
const t0 = Date.now();
|
||||
while ((!staticCard.renderRoot?.querySelector('.wallbody') || !(staticCard._stageWidth > 0))
|
||||
&& Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await staticCard.updateComplete;
|
||||
out.thinWallFullSolid = !!sr().querySelector('.wallbody.solid');
|
||||
out.thinWallStaticSolid = !!staticCard.renderRoot?.querySelector('.wallbody.solid');
|
||||
|
||||
for (const w of paritySp.walls || []) w.cm = 20;
|
||||
c._cfgEpoch++;
|
||||
c.requestUpdate();
|
||||
staticCard.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await staticCard.updateComplete;
|
||||
out.thickWallFullHatched = !!sr().querySelector('.wallbody:not(.solid)');
|
||||
out.thickWallStaticHatched = !!staticCard.renderRoot?.querySelector('.wallbody:not(.solid)');
|
||||
staticCard.remove();
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,120 @@
|
||||
// DEV-B703-03: «карточка перезагружается при возврате на вкладку» — часть 2.
|
||||
// Тёплый ре-маунт (v1.58.0) убрал вуаль, но не сохранял ни ПАН, ни зум
|
||||
// редактора, ни открытые диалоги: Lovelace пересоздаёт элемент, состояние
|
||||
// диалога живёт в экземпляре и умирает вместе с ним. Смок воспроизводит
|
||||
// пересоздание (remove + create на той же странице) и требует:
|
||||
// 1) вид восстановлен БИТ-В-БИТ — ни один кадр не отличается от прежнего;
|
||||
// 2) открытый диалог пережил пересоздание вместе с черновиком;
|
||||
// 3) осознанно закрытый (Esc) — НЕ воскресает;
|
||||
// 4) подтверждение «Выровнять всё» — НЕ воскресает никогда;
|
||||
// 5) воскрешение одноразовое: третий экземпляр диалога уже не видит.
|
||||
// ПАДАЕТ на сборке до DEV-B703-03 (вид рецентрировался, диалоги терялись).
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
customElements.get('houseplan-card')?._warmBootReset?.();
|
||||
localStorage.removeItem('houseplan_card_zoom_v1');
|
||||
localStorage.removeItem('houseplan_card_nav_v1');
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
document.body.appendChild(wrap);
|
||||
const mk = () => {
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' }); // Lovelace: setConfig ДО вставки
|
||||
c.hass = window.__mkHass();
|
||||
wrap.appendChild(c);
|
||||
return c;
|
||||
};
|
||||
const settle = async (c) => {
|
||||
const t0 = performance.now();
|
||||
while (c._booting && performance.now() - t0 < 2500) await sleep(30);
|
||||
await sleep(350);
|
||||
};
|
||||
const rect = (c) => (c._view ? [c._view.x, c._view.y, c._view.w, c._view.h] : null);
|
||||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
/** покадрово: ни один кадр после пересоздания не отличается от эталона */
|
||||
const watchView = async (c, zoom0, v0, ms = 700) => {
|
||||
const bad = [];
|
||||
const t1 = performance.now();
|
||||
await new Promise((done) => {
|
||||
const s = () => {
|
||||
if (Math.abs(c._zoom - zoom0) > 1e-6 || !same(rect(c), v0)) {
|
||||
bad.push({ t: Math.round(performance.now() - t1), zoom: c._zoom, v: rect(c) });
|
||||
}
|
||||
if (performance.now() - t1 < ms) requestAnimationFrame(s); else done();
|
||||
};
|
||||
requestAnimationFrame(s);
|
||||
});
|
||||
return bad.length === 0 ? true : `кадр ${bad[0].t}мс: zoom=${bad[0].zoom} view=${JSON.stringify(bad[0].v)} (ждали ${zoom0} / ${JSON.stringify(v0)})`;
|
||||
};
|
||||
|
||||
// ================= A. просмотр: пан+зум и настройки пространства ==========
|
||||
let c = mk();
|
||||
await settle(c);
|
||||
c._applyView(2.4, 260, 720); // зум в угол — вид точно не по центру
|
||||
c._saveZoom();
|
||||
c.requestUpdate(); await c.updateComplete; await sleep(100);
|
||||
const zoomA = c._zoom, viewA = rect(c);
|
||||
out.aPanned = viewA[0] > 1 || viewA[1] > 1; // sanity: вид действительно смещён
|
||||
c._openSpaceDialog('edit', c._space); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'ЧЕРНОВИК-42' }; // недосохранённая правка
|
||||
await c.updateComplete;
|
||||
out.aDialogOpenBefore = !!c._spaceDialog;
|
||||
|
||||
c.remove(); await sleep(20); // ← Lovelace выбрасывает элемент
|
||||
c = mk(); // ← и создаёт новый
|
||||
out.aViewBitExact = await watchView(c, zoomA, viewA);
|
||||
out.aDialogSurvived = !!c._spaceDialog;
|
||||
out.aDraftSurvived = c._spaceDialog?.title === 'ЧЕРНОВИК-42';
|
||||
out.aStillSameSpaceMode = c._mode === 'view';
|
||||
|
||||
// ---- воскрешение одноразовое: ещё одно пересоздание диалог не вернёт ----
|
||||
c._spaceDialog = null; await c.updateComplete; // (осознанно закрыли)
|
||||
c.remove(); await sleep(20);
|
||||
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
|
||||
out.aNoZombieAfterClose = !c._spaceDialog;
|
||||
|
||||
// ================= B. редактор устройств: зум редактора + карточка =======
|
||||
c._setMode('devices'); await c.updateComplete; await sleep(120);
|
||||
c._applyView(3.4, 430, 380); c.requestUpdate(); await c.updateComplete; await sleep(100);
|
||||
const zoomB = c._zoom, viewB = rect(c);
|
||||
const dev = c._devices.find((d) => d.space === c._space);
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
c._markerDialog = { ...c._markerDialog, name: 'ИМЯ-ЧЕРНОВИК' };
|
||||
await c.updateComplete;
|
||||
out.bDialogOpenBefore = !!c._markerDialog;
|
||||
|
||||
c.remove(); await sleep(20);
|
||||
c = mk();
|
||||
out.bModeRestored = c._mode === 'devices';
|
||||
out.bViewBitExact = await watchView(c, zoomB, viewB);
|
||||
out.bDialogSurvived = !!c._markerDialog;
|
||||
out.bDraftSurvived = c._markerDialog?.name === 'ИМЯ-ЧЕРНОВИК';
|
||||
|
||||
// ================= C. Esc = осознанное закрытие ==========================
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
await c.updateComplete; await sleep(30);
|
||||
out.cClosedByEsc = !c._markerDialog;
|
||||
c.remove(); await sleep(20);
|
||||
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
|
||||
out.cNoResurrectionAfterEsc = !c._markerDialog;
|
||||
|
||||
// ================= D. «Выровнять всё» не воскресает НИКОГДА ==============
|
||||
c._setMode('view'); await c.updateComplete; await sleep(60);
|
||||
c._alignDialog = { report: { moved: 3, maxShiftCm: 2, maxSpace: c._space }, spaces: [], layout: {}, cm: 2, where: '', busy: false };
|
||||
await c.updateComplete;
|
||||
out.dAlignOpenBefore = !!c._alignDialog;
|
||||
c.remove(); await sleep(20);
|
||||
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
|
||||
out.dAlignNotRevived = !c._alignDialog;
|
||||
|
||||
c.remove(); wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(res)) check(k, v);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,117 @@
|
||||
// Регрессия аудита v1.59.0-beta.1 — владение тёплой памяткой.
|
||||
// Три находки, все три ПАДАЮТ на сборке v1.59.0-beta.1:
|
||||
// AUD-159B1-01 (P2): две карточки с ИДЕНТИЧНЫМ конфигом на одной странице
|
||||
// делили один слот памятки (ключ = размер окна × JSON конфига), поэтому
|
||||
// новый экземпляр на месте A усыновлял вид последнего писателя — соседа B
|
||||
// (его этаж, режим, зум), а черновик настоящего предшественника съедался
|
||||
// guard-ом `d.mode !== this._mode`.
|
||||
// AUD-159B1-02 (P2): быстрая серия пересозданий (A→B→C в одном такте):
|
||||
// промежуточный B ещё не успел воскресить диалог A, но в своём
|
||||
// disconnectedCallback сбрасывал `_warmRevivePending` ДО снимка и писал
|
||||
// `dlg: null` поверх чужого черновика — C не получал ничего.
|
||||
// AUD-159B1-03 (P3): 10-секундный TTL запрещал воскрешение, но не освобождал
|
||||
// payload: просроченная запись держала диалог (у пространства это план
|
||||
// целиком в base64) до перезагрузки страницы.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const HP = customElements.get('houseplan-card');
|
||||
HP._warmBootReset(400); // TTL диалога 400мс вместо 10с — иначе смок стоит 10 секунд
|
||||
localStorage.removeItem('houseplan_card_zoom_v1');
|
||||
localStorage.removeItem('houseplan_card_nav_v1');
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
document.body.appendChild(wrap);
|
||||
/** Lovelace: setConfig ДО вставки; `before` = вставить в чужой DOM-слот */
|
||||
const mk = (before) => {
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__mkHass();
|
||||
if (before) wrap.insertBefore(c, before); else wrap.appendChild(c);
|
||||
return c;
|
||||
};
|
||||
const settle = async (c) => {
|
||||
const t0 = performance.now();
|
||||
while (c._booting && performance.now() - t0 < 2500) await sleep(30);
|
||||
await sleep(250);
|
||||
};
|
||||
const near = (a, b) => Math.abs(a - b) < 1e-6;
|
||||
|
||||
// ============ A. две одинаковые карточки не делят вид и черновик =========
|
||||
const A = mk();
|
||||
await settle(A);
|
||||
A._applyView(2.15, 300, 420);
|
||||
A.requestUpdate(); await A.updateComplete; await sleep(60);
|
||||
const zoomA = A._zoom;
|
||||
A._openSpaceDialog('edit', A._space); await A.updateComplete;
|
||||
A._spaceDialog = { ...A._spaceDialog, title: 'DRAFT-FROM-A' };
|
||||
await A.updateComplete; await sleep(30);
|
||||
|
||||
const B = mk(); // вторая карточка, тот же конфиг
|
||||
await settle(B);
|
||||
B._setMode('devices'); await B.updateComplete; await sleep(120);
|
||||
B._applyView(3.35, 200, 200);
|
||||
B.requestUpdate(); await B.updateComplete; await sleep(60);
|
||||
const zoomB = B._zoom;
|
||||
out.aTwoLiveCards = A.isConnected && B.isConnected && zoomB !== zoomA;
|
||||
// B — самостоятельная карточка, а не наследник A
|
||||
out.aNeighbourKeptOwnView = B._mode === 'devices' && !B._spaceDialog;
|
||||
|
||||
// Lovelace создаёт замену A ДО того, как отсоединит A (порядок аудитора)
|
||||
const A2 = mk(A);
|
||||
A.remove();
|
||||
await sleep(120); await A2.updateComplete; await sleep(60);
|
||||
|
||||
out.aReplacementKeepsOwnMode = A2._mode === 'view'; // не 'devices' от B
|
||||
out.aReplacementKeepsOwnZoom = near(A2._zoom, zoomA); // не 3.35 от B
|
||||
out.aOwnersDraftRestored = A2._spaceDialog?.title === 'DRAFT-FROM-A';
|
||||
out.aNeighbourUntouched = B._mode === 'devices' && near(B._zoom, zoomB) && !B._spaceDialog;
|
||||
A2.remove(); B.remove(); await sleep(20);
|
||||
|
||||
// ============ B. быстрый двойной ре-маунт не убивает черновик ============
|
||||
HP._warmBootReset(400);
|
||||
let c = mk();
|
||||
await settle(c);
|
||||
c._openSpaceDialog('edit', c._space); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'DRAFT-RAPID' };
|
||||
await c.updateComplete; await sleep(30);
|
||||
out.bDraftOpenBefore = c._spaceDialog?.title === 'DRAFT-RAPID';
|
||||
// A(черновик) -> создать B -> убрать A -> создать C -> убрать B — всё в одном
|
||||
// такте, ни один таймер воскрешения не успевает отработать
|
||||
const rb = mk(c);
|
||||
c.remove();
|
||||
const rc = mk(rb);
|
||||
rb.remove();
|
||||
await sleep(150); await rc.updateComplete; await sleep(60);
|
||||
out.bDraftSurvivedDoubleRemount = rc._spaceDialog?.title === 'DRAFT-RAPID';
|
||||
rc.remove(); await sleep(20);
|
||||
|
||||
// ============ C. просроченный диалог освобождает payload =================
|
||||
HP._warmBootReset(400);
|
||||
c = mk();
|
||||
await settle(c);
|
||||
c._openSpaceDialog('edit', c._space); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'DRAFT-TTL', planFile: 'x'.repeat(4096) };
|
||||
await c.updateComplete; await sleep(30);
|
||||
c.remove(); await sleep(20);
|
||||
out.cHeldWhileRevivable = HP._warmBootStats().dlgs === 1; // пока TTL идёт — держим
|
||||
await sleep(900); // TTL 400мс + запас
|
||||
const st = HP._warmBootStats();
|
||||
out.cPayloadFreedAfterTtl = st.dlgs === 0;
|
||||
out.cGeometryKept = st.slots >= 0 && st.keys >= 0; // память не сломана
|
||||
// и после TTL ничего не воскресает
|
||||
c = mk(); await sleep(150); await c.updateComplete; await sleep(40);
|
||||
out.cNoReviveAfterTtl = !c._spaceDialog;
|
||||
c.remove();
|
||||
|
||||
HP._warmBootReset();
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(res)) check(k, v);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,119 @@
|
||||
// DEV-B703-01: Lovelace re-creates card elements when the websocket reconnects
|
||||
// after a long-backgrounded tab; the fresh instance used to run the whole
|
||||
// first-open boot (veil + 700 ms + quiescence) — the owner's «план
|
||||
// перезагружается при возврате на вкладку». Within one loaded page a repeat
|
||||
// instance must open WARM: no veil, no wait, not a single frame at a stale
|
||||
// stage height or a default zoom. A window resize between instances
|
||||
// invalidates the memo → the full protective boot is back.
|
||||
// FAILS on the build before DEV-B703-01 (every instance booted cold).
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
customElements.get('houseplan-card')?._warmBootReset?.(); // deterministic: the page starts cold
|
||||
localStorage.setItem('houseplan_card_zoom_v1', JSON.stringify({ f1: 1.8 }));
|
||||
localStorage.removeItem('houseplan_card_nav_v1');
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
document.body.appendChild(wrap);
|
||||
const mk = () => {
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__mkHass();
|
||||
wrap.appendChild(c);
|
||||
return c;
|
||||
};
|
||||
|
||||
// ---- cold boot: the first instance pays the full protective window ----
|
||||
const c1 = mk();
|
||||
out.coldBoots = c1._booting === true;
|
||||
const t0 = performance.now();
|
||||
let veilSeen = false;
|
||||
while (c1._booting && performance.now() - t0 < 2500) {
|
||||
if ((c1.shadowRoot || c1.renderRoot).querySelector('.bootveil')) veilSeen = true;
|
||||
await sleep(30);
|
||||
}
|
||||
out.coldVeilSeen = veilSeen;
|
||||
out.coldSettled = c1._booting === false;
|
||||
await sleep(300); // fade out + soft grace start
|
||||
const stage1 = (c1.shadowRoot || c1.renderRoot).querySelector('.stage');
|
||||
const hFinal = stage1.clientHeight;
|
||||
const zoomSaved = c1._zoom;
|
||||
out.zoomArmed = Math.abs(zoomSaved - 1.8) < 0.01;
|
||||
c1.remove(); // Lovelace throws the old element away…
|
||||
await sleep(30);
|
||||
|
||||
// ---- …and creates a new one: same page, same viewport → WARM mount ----
|
||||
const c2 = mk();
|
||||
out.warmNoBootFlag = c2._booting === false; // decided synchronously in setConfig
|
||||
const sr2 = () => c2.shadowRoot || c2.renderRoot;
|
||||
const frames = [];
|
||||
const t1 = performance.now();
|
||||
await new Promise((done) => {
|
||||
const sample = () => {
|
||||
const stage = sr2().querySelector('.stage');
|
||||
if (stage) {
|
||||
const zw = stage.querySelector(':scope > .zoomwrap');
|
||||
frames.push({
|
||||
t: Math.round(performance.now() - t1),
|
||||
veil: !!sr2().querySelector('.bootveil'),
|
||||
planVisible: !!zw && getComputedStyle(zw).visibility === 'visible',
|
||||
rooms: stage.querySelectorAll('.room').length,
|
||||
stageH: stage.clientHeight,
|
||||
zoom: c2._zoom,
|
||||
});
|
||||
}
|
||||
if (performance.now() - t1 < 900) requestAnimationFrame(sample);
|
||||
else done();
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
out.framesSampled = frames.length > 20;
|
||||
out.warmNoVeilEver = !frames.some((f) => f.veil);
|
||||
out.warmPlanVisibleEveryFrame = frames.length > 0 && frames.every((f) => f.planVisible)
|
||||
? true : 'a frame hid the plan';
|
||||
const badH = frames.filter((f) => Math.abs(f.stageH - hFinal) > 2);
|
||||
out.warmNoStaleHeightFrame = badH.length === 0
|
||||
? true : `stage h=${badH[0].stageH} vs final ${hFinal} at t=${badH[0].t}ms`;
|
||||
out.warmZoomEveryFrame = frames.every((f) => Math.abs(f.zoom - zoomSaved) < 0.01)
|
||||
? true : 'a frame at a non-saved zoom';
|
||||
// the server config reload on a warm mount must not blank the plan
|
||||
out.warmNeverEmptyPlan = frames.every((f) => f.rooms > 0) ? true : 'a frame rendered no rooms';
|
||||
c2.remove();
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(res)) check(k, v);
|
||||
|
||||
// ---- a window resize between instances → memo stale → the full boot ----
|
||||
await page.setViewportSize({ width: 900, height: 680 });
|
||||
const rez = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = window.__mkHass();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.appendChild(c);
|
||||
document.body.appendChild(wrap);
|
||||
out.resizedBootsCold = c._booting === true;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const t0 = performance.now();
|
||||
let veil = false;
|
||||
while (c._booting && performance.now() - t0 < 2500) {
|
||||
if (sr().querySelector('.bootveil')) veil = true;
|
||||
await sleep(30);
|
||||
}
|
||||
out.resizedVeilShown = veil;
|
||||
out.resizedSettles = c._booting === false;
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(rez)) check(k, v);
|
||||
|
||||
await finish(browser, { ...res, ...rez });
|
||||
@@ -0,0 +1,80 @@
|
||||
// DEV-B703-02: temporary WS failures must NEVER blank a plan that was already
|
||||
// shown. On the build before the fix _loadFromServer's catch nulled
|
||||
// _serverCfg after 8 failed tries — the plan disappeared even though a valid
|
||||
// config (the LS snapshot) was on screen. Stale-while-revalidate: the last
|
||||
// valid config lives until a successful reload replaces it, and once the
|
||||
// socket recovers the card revalidates QUIETLY on its own retry clock
|
||||
// (willUpdate stops driving loads after 8 tries).
|
||||
// planSurvivesOutage + reloadedAfterRecovery FAIL on the pre-fix build.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
customElements.get('houseplan-card')?._warmBootReset?.();
|
||||
// the LS snapshot is in place (the page's main card cached it): the new
|
||||
// instance renders instantly from it — a wall tablet rejoining wifi
|
||||
out.lsCachePresent = !!localStorage.getItem('houseplan_card_cfg_v1');
|
||||
|
||||
let fail = true;
|
||||
let failedCalls = 0;
|
||||
const base = window.__mkHass();
|
||||
const mkFlaky = () => ({
|
||||
...base,
|
||||
callWS: async (m) => {
|
||||
if (fail) { failedCalls++; throw new Error('ws down'); }
|
||||
return base.callWS(m);
|
||||
},
|
||||
});
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' });
|
||||
c.hass = mkFlaky();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
wrap.appendChild(c);
|
||||
document.body.appendChild(wrap);
|
||||
await sleep(50);
|
||||
out.rendersFromCache = !!c._serverCfg;
|
||||
|
||||
// hass keeps ticking while the socket is down — drive past the 8-try budget
|
||||
for (let i = 0; i < 12; i++) {
|
||||
c.hass = mkFlaky();
|
||||
await sleep(60);
|
||||
}
|
||||
out.wsFailedEnough = failedCalls >= 8 ? true : `only ${failedCalls} failed calls`;
|
||||
out.planSurvivesOutage = c._serverCfg ? true : '_serverCfg was cleared by the outage';
|
||||
out.stillServerMode = c._serverStorage === true;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stage = sr().querySelector('.stage');
|
||||
out.roomsStillRendered = !!stage && stage.querySelectorAll('.room').length > 0;
|
||||
|
||||
// ---- the socket comes back: quiet self-driven revalidation ----
|
||||
fail = false;
|
||||
const t0 = performance.now();
|
||||
while (!c._loadOk && performance.now() - t0 < 12000) await sleep(100);
|
||||
out.reloadedAfterRecovery = c._loadOk === true ? true : 'never revalidated after recovery';
|
||||
out.cfgValidAfterRecovery = !!c._serverCfg && Array.isArray(c._serverCfg.spaces)
|
||||
&& c._serverCfg.spaces.length > 0;
|
||||
wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(res)) check(k, v);
|
||||
|
||||
// ---- a LIVE instance: a failing config reload keeps the last config ----
|
||||
const live = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const before = c._serverCfg;
|
||||
const origHass = c.hass;
|
||||
c.hass = { ...c.hass, callWS: async () => { throw new Error('ws down'); } };
|
||||
await c._reloadConfigOnly(true);
|
||||
out.liveReloadFailKeepsCfg = c._serverCfg === before
|
||||
? true : 'a failed reload replaced/cleared the live config';
|
||||
c.hass = origHass;
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(live)) check(k, v);
|
||||
|
||||
await finish(browser, { ...res, ...live });
|
||||
@@ -33,9 +33,10 @@ const out = await page.evaluate(() => {
|
||||
'climate.trv': { state: 'off', attributes: { hvac_action: 'off' } },
|
||||
}) === '';
|
||||
|
||||
// без hvac_action фолбэк на state
|
||||
o.stateFallback = cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === ''
|
||||
? false : cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === 'on';
|
||||
// без hvac_action выбранный режим heat не доказывает реальную работу
|
||||
o.modeWithoutActionStaysDark = cls(trv, {
|
||||
'climate.trv': { state: 'heat', attributes: {} },
|
||||
}) === '';
|
||||
|
||||
// горящая лампа устройства желтит значок, даже если primary — не она
|
||||
const lamp = { id: 'l', primary: 'sensor.lamp_power', entities: ['sensor.lamp_power', 'light.lamp'], marker: null };
|
||||
|
||||
@@ -44,8 +44,11 @@ out.zoomOut = await page.evaluate(() => {
|
||||
out.zoomOutWorks = out.zoomOut.zoom === 0.5 && out.zoomOut.wider
|
||||
&& out.zoomOut.centredX && out.zoomOut.centredY;
|
||||
delete out.zoomOut;
|
||||
out.floorIsHalf = await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
|
||||
c._applyView(0.1); return c._zoom; }) === 0.4; // clamped at the floor
|
||||
// docs/CANVAS.md §5: the zoom-out floor moved from a fixed 0.4 of the old
|
||||
// view_box to 1/3 of the CONTENT frame — three canvases of empty plane is as
|
||||
// far as it is useful to go when the canvas has no edges.
|
||||
out.floorIsThird = Math.abs(await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
|
||||
c._applyView(0.01); return c._zoom; }) - 1 / 3) < 1e-9; // clamped at the floor
|
||||
await page.evaluate(() => window.__card._resetZoom());
|
||||
|
||||
// -- editor zoom is a working tool, not the viewing intent ----------------
|
||||
|
||||
+1398
-499
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
-3
@@ -72,6 +72,7 @@ const LAYOUT = {
|
||||
d_bedlight:{s:'f1',x:0.75,y:0.6}, d_window:{s:'f1',x:0.9,y:0.85}, d_lock:{s:'f1',x:0.1,y:0.88},
|
||||
d_motion:{s:'f1',x:0.4,y:0.75}, d_mower:{s:'garden',x:0.25,y:0.35}, d_gate:{s:'garden',x:0.73,y:0.22}
|
||||
};
|
||||
let CFG_REV=1, LAYOUT_REV=1;
|
||||
const AREAS={living_room:{area_id:'living_room',name:'Living room'},kitchen:{area_id:'kitchen',name:'Kitchen'},
|
||||
bedroom:{area_id:'bedroom',name:'Bedroom'},hallway:{area_id:'hallway',name:'Hallway'},garden:{area_id:'garden',name:'Garden'}};
|
||||
function dev(id,name,model,area){return [id,{id,name,model,area_id:area,identifiers:[['demo',id]],entry_type:null,via_device_id:null}];}
|
||||
@@ -120,12 +121,22 @@ let STATES={
|
||||
function mkHass(){
|
||||
return {
|
||||
language:'en', locale:{language:'en'},
|
||||
// audit P0-4: the card fail-closes editors without an admin user /
|
||||
// can_write flag — the demo admin mirrors a real HA admin session.
|
||||
user:{id:'demo', name:'Demo', is_admin:true},
|
||||
devices:DEVICES, entities:ENTITIES, areas:AREAS, states:STATES,
|
||||
floors:{g:{floor_id:'g',name:'Ground floor',level:0},u:{floor_id:'u',name:'Upstairs',level:1}},
|
||||
callWS:async (m)=>{
|
||||
if(m.type==='houseplan/config/get')return{config:CFG,rev:1};
|
||||
if(m.type==='houseplan/layout/get')return{layout:LAYOUT};
|
||||
return {ok:true,rev:2};
|
||||
if(m.type==='houseplan/config/get')return{config:CFG,rev:CFG_REV,can_write:true};
|
||||
if(m.type==='houseplan/layout/get')return{layout:LAYOUT,rev:LAYOUT_REV};
|
||||
if(m.type==='houseplan/config/set')return{ok:true,rev:++CFG_REV};
|
||||
if(['houseplan/layout/set','houseplan/layout/update','houseplan/layout/delete'].includes(m.type))
|
||||
return{ok:true,rev:++LAYOUT_REV};
|
||||
if(m.type==='houseplan/plan/optimize')return{
|
||||
ok:true,config_rev:++CFG_REV,layout_rev:++LAYOUT_REV,can_undo:true};
|
||||
if(m.type==='houseplan/plan/optimize_undo')return{
|
||||
ok:true,config_rev:++CFG_REV,layout_rev:++LAYOUT_REV,can_undo:false};
|
||||
return {ok:true};
|
||||
},
|
||||
callService:async (d,s,data)=>{
|
||||
const eid=data.entity_id, st=STATES[eid];
|
||||
|
||||
Vendored
+1398
-499
File diff suppressed because one or more lines are too long
+75
-20
@@ -9,8 +9,9 @@ that contains both the backend (`custom_components/houseplan`) and the Lovelace
|
||||
houseplan-card/
|
||||
├─ src/ # card sources (TypeScript + Lit 3)
|
||||
│ ├─ houseplan-card.ts # the card: rendering, states, drag, tooltip, sticky header
|
||||
│ ├─ hp-dialog.ts # shared HA/native modal shell and focus lifecycle
|
||||
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
|
||||
│ ├─ rules.ts # icon rules (iconFor), filtering, groups, domain priority
|
||||
│ ├─ rules.ts # icon rules (iconFor), filtering, groups, fallback order
|
||||
│ └─ data/
|
||||
│ ├─ house.ts # geometry: ROOMS (rooms→area), FLOOR_VB (viewBox), names
|
||||
│ └─ backgrounds.ts # VECTOR plans (SVG base64) + FLOOR_BG_RECT (positioning)
|
||||
@@ -40,6 +41,12 @@ houseplan-card/
|
||||
`hass.devices/entities/areas` (registries). No direct WS connections.
|
||||
4. **Reactivity.** Every state change in HA leads to set hass → re-render.
|
||||
Temperatures/LQI/on-off are live by definition (verified by substituting state).
|
||||
5. **One modal contract.** Card modals render through `hp-dialog`. In Home
|
||||
Assistant it delegates surface semantics and trapping to `ha-dialog`; the
|
||||
standalone demo falls back to native `<dialog>`. The wrapper owns the title,
|
||||
initial focus, Escape close event and restore-focus session. Focus sessions
|
||||
are scoped to a card shadow root so nested dialogs return to their parent
|
||||
trigger and dialog replacement still returns to the original outside opener.
|
||||
|
||||
## Coordinate system
|
||||
|
||||
@@ -55,8 +62,10 @@ houseplan-card/
|
||||
|
||||
## Card data model (runtime)
|
||||
|
||||
`DevItem`: id (device_id), name, model, area, floor, icon, entities[], primary (the entity used for
|
||||
more-info by domain priority), temp, members[] (light group), link/linkPrimary (Z2M group).
|
||||
`DevItem`: id (device_id), name, model, area, floor, icon, entities[], primary
|
||||
(the first resolved state entity for actions requiring one target), temp,
|
||||
members[] (light group), link/linkPrimary (Z2M group). Marker state consumes
|
||||
the complete resolved role, not this single compatibility field.
|
||||
|
||||
Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the prototype:
|
||||
- only devices with an area from the room list are shown;
|
||||
@@ -72,8 +81,12 @@ Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the
|
||||
- Temperature: an entity with device_class=temperature / °C / `_temperature$` → label on the right.
|
||||
- LQI (zigbee): the average over `*_linkquality` entities → label under the icon; color via
|
||||
`lqiColor()`: ≤40 red → ≥180 green (hsl gradient). The room average is shown in the room tooltip.
|
||||
The same tooltip includes the formatted clean-floor area (inner contour for
|
||||
thick walls), and View hover gives every room an accent/brightness highlight.
|
||||
- Icon state classes: on (yellow), open (orange: cover/valve/lock/binary_sensor
|
||||
of problem classes), unavail (transparency).
|
||||
of problem classes), unavail (transparency). Yellow remains on the marker in
|
||||
source-glow fill mode: a light pool is spatial information, not a replacement
|
||||
for the universal working-state plate.
|
||||
|
||||
## Sizes
|
||||
|
||||
@@ -87,11 +100,18 @@ Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the
|
||||
|
||||
## Device markers (v1.6.0+)
|
||||
|
||||
Per-marker appearance (v1.22.0): `display: badge|ripple|icon_ripple` (+`ripple_color`,
|
||||
`ripple_size`) draws presence-style pulsing rings gated by the pure `isActiveState` —
|
||||
deliberately independent of the card-wide `live_states` toggle, with `unavailable` counting as
|
||||
idle. `size` (icon multiplier via the `--dev-size` CSS var — value badges scale along) and
|
||||
`angle` rotate/scale a single icon. Room drawing shows a live **ruler** (`segmentCm` +
|
||||
Per-marker appearance: `display: badge|icon_ripple|value`. All three use one semantic
|
||||
resolver (`src/device-visual.ts`) for availability, steady status and activity. `badge`
|
||||
shows the icon/morph and status plate; `icon_ripple` additionally shows a finite event,
|
||||
static presence, mechanical transition or actual-work ring; `value` replaces the icon
|
||||
with the HA-formatted numeric value. A critical alarm is red in every presentation.
|
||||
Runtime baselines are seeded as soon as a rebuilt registry becomes authoritative, before
|
||||
the next HA snapshot is classified; source-key changes reset any finite effect immediately.
|
||||
Legacy `display: ripple` is read as `icon_ripple` and rewritten on the next config save;
|
||||
the backend accepts it only for compatibility. `ripple_color` and `ripple_size` remain the
|
||||
stored names for the ordinary activity effect. `size` (icon multiplier via the
|
||||
`--dev-size` CSS var — value badges scale along) and `angle` rotate/scale a single icon.
|
||||
Room drawing shows a live **ruler** (`segmentCm` +
|
||||
`formatLength`, metres or feet+inches by `hass.config.unit_system`); the scale is per-space
|
||||
`cell_cm` (default 5 cm per grid cell).
|
||||
|
||||
@@ -109,7 +129,9 @@ references and duplicates by name|area. Manual files: transactional HTTP upload
|
||||
|
||||
`.storage/houseplan.config` (Store):
|
||||
```json
|
||||
{ "spaces": [{ "id","title","plan_url","plan_aspect","view_box":[4],
|
||||
{ "spaces": [{ "id","title","plan_url","plan_aspect",
|
||||
"plan_x","plan_y","plan_scale", // optional, docs/BACKDROP.md
|
||||
"view_box":[4],
|
||||
"rooms":[{"id","name","area","poly|x/y/w/h","open_to","settings"}],
|
||||
"openings":[…], "decor":[…], "settings":{…} }],
|
||||
"markers": [{ "id","binding":"device:<id>|entity:<eid>|virtual","hidden",
|
||||
@@ -122,7 +144,9 @@ references and duplicates by name|area. Manual files: transactional HTTP upload
|
||||
All coordinates are **normalized (0..1 of the canvas)**; the canvas is always
|
||||
**square** (v1.48.0), render space `NORM_W × NORM_W` (1000×1000). A space has no
|
||||
proportions of its own — `plan_aspect` is the IMAGE's ratio, used to letterbox
|
||||
it centred on the square. The schema bounds geometry to ±4 with strictly
|
||||
it centred on the square; the optional `plan_x`/`plan_y`/`plan_scale` then move
|
||||
and uniformly scale that rectangle (`planRect`, docs/BACKDROP.md), and their
|
||||
absence is the centred default exactly. The schema bounds geometry to ±4 with strictly
|
||||
positive sizes (HP-1501/1502). `device_overrides`/`virtual_devices` are long
|
||||
gone — markers carry everything, `marker.hidden` is the explicit
|
||||
"hide from plan" flag seeded once by the old filter (docs/FILTERING.md).
|
||||
@@ -146,11 +170,18 @@ bigger part keeps the room identity (name/area/devices).
|
||||
|
||||
## Markup editor (v1.4.0+)
|
||||
|
||||
State inside the card: `_markup` (mode), `_tool` (draw/delroom), `_path` (the current outline,
|
||||
State inside the card: `_markup` (mode), `_tool` (draw/merge/split/resize/opening/openwall/
|
||||
closewall/wallthick/delroom), `_path` (the current outline,
|
||||
vertices on the GRID_N=240 grid). Clicks on the stage → `_svgPoint`→`_snap`. The outline is closed
|
||||
= a click on the first vertex → area select (hass.areas) + name → room {poly}. Polygon rooms and
|
||||
rectangles are rendered uniformly (hit-test: point-in-polygon / rect).
|
||||
|
||||
All committed plan-geometry mutations enter one named 50-command Undo/Redo stack. Ctrl+Z,
|
||||
Ctrl+Shift+Z/Ctrl+Y and the toolbar buttons use the same stack; a new mutation after Undo drops
|
||||
the redo branch. The local stack survives the server echo of its own writes, but is cleared when
|
||||
a newer external config revision is adopted. Positional placement is always quantized to the plan
|
||||
grid. Shift remains an angular precision modifier only and cannot create off-grid coordinates.
|
||||
|
||||
**A line is never an entity of its own (v1.19.0).** Nothing is persisted while you draw: an
|
||||
outline you never close leaves no trace. Walls are *derived* from the room outlines by
|
||||
`roomEdges(rooms)` (logic.ts) and deduped by `segKey`, so a wall shared by two rooms is emitted
|
||||
@@ -192,6 +223,8 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
|
||||
| `houseplan/config/get` | — | `{config, rev}` |
|
||||
| `houseplan/trail/get` | — | `{trails: {marker: {current, previous}}}` — vacuum runs, raw robot coords |
|
||||
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
|
||||
| `houseplan/plan/optimize` | `config`, `layout`, both expected revisions | crash-resumable two-store commit + one-deep backup |
|
||||
| `houseplan/plan/optimize_undo` | both expected revisions | restores backup only before any later edit |
|
||||
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
|
||||
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}], total}` (newest 60) |
|
||||
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
|
||||
@@ -382,16 +415,38 @@ hash falls back to the default.
|
||||
a per-light `clipPath` = zone polygons + doorway sectors, each contour a
|
||||
SEPARATE clipPath child (children union; subpaths of one nonzero path
|
||||
cancel on opposite windings — field bug v1.36.3). Radius: global
|
||||
`settings.glow_radius_cm` + per-marker `glow_radius_cm`.
|
||||
- **Open boundaries** (v1.37): `room.open_to: [roomId]` symmetric links;
|
||||
light zones are connected components (`openZoneOf`); shared stretches
|
||||
computed by collinear-overlap math (`sharedBoundary`) and drawn as a TRUE
|
||||
dash — both room outlines and derived wall segments are trimmed
|
||||
(`outlineWithout`/`cutSegments`).
|
||||
`settings.glow_radius_cm` + per-marker `glow_radius_cm`. On a thick wall the
|
||||
doorway sector is the angular intersection of its near- and far-face clear
|
||||
spans, so the jamb returns clip the spill as a real opening tunnel.
|
||||
- **Open boundaries** (v1.37, revised 2026-08): `space.open_spans` hold
|
||||
geometric virtual stretches; `room.open_to` remains the light-zone index
|
||||
derived from spans (legacy `open_to`-only configs expand to full
|
||||
`sharedBoundary` on read). Shared stretches drawn as a TRUE dash; outlines
|
||||
trimmed (`outlineWithout`/`cutSegments`). In View the dash group is painted
|
||||
before thick wall bodies, letting real jambs mask its centreline ends; in all
|
||||
editors it is painted after the bodies so saved spans and previews remain
|
||||
fully visible. Geometry mutations clip one stored
|
||||
span to **every** surviving shared segment. Adjacent pieces owned by different
|
||||
room pairs stay separate: their midpoints are the source of the corresponding
|
||||
`open_to` links after Split (`AUD-159B7-01`).
|
||||
- **Marker controls** (v1.36): `marker.controls[]` (lights/switches only)
|
||||
toggled as one HA-group-semantics service call on the marker's EXPLICIT
|
||||
tap_action=toggle; icon state/tint mirrors the targets. `primaryEntity`
|
||||
picks in tiers so domain priority beats registry `hidden` (grouped lamps).
|
||||
tap_action=toggle; icon state/tint mirrors the targets.
|
||||
- **Resolved device state** (2026-08-06): HA provides states per entity, not
|
||||
one state per device. `resolvedDeviceStateEntities` therefore starts from
|
||||
uncategorised registry entities, resolves one functional role (whole-device
|
||||
domains, then semantic binary signals, then switches), and aggregates
|
||||
passive readings as the final fallback. `_visualSamples` consumes the full
|
||||
result; `primaryEntity` only selects its first member where a single action
|
||||
target is required. Integration option switches can no longer make an
|
||||
otherwise healthy device working or unavailable merely by list order.
|
||||
- **Resolved light sources** (UX-12): `resolvedLightSources(hass, devices,
|
||||
room?)` is the only light-membership resolver. Per marker the precedence is
|
||||
`controls[]` -> the primary controllable entity when `is_light` is set ->
|
||||
automatic `light.*`. It excludes hidden markers, de-duplicates entity ids,
|
||||
and honours `marker.room_id` before an HA area. Glow, Light fill, room light
|
||||
stats, marker indication/card ordering and group toggle all consume this
|
||||
result instead of maintaining separate domain tests.
|
||||
- **Island rooms** (v1.34): full nesting is legal (`polyContainsPoly`);
|
||||
parents render as evenodd paths with holes (`islandsOf`).
|
||||
- **Kiosk mode** (v1.41): a card-config flag, not a mode — `_setMode` is
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
# The backdrop picture — the spec (source of truth)
|
||||
|
||||
*Owner-approved 2026-08-04. Companion to docs/CANVAS.md (the plane the
|
||||
picture lives on) and docs/RESIZE.md (the other transform frame).*
|
||||
|
||||
Until v1.58.0 an uploaded plan image was nailed to the canvas: centred, at
|
||||
its own proportions, filling the square. If it did not line up with the
|
||||
rooms you had drawn — or with the rooms you were about to draw — the only
|
||||
recourse was to edit the file. And because it filled the canvas, it also
|
||||
decided where the opaque **paper** was, which quietly made a picture plan
|
||||
and a drawn plan two different kinds of thing.
|
||||
|
||||
This closes both. The picture is now **one of the objects on the plane**:
|
||||
it can be moved and scaled like anything else, it counts as content for
|
||||
the frame, and it no longer makes paper of its own.
|
||||
|
||||
---
|
||||
|
||||
## §1 Model — three optional numbers
|
||||
|
||||
A space gains three optional fields, next to `plan_url` / `plan_aspect`:
|
||||
|
||||
| Field | Meaning | Units | Range |
|
||||
| --- | --- | --- | --- |
|
||||
| `plan_x` | offset of the image's **top-left corner** from its default place | normalised (as rooms, decor, openings) | ±`CANVAS_LIMIT` (±5000) |
|
||||
| `plan_y` | the same, vertically | normalised | ±`CANVAS_LIMIT` |
|
||||
| `plan_scale` | **one** uniform multiplier for both sides, anchored at the (already offset) top-left corner | multiplier | `0.01 … 100` |
|
||||
|
||||
There is **no rotation** and no independent width/height: the picture keeps
|
||||
its own aspect ratio for ever, which is what makes "scale" a single number.
|
||||
|
||||
**The absence of all three is exactly the pre-v1.58.0 behaviour.** No
|
||||
migration runs, nothing is written into anybody's store, and a plan that has
|
||||
never been through the backdrop editor renders bit-identically. Junk (a
|
||||
string, `NaN`, a zero or negative scale) is treated as absence, not as an
|
||||
error — the same reading rule the rest of the geometry uses.
|
||||
|
||||
One function owns the arithmetic, `planRect()` in `src/space-geometry.ts`:
|
||||
|
||||
```
|
||||
base = fitInSquare(plan_aspect, 1000) // the centred default
|
||||
rect.x = base.x + plan_x * 1000
|
||||
rect.y = base.y + plan_y * 1000
|
||||
rect.w = base.w * plan_scale
|
||||
rect.h = base.h * plan_scale
|
||||
```
|
||||
|
||||
`spaceModels()` calls it, so **every** consumer — the full card, the static
|
||||
`houseplan-space-card`, the kiosk, the content frame — reads the same
|
||||
rectangle. The card's model cache key (`_cfgFingerprint`) carries the three
|
||||
fields, so a drag invalidates the memo.
|
||||
|
||||
`validation.py` mirrors the ranges (`PLAN_SCALE_MIN` / `PLAN_SCALE_MAX`
|
||||
alongside `CANVAS_LIMIT`). Detaching the image (space dialog → «Нарисую
|
||||
комнаты вручную») deletes all three: there is nothing left for them to
|
||||
describe, and a stale transform must not silently apply to the next upload.
|
||||
|
||||
---
|
||||
|
||||
## §2 The transform frame
|
||||
|
||||
**Where.** The **backdrop editor** (`mode: 'decor'`, «Редактор подложки»).
|
||||
The frame is on screen as soon as that editor opens (tools **select** and
|
||||
**«Картинка-подложка»**); the drawing tools own the drag, so the frame
|
||||
steps aside for them. Nowhere else: View, the Plan editor, the Device
|
||||
editor and the kiosk never touch the picture. A space with no image never
|
||||
grows a frame, and its toolbar is unchanged.
|
||||
|
||||
**What.** A dashed outline on the picture's rectangle plus four corner
|
||||
handles. **Two radii, one gesture:** the HIT circle is 2 % of the visible
|
||||
view — finger-sized at any zoom, the same rule the vacuum fit frame uses —
|
||||
and is invisible; the bead you actually see (`.bdknob`) is a **quarter** of
|
||||
it (owner, 2026-08-05: «уменьшить в 4 раза… они постоянно гигантские»). The
|
||||
clickable area is unchanged; only the ink shrank, so the frame stops
|
||||
covering the picture it frames. The same split now governs the text block
|
||||
(docs/LIVE-TEXT.md §3), the room-resize corners and the vacuum fit. The
|
||||
outline never takes a pointer; the handles do, with `setPointerCapture` and
|
||||
`nwse-resize` / `nesw-resize` cursors. Under the picture's own tool the
|
||||
stage cursor is `grab`, and `grabbing` while a gesture is live.
|
||||
|
||||
**Move** — press anywhere inside the picture and drag, **under the
|
||||
«Картинка-подложка» tool**. Why a tool and not just the select tool: the
|
||||
picture's body is most of the screen, and claiming it would take away the
|
||||
one-finger pan of the whole plane, which the owner asked for on 2026-08-04
|
||||
(«таскать план при любом масштабе») and `smoke_pan_any_zoom` guards. The
|
||||
corner handles are precise targets and need no such protection — they are
|
||||
live whenever the frame is. The gesture is also only started INSIDE the
|
||||
rectangle, so pressing beside the picture pans even under its own tool.
|
||||
|
||||
**…and that tool is ARMED when the editor opens** (owner, 2026-08-05: «не
|
||||
получается двигать картинку-подложку в режиме редактора подложки»). The
|
||||
frame is drawn on open, which promises a draggable picture, while the
|
||||
promise was only kept once the user found a tool they had no reason to look
|
||||
for. So `_setMode('decor')` selects `backdrop` instead of `select` whenever
|
||||
the space HAS a picture — the editor is named after it. Nothing else
|
||||
changes: `select` is one click away and still leaves the body to the pan, a
|
||||
space with no picture still opens on `select`, and Esc still returns to
|
||||
`select`. This is a discoverability fix, not a new gesture.
|
||||
|
||||
**Nothing may rescale the stage mid-gesture.** Two things wanted to, and
|
||||
both moved the picture away from the finger before they were stopped:
|
||||
|
||||
* the picture is a content item, so dragging it GROWS the editor's content
|
||||
frame, which rescales the view — `_frameOf()` therefore returns the
|
||||
memoized frame untouched while `_bdDrag` is live, and catches up on
|
||||
release;
|
||||
* «Вернуть картинку» appears the moment the picture has moved, and the
|
||||
toolbar sits above the stage — so it is withheld until the gesture ends.
|
||||
|
||||
**Scale** — pull a corner. Uniform, about the **opposite** corner, which
|
||||
stays exactly where it was (the mechanic borrowed from the vacuum
|
||||
calibration panel). `k = max(|dx| / base.w, |dy| / base.h)`, so the gesture
|
||||
follows whichever axis you pulled harder, and both sides then follow `k`.
|
||||
|
||||
**Nothing else moves.** Rooms, openings, devices, decor and room labels are
|
||||
untouched by both gestures — this is a picture-only transform, and the
|
||||
smoke asserts the room geometry is byte-identical afterwards.
|
||||
|
||||
**Undo** is the «Вернуть картинку» button in the backdrop toolbar: it
|
||||
appears only once the picture HAS been moved, and it deletes the three
|
||||
fields (back to centred, own size).
|
||||
|
||||
---
|
||||
|
||||
## §3 THE PAPER IS THE ROOMS — and only the rooms
|
||||
|
||||
*This is a behaviour CHANGE, deliberately.*
|
||||
|
||||
The opaque "paper" exists so the scene background — `bg_color`, or the
|
||||
`daynight` sky (docs/SUN.md) — never bleeds through the plan itself. Until
|
||||
v1.58.0 it had two forms: the ROOM CONTOURS for a drawn plan, and **the
|
||||
image rectangle** for a picture plan.
|
||||
|
||||
The second form is gone. The paper is now `paperRoomShapes(space.rooms)`
|
||||
in every case (or `paperRoomShapesWithWalls` when a space carries wall
|
||||
thickness — docs/WALL-THICKNESS.md §4 — so the sheet grows under shared
|
||||
slabs and the scene never shows through a hatched body):
|
||||
|
||||
* one opaque shape per room, in exactly the room's own geometry — an
|
||||
L-shaped house or a pair of detached buildings never grows a bounding
|
||||
rectangle;
|
||||
* an empty space has **no paper at all**, image or no image;
|
||||
* its colour is unchanged (`styles.ts .hp-paper`): white on a hand-drawn
|
||||
plan (`.stage.noplan`), the theme card background where an image is
|
||||
attached in **View**; every editor (plan / devices / decor) forces white
|
||||
under the grid even with a backdrop, so the drawing sheet stays the same
|
||||
sheet as a hand-drawn plan. `daynight` dims it via the `.zoomwrap`
|
||||
brightness filter only — its alpha stays 1.
|
||||
|
||||
**Layer order**, top to bottom, in both renderers:
|
||||
|
||||
```
|
||||
devices / labels
|
||||
sun wedges and rays
|
||||
walls, openings, room fills, decor
|
||||
THE PICTURE ← above the paper, below the geometry
|
||||
the paper (room contours)
|
||||
the scene background (bg_color / the daynight sky)
|
||||
```
|
||||
|
||||
**The consequence, and the owner knows it:** a picture with transparency
|
||||
over a space with no rooms drawn shows the scene background through itself.
|
||||
That is the honest reading of "the paper belongs to the rooms" — draw the
|
||||
rooms and the paper appears under them.
|
||||
|
||||
---
|
||||
|
||||
## §4 The picture is content
|
||||
|
||||
`contentItems()` has always counted the image rectangle as one content item
|
||||
alongside the rooms; it now counts the **transformed** one. So the frame,
|
||||
«Вписать всё», the zoom floor and the pan slack (docs/CANVAS.md §4–§5) all
|
||||
follow the picture wherever it is dragged, and a picture scaled down to a
|
||||
quarter tightens the frame around it instead of framing empty plane.
|
||||
|
||||
`iconUnit()` deliberately does **not** count it: an icon is a percentage of
|
||||
the drawn plan (docs/CANVAS.md §6), and scaling the backdrop must not
|
||||
resize every marker on it.
|
||||
|
||||
---
|
||||
|
||||
## §5 Snap
|
||||
|
||||
Both gestures obey the canvas snap contract (docs/CANVAS.md §9):
|
||||
|
||||
| Gesture | What is snapped |
|
||||
| --- | --- |
|
||||
| move | the resulting **top-left corner** — the anchor, not the delta, so one drag is enough to put a legacy off-grid picture onto the lattice |
|
||||
| scale | the dragged corner **along the picture's longer side**; the scale is then read back off it |
|
||||
|
||||
The fixed corner of a scale keeps its (on-grid) place and the long side
|
||||
lands on a node. The short side follows from the aspect ratio and generally
|
||||
does **not** — that is what "uniform, no stretch" costs, and it is the
|
||||
right trade: a picture whose proportions drift is a broken picture.
|
||||
|
||||
There is no modifier that suspends positional snapping (§9.4). The
|
||||
`±CANVAS_LIMIT` clamp rides along — `_snap()` and `clampCanvasN` are the
|
||||
only bound either gesture has.
|
||||
|
||||
---
|
||||
|
||||
## §6 Live measurements
|
||||
|
||||
While the picture is dragged or scaled, one badge rides its centre with the
|
||||
picture's **real size**, width × height, through the space's `cell_cm` and
|
||||
the HA unit system (metres or feet). It is the same `_fmtLen`
|
||||
(`segmentCm` → `formatLength`) and the same `.measurelabel` element the
|
||||
wall ruler, the opening shoulders and the room resize use — the card states
|
||||
a length exactly one way.
|
||||
|
||||
---
|
||||
|
||||
## §6b One tool at a time — a drawing tool owns the canvas
|
||||
|
||||
The backdrop editor has seven tools, and they are **exclusive**: only
|
||||
**select** talks to an existing shape, only **erase** deletes one, only
|
||||
**«Картинка-подложка»** grabs the picture's body, and the four drawing
|
||||
tools (**line**, **rect**, **ellipse**, **text**) do nothing but create.
|
||||
|
||||
The rule exists because of a concrete failure (owner, 2026-08-04): *«нельзя
|
||||
поставить начало линии на конец другой — при клике на конец выделяется
|
||||
первая линия, а не начинает рисоваться новая»*. The shape's own
|
||||
`pointerdown` handler ran first, stopped the event and did nothing under a
|
||||
drawing tool, so the press never reached the stage and no draft was born —
|
||||
exactly on the one spot where a new line most wants to start, since §5's
|
||||
snap anchors include every line end.
|
||||
|
||||
So, under a drawing tool (and under the picture tool):
|
||||
|
||||
* `.dshape` is `pointer-events: none` — an existing figure is not a target
|
||||
at all, and the cursor is the stage's;
|
||||
* `_decorShapeDown` returns before `stopPropagation` for any tool that is
|
||||
not select/erase, so a synthetic press cannot swallow the gesture either;
|
||||
* `_decorPointerDown` only looks for a shape under the pointer for
|
||||
select/erase, and otherwise starts the draft at the pressed point;
|
||||
* the transform frame's corner handles stay off (§2) — they were the first
|
||||
thing taught not to swallow the first point of a line.
|
||||
|
||||
Selecting, moving and the text dialog's double-click therefore live in the
|
||||
select tool only. `demo/smoke_decor.mjs` asserts both halves: a press on an
|
||||
existing line starts a new line **from that end** under the line tool, and
|
||||
still selects and grabs it under select.
|
||||
|
||||
---
|
||||
|
||||
## §7 What is deliberately NOT done
|
||||
|
||||
* **No rotation.** A skewed scan is a scan to re-take, not a plan to skew
|
||||
the whole editor's maths for.
|
||||
* **No non-uniform scale.** See above: the picture keeps its ratio.
|
||||
* **No numeric fields** in the space dialog. The frame is the interface;
|
||||
the numbers exist for the config, not for typing.
|
||||
* **No per-room clipping of the picture.** It is one rectangle.
|
||||
* **No transform for the sun's paper/wedges.** Those follow the rooms, and
|
||||
now so does the paper — one rule, no second geometry.
|
||||
|
||||
---
|
||||
|
||||
## §8 Where it lives
|
||||
|
||||
| Concern | File |
|
||||
| --- | --- |
|
||||
| the rectangle (`planRect`, `PLAN_SCALE_MIN/MAX`), content items | `src/space-geometry.ts` |
|
||||
| the frame, the gestures, the live badge, the reset | `src/houseplan-card.ts` (`_bd*`, `_renderBackdropFrame`) |
|
||||
| the frame's look, the paper, the cursors | `src/styles.ts` (`.bdframe`, `.hp-paper`) |
|
||||
| the static card's copy of the paper + picture order | `src/space-render.ts` |
|
||||
| the stored ranges | `custom_components/houseplan/validation.py` |
|
||||
| unit coverage | `test/backdrop.test.mjs` |
|
||||
| DOM coverage | `demo/smoke_backdrop.mjs`, `demo/smoke_bg_color.mjs` §11 |
|
||||
| screenshots | `demo/shot_backdrop.mjs` |
|
||||
+520
@@ -0,0 +1,520 @@
|
||||
# Infinite canvas — the spec (source of truth)
|
||||
|
||||
Status: approved by the owner 2026-08-03, **shipped in v1.57.0**.
|
||||
Scope decisions final: there is no "plan size" any more, the canvas
|
||||
is conceptually unbounded, storage does not change, and the
|
||||
opening view is always derived from what is actually drawn.
|
||||
|
||||
## The problem it closes
|
||||
|
||||
Several users drew plans that ran past the edge of the grid and then
|
||||
could not place devices outside it. The only workaround was to redraw
|
||||
the whole plan. The card behaved as if the normalised unit square
|
||||
(`0..1`, rendered as `NORM_W x NORM_W = 1000 x 1000` units) were a
|
||||
sheet of paper with edges. It is not a sheet of paper — it is just the
|
||||
coordinate system.
|
||||
|
||||
## Principle
|
||||
|
||||
1. **Coordinates keep their meaning.** Rooms, openings, decor and
|
||||
device positions are still stored normalised. `1.0` is still the
|
||||
same distance it always was; `cell_cm` still ties a grid cell to
|
||||
real centimetres. **No data migration.** An existing plan opens as
|
||||
before — including the size of the markers (§6).
|
||||
2. **`0..1` is not a boundary, it is an origin.** Any finite
|
||||
coordinate is legal. `2.7` simply means "2.7 canvas widths to the
|
||||
right of the origin".
|
||||
3. **Nothing in the product may say "you cannot go past the edge".**
|
||||
No clamp on drawing, dragging, decor or device placement stops at
|
||||
a rectangle.
|
||||
4. **What is stored is only where something is drawn.** There is no
|
||||
stored extent to keep in sync.
|
||||
|
||||
## Model
|
||||
|
||||
| Concept | Before | Now |
|
||||
| --- | --- | --- |
|
||||
| Canvas | square `0..1`, rendered `0..1000` | unbounded plane, same units |
|
||||
| `space.view_box` | the frame; everything was clamped into it | an OPTIONAL hint for the very first frame; used only when there is nothing to frame |
|
||||
| "fit" rectangle | `view_box` (or the content bbox in view mode) | always the **content frame** (§4) |
|
||||
| Zoom out floor | `ZOOM_MIN = 0.4` (fraction of `view_box`) | 3x the content frame (`MIN_ZOOM = 1/3`) |
|
||||
| Pan bounds | content must cover the scene, and only above 100% zoom | content frame + one screen of slack in each direction, at any zoom |
|
||||
| Icon size | % of `view_box`, i.e. grew with zoom | % of `iconUnit` — still grows with zoom (§6) |
|
||||
| Validation range | `+/-4` | `+/-5000` (§3) |
|
||||
|
||||
### Render frame vs. view
|
||||
|
||||
* **Frame** (`_baseVb()` / `spaceFrame()`) — the rectangle that "fit to
|
||||
screen" fits and that zoom `1` means. It is recomputed from content,
|
||||
never stored.
|
||||
* **View** (`_view`) — the SVG `viewBox` actually painted. It is in
|
||||
absolute render units, so recomputing the frame never teleports the
|
||||
plan; it only changes what zoom `100 %` means and where panning
|
||||
stops.
|
||||
|
||||
## §3 Validation limits
|
||||
|
||||
`custom_components/houseplan/validation.py`:
|
||||
|
||||
| Symbol | Before | Now | What it is |
|
||||
| --- | --- | --- | --- |
|
||||
| `_COORD` (layout x/y) | `-4 .. 4` | `-5000 .. 5000` | coordinate |
|
||||
| `_GEOM` (room x/y, poly points, opening x/y, `view_box` origin) | `-4 .. 4` | `-5000 .. 5000` | coordinate |
|
||||
| `_EXTENT` (room w/h, `view_box` w/h) | `0.001 .. 4` | `0.001 .. 5000` | size — strictly positive |
|
||||
| `_NORM` (decor x/y/w/h) | `-1 .. 2` | `-5000 .. 5000` | coordinate |
|
||||
| opening `length` | `0.001 .. 1` | `0.001 .. 5000` | size — strictly positive |
|
||||
|
||||
`+/-5000` is **garbage insurance, not a frame**. At the product's own
|
||||
scale (`cell_cm` = 5 by default, 240 grid cells across the unit width)
|
||||
one canvas width is ~12 m, so `5000` is ~60 km of plan — unreachable
|
||||
in a home, while still stopping a stored `1e100` from making the plan
|
||||
invisible for every client (the failure HP-1500-03 / HP-1501-01
|
||||
closed). Sizes stay strictly positive because SVG divides by them and
|
||||
`viewBox="0 0 0 0"` paints nothing (HP-1502-01).
|
||||
|
||||
## §4 The content frame
|
||||
|
||||
`contentFrame(items, opts)` in `src/space-geometry.ts` — pure, unit
|
||||
tested. Input is a list of **items**, one per drawn/placed object:
|
||||
|
||||
* every room (its own bounding box — polygon or legacy rect);
|
||||
* the backdrop image rectangle, when the space has one;
|
||||
* every opening (door/window) end-to-end segment;
|
||||
* every decor shape;
|
||||
* every device the layout actually places in this space **and that the card
|
||||
actually draws** — a HIDDEN device (docs/FILTERING.md) is not content: the
|
||||
frame is presentation, and an object nobody can see must not decide what the
|
||||
plan opens on. It keeps its auto-grid cell and every aggregation it feeds;
|
||||
it simply is not an item here. Ghosts in the device editor are not items
|
||||
either — reaching them is §5's job, not the opening view's.
|
||||
|
||||
Output:
|
||||
|
||||
```ts
|
||||
{ core: Rect | null, all: Rect | null, outliers: number }
|
||||
```
|
||||
|
||||
* `core` — bbox of the **main mass**, padded. This is the opening view.
|
||||
* `all` — bbox of **everything**, padded. This is what "show the far
|
||||
objects" fits.
|
||||
* `outliers` — how many items were left out of `core`.
|
||||
|
||||
Both rectangles are padded by `pad` (default `0.05`) of the longer
|
||||
side, and degenerate axes are inflated (see §4.2).
|
||||
|
||||
For a space with a **backdrop image** the image rectangle is one of the
|
||||
items, so the image still sets the extent — cropping to the rooms would
|
||||
hide the parts of the picture nobody has outlined yet (owner, point 2).
|
||||
|
||||
Fallback order when there are no items at all: the stored `view_box`
|
||||
(the "hint"), then the legacy unit square. This is the only place
|
||||
`view_box` is still read for framing.
|
||||
|
||||
### §4.1 Outlier rejection
|
||||
|
||||
An object standing an order of magnitude further away than the rest
|
||||
must not decide the opening view, but must still be reachable. The
|
||||
criterion is deliberately rank-based (medians/percentiles), so a single
|
||||
absurd value cannot move it:
|
||||
|
||||
1. Items whose coordinates fall outside the sane range
|
||||
(`+/-CANVAS_LIMIT`, i.e. the same `+/-5000` the backend accepts) are
|
||||
dropped outright — that is corruption, not content.
|
||||
2. With fewer than `MIN_VOTERS = 4` items no outlier is declared:
|
||||
with two objects there is no majority to be far from.
|
||||
3. `m` = component-wise **median** of the item centres.
|
||||
4. `d_i` = Chebyshev distance `max(|x_i-m_x|, |y_i-m_y|)` from `m`.
|
||||
5. `spread` = the **75th percentile** of `d`, floored at
|
||||
`MIN_SPREAD = 0.05 * NORM_W` (50 render units, about a small room),
|
||||
so a tightly clustered plan does not call its own neighbour an
|
||||
outlier.
|
||||
6. Item `i` is an outlier iff `d_i > OUTLIER_K * spread`, with
|
||||
`OUTLIER_K = 10` — literally "an order of magnitude further than
|
||||
the bulk".
|
||||
7. **Majority veto**: if more than a third of the items came out as
|
||||
outliers, this is not a plan with strays — it is a spread-out plan.
|
||||
No outliers are declared and `core = all`.
|
||||
|
||||
When `outliers > 0` the card shows an unobtrusive inline hint (no
|
||||
modal) — "there are objects far from the plan" with a **Show** action
|
||||
that fits `all`.
|
||||
|
||||
Whatever the vote rejects is rejected **everywhere the plan is measured**,
|
||||
not only in the viewBox: §6's `iconUnit` runs the same vote over the rooms.
|
||||
One notion of "the plan", or a stray the frame had just thrown out came back
|
||||
as icons ninety times too big (audit DEV-2C947-03).
|
||||
|
||||
### §4.3 The frame in an editor
|
||||
|
||||
Inside an editor the frame only ever **grows** (`unionRect` with the previous
|
||||
one): it bounds pan and defines what zoom 1 means, and a frame that shrank the
|
||||
instant a room was deleted would move the ground under a live gesture.
|
||||
|
||||
That union belongs to the editor session and to nothing else. The memo key
|
||||
carries the growth flag, so leaving for View recomputes the frame from the
|
||||
content instead of inheriting the union — otherwise a room dragged five
|
||||
canvases away in the Plan editor kept View framing the empty ground it had
|
||||
left behind, until some unrelated model change happened to invalidate the memo
|
||||
(audit DEV-2C947-02).
|
||||
|
||||
### §4.2 Degenerate frames
|
||||
|
||||
An SVG `viewBox` with a zero axis paints nothing, so a frame still has
|
||||
a floor:
|
||||
|
||||
* an axis shorter than `DEGENERATE = 0.03 * NORM_W` is grown to
|
||||
`FLOOR = 0.2 * NORM_W`, centred on itself.
|
||||
|
||||
That covers "one lone marker" and "a collinear row of markers". A real
|
||||
thin shape (a 100-unit corridor) is well above the threshold and keeps
|
||||
its tight frame. This is the only survivor of the old safety props —
|
||||
the `-25 % .. 125 %` envelope that used to reject far content is gone,
|
||||
replaced by §4.1 (the envelope WAS the bug: content past the old square
|
||||
was silently excluded from the frame).
|
||||
|
||||
## §5 Zoom and pan
|
||||
|
||||
* **Zoom in** — unchanged, `ZOOM_MAX = 8`.
|
||||
* **Zoom out** — `MIN_ZOOM = 1/3`: you can see three times the content
|
||||
frame and no further. Empty space beyond that is not information.
|
||||
* **Pan** — available at **every zoom**, in view mode and in every
|
||||
editor, and bounded by the content frame inflated by
|
||||
`PAN_SLACK = 1.0` of `max(view, frame)` on each side. You can walk
|
||||
off the plan (there is no edge), but not into infinity.
|
||||
Until 2026-08-04 a drag moved the view only while `zoom > 1`: on the
|
||||
old bounded canvas a plan smaller than the scene genuinely had
|
||||
nowhere to go, so the gate was harmless. With no edge left it was
|
||||
simply a missing feature, and the owner reported it as one
|
||||
(«таскать план при любом масштабе»). The zoom no longer takes part
|
||||
in the decision — `_clampView` alone says how far you may walk.
|
||||
* **Who owns the pointer.** A drag pans only when it starts on empty
|
||||
scene: the room-resize handles, device badges, openings, room labels
|
||||
and the decor shapes take the pointer first (`_stagePointerDown`
|
||||
bails out on them), and a drawing tool that consumes the press —
|
||||
decor line/rect/ellipse/text — bails out too. Two fingers are always
|
||||
a pinch, never a pan. On a **kiosk** screen at swipe zoom (`≤ 1`,
|
||||
more than one space) a *horizontal* drag belongs to the floor swipe:
|
||||
the gesture is classified once, on the first movement past 8 px, and
|
||||
keeps that role until the finger lifts (`_panLock`), so the plan
|
||||
never slides under a swipe and a vertical drag still pans.
|
||||
* **The lock is final, at the release too** (audit DEV-1DA1-02). The
|
||||
release used to ask `swipeTarget()` again from the raw start→end
|
||||
vector, ignoring the lock — so a *curved* gesture (a short vertical
|
||||
lead-in that locks `pan`, then a long horizontal sweep) dragged the
|
||||
plan under the finger and still landed on another storey when it
|
||||
lifted: the worst kind of surprise on a wall tablet. `_panLock ===
|
||||
'pan'` now means no floor change, whatever the overall vector ends up
|
||||
looking like; only a gesture locked as `swipe` may reach
|
||||
`swipeTarget()`, and it never pans on the way. A motionless tap locks
|
||||
nothing, so the double-tap zoom reset is untouched.
|
||||
* **"Home is that way" arrow** — when the content frame is entirely
|
||||
outside the current view, a small pointer appears at the view edge
|
||||
in the frame's direction. Clicking it fits the content. Cheap
|
||||
insurance against getting lost in the empty plane.
|
||||
|
||||
## §6 Icon size — a percentage of the plan
|
||||
|
||||
**Unchanged behaviour** — an icon scales with the plan, exactly as it
|
||||
always did. (A first cut of the infinite canvas made it a fixed
|
||||
percentage of the viewport; the owner looked at it on 2026-08-03 and
|
||||
asked for the original back. The history is kept here because the
|
||||
reasoning for the *numerator* below is the whole point.)
|
||||
|
||||
Before the infinite canvas:
|
||||
|
||||
```
|
||||
--icon-size: iconPct * vb.w / view.w (cqw)
|
||||
```
|
||||
|
||||
Now (`iconCqw()` in `src/space-geometry.ts`, pure and unit tested):
|
||||
|
||||
```
|
||||
--icon-size: iconPct * iconUnit(space) * kioskScale / view.w (cqw)
|
||||
```
|
||||
|
||||
Read it in render units: a marker always occupies
|
||||
`iconPct/100 * iconUnit` **render units** of the plan, whatever the
|
||||
frame and whatever the zoom. Dividing by the width of the visible view
|
||||
turns that into the percentage of the container `cqw` means. Zoom in
|
||||
2x and the marker is 2x bigger, together with the walls it sits on.
|
||||
|
||||
**Why the numerator changed.** `vb.w` was the stored `view_box`, and
|
||||
`view_box` is not a frame any more (§4). Keeping a fixed `NORM_W`
|
||||
there would have been worse than wrong: on a plan drawn 2 canvases
|
||||
wide the frame is ~2.2 canvases, so every marker would come out 2.2x
|
||||
smaller than on an ordinary plan — and 55x smaller on a plan 50
|
||||
canvases out, i.e. an invisible dot. `iconUnit(space) =
|
||||
max(NORM_W, mainMassOfTheRooms)` — the extent of the rooms **after the same
|
||||
outlier vote §4.1 applies to the frame** (rooms only, so the full card and the
|
||||
static card cannot drift apart), and it is:
|
||||
|
||||
* **exactly `NORM_W` for every plan that fits the old square**, and the
|
||||
editor has only ever stored `view_box: [0,0,1,1]`, so `iconUnit ===
|
||||
vb.w` there and the rendered pixel size is bit-identical to the
|
||||
pre-canvas card (verified against the v1.56.0 bundle at a fixed view:
|
||||
`3.400 / 3.091 / 6.182 / 12.364 cqw`, i.e. `28.52 / 26.11 / 50.22 /
|
||||
98.44 px`, both bundles);
|
||||
* **proportional to an outsized plan**, so a runaway plan gets markers
|
||||
of the same apparent size as an ordinary one.
|
||||
|
||||
Everything else is untouched: the per-device multiplier `marker.size`
|
||||
and the kiosk icon/font scales still feed `--dev-size`, and every
|
||||
satellite (badges, LQI chips, presence rings, ripples) still derives
|
||||
from `--dev-size`. The full card and the static
|
||||
`houseplan-space-card` call the same `iconCqw()` — the static card has
|
||||
no zoom, but its frame is the content now, so a bare `iconPct` would
|
||||
have made its markers shrink as the frame tightened.
|
||||
|
||||
**Auto-placement spacing** (`defaultPositions` -> `declump`) is measured
|
||||
in render units and uses the same `iconUnit`, so the icon's footprint
|
||||
and the distance markers are pushed apart by can never drift apart — and
|
||||
the outlier vote reaches the spacing through the very same call.
|
||||
|
||||
## §7 Adaptive grid
|
||||
|
||||
The drawing grid is a dot pattern at `pitch = NORM_W / GRID_N`. On a
|
||||
plan several canvases wide, zoomed out, the dots merged into a grey
|
||||
wash. `gridLevels(pitch, pxPerUnit, minPx)` (pure, unit tested) picks:
|
||||
|
||||
* `fine` — the smallest multiplier from `1, 2, 5, 10, 20, 50, 100,
|
||||
200, 500, 1000` whose on-screen step is at least `minPx` (7 px);
|
||||
finer dots are simply not drawn;
|
||||
* `coarse` — the next multiplier that is at least `5 x fine`, drawn
|
||||
bigger/darker, so the eye keeps a scale reference (the usual CAD
|
||||
every-5th/10th-line convention);
|
||||
* `null` when even the coarsest step would be sub-pixel — then there
|
||||
is no grid at all rather than a grey fog.
|
||||
|
||||
The grid rectangle also follows the **view**, not the old `view_box`,
|
||||
so it is there wherever you pan.
|
||||
|
||||
The dots are a **hint, not content** (owner 2026-08-04): at full strength
|
||||
they argued with the plan on white paper. `.griddot` is `opacity: 0.35`,
|
||||
`.griddot.major` `opacity: 0.5` (styles.ts) — both muted, the CAD
|
||||
hierarchy kept, coarse nodes still denser than fine ones. The grid lives
|
||||
in the editors only; View never draws it (`smoke_grid_fade`).
|
||||
|
||||
## §8 Toolbar
|
||||
|
||||
The middle button of the zoom control was "Reset zoom" (`_resetZoom`,
|
||||
disabled at zoom 1). It **is** the fit-everything action, so it was
|
||||
re-labelled rather than duplicated: `title.zoom_fit` — "Fit all" /
|
||||
«Вписать всё», icon unchanged (`mdi:fit-to-page-outline`), and it is no
|
||||
longer disabled at zoom 1 (at zoom 1 off-centre it still has work to
|
||||
do). It fits `core` — the same rectangle the plan opens with. Far
|
||||
objects are reached through the outlier hint's **Show** action, which
|
||||
fits `all`.
|
||||
|
||||
## §9 Drag limits and the snap contract (dev, DEV-B58)
|
||||
|
||||
### 9.1 One bound, and it is the backend's
|
||||
|
||||
v1.57.0 freed the FRAME and the DRAWING, but not the drag handlers. Two
|
||||
of them still clamped, and the owner and a user hit both:
|
||||
|
||||
| Handler | Old clamp | Effect |
|
||||
| --- | --- | --- |
|
||||
| `_pointerMove` (device marker) | `_baseVb()` ± a 0.8 % inset — the CONTENT FRAME | a marker could never be dragged past the outline of what was already drawn, so a plan could not be extended by putting a device where the next room was going to be |
|
||||
| `_labelMove` (room label) | `_spaceModel().vb` — the space's STORED `view_box` | worse: that is `[0,0,1,1]` for every plan the card has ever written, i.e. literally the old square. A room drawn at 2.5 had a name that could not reach its own room |
|
||||
| `_decorCommitDraft` / decor text anchor | *none at all* | asymmetric with `_decorMoveUpdate`, which did clamp — a draft could be born outside the range the mover then refused to leave |
|
||||
|
||||
The rule now: **an editor gesture has exactly one bound, `±CANVAS_LIMIT`
|
||||
(±5000 normalised, ±`SANE_LIMIT` in render units), and it is the same
|
||||
number `validation.py` enforces.** It is a garbage limit — insurance
|
||||
against a stored `1e100` — and never a frame. `clampCanvasR` /
|
||||
`clampCanvasN` (`space-geometry.ts`) are the only two functions that may
|
||||
impose it, and `_snap()` applies `clampCanvasR` on the way out, so every
|
||||
gesture that goes through the snap is bounded by construction.
|
||||
|
||||
Room drawing, split, resize and opening placement had no clamp before
|
||||
and still have none of their own — they inherit it from `_snap()` /
|
||||
`clampCanvasN` at the write.
|
||||
|
||||
### 9.2 The grid step never changed
|
||||
|
||||
`_gridPitch = NORM_W / GRID_N = 1000 / 240`. Both constants; neither
|
||||
depends on the content frame, the view, the zoom, `view_box`, or
|
||||
`cell_cm`. `git log -S` confirms neither has been touched since v1.4.0
|
||||
(the one historical change, `GRID_N` 120 → 240, halved the step, so the
|
||||
old nodes stayed a subset of the new ones and every position was
|
||||
preserved).
|
||||
|
||||
**So the infinite canvas did NOT move any existing element off the
|
||||
grid.** `gridLevels()` (§7) chooses which multiples of that pitch are
|
||||
still legible at the current zoom — it changes what is DRAWN, never what
|
||||
is SNAPPED TO. An element that looks off-grid is off-grid because it was
|
||||
placed by something that never snapped, not because the lattice moved.
|
||||
|
||||
### 9.3 What snaps, and to what
|
||||
|
||||
Two kinds of element, because a door rounded to a grid node while its
|
||||
wall runs diagonally is broken geometry, not a tidy plan:
|
||||
|
||||
**GRID-BOUND — rounded to the nearest node:**
|
||||
|
||||
| Element | Where |
|
||||
| --- | --- |
|
||||
| the backdrop picture: move (its top-left corner) and corner scale (the dragged corner, along the longer side) | `_bdMove` → `_snap` / `snapToGrid` (docs/BACKDROP.md §5) |
|
||||
| room vertices (draw tool) | `_markupClick` → `_snap` |
|
||||
| split tool's interior vertices | `_splitClick` → `_snap` |
|
||||
| resize edge drag / corner scale | `_rszMove` → `_snap` |
|
||||
| decor draft endpoints, text anchor | `_decorPointerDown` / `_stagePointerMove` → `_snap` |
|
||||
| decor move | `_decorMoveUpdate` → `_snap` of the resulting ANCHOR |
|
||||
| device markers | `_savePos` |
|
||||
| room labels | `_labelMove` → `_savePos` |
|
||||
| auto-placed markers (`defaultPositions`), the `spaceCenter` fallback, an undragged room label | `snapPt` |
|
||||
|
||||
**WALL-BOUND — projected onto the wall, then the offset ALONG the wall
|
||||
quantised to the same step, measured from the wall's first corner:**
|
||||
|
||||
| Element | Where |
|
||||
| --- | --- |
|
||||
| openings, placed and dragged | `snapToWall(..., { step, length })` |
|
||||
| split tool's points ON a wall | `snapPointAlongPoly` |
|
||||
|
||||
On an axis-aligned wall whose corners are on the grid — every wall the
|
||||
editor itself draws — the two rules give the same point. An opening is
|
||||
also kept inside its wall by half its own length.
|
||||
|
||||
Three things were fixed here besides the new coverage:
|
||||
|
||||
* `_decorMoveUpdate` used to snap the **delta**, which preserves any
|
||||
off-grid offset the shape already had for ever, one step at a time.
|
||||
It snaps the resulting anchor now, so one drag is enough.
|
||||
* `defaultPositions` / `labelPos` / the `spaceCenter` fallback placed
|
||||
auto elements at centroids, which are not nodes for an odd-sized or
|
||||
polygonal room. These were the most likely source of "some elements
|
||||
are between the points" on an untouched plan.
|
||||
* `snapToGrid` and `snapR` now return a value that is already on a node
|
||||
**bit-identical**. The round trip through a non-dyadic pitch
|
||||
(1000/240) otherwise turns an exact `500` into `500.00000000000006`,
|
||||
and "is this on the grid?" starts answering no.
|
||||
|
||||
### 9.4 Shift
|
||||
|
||||
There is no free-position mode. `Shift` never suspends coordinate
|
||||
snapping: free anchors always land on grid nodes, and wall-bound objects
|
||||
always remain projected and quantised along their wall. `Shift` is
|
||||
reserved for explicitly angular controls (fine decor rotation and the
|
||||
compass step) and cannot create an off-grid object.
|
||||
|
||||
### 9.5 «Оптимизировать планы» — explicit whole-plan maintenance
|
||||
|
||||
Existing and imported plans may still hold coordinates between the
|
||||
nodes. New editor operations cannot create more. General settings contain
|
||||
a **Plan maintenance** group whose action previews and then repairs old
|
||||
data through all current passes: model upgrades, mandatory grid
|
||||
alignment, exact open-span canonicalisation and wall-interval compaction.
|
||||
|
||||
Why an action rather than a silent migration:
|
||||
|
||||
1. It moves the user's data without asking. A house plan is a drawing;
|
||||
the card has no mandate to redraw it on a version bump.
|
||||
2. A silent migration is unattributable. When a room looks 3 cm wrong
|
||||
the owner cannot tell whether the card did it or they did.
|
||||
3. An update that touches stored geometry cannot be rolled back by
|
||||
downgrading the card. The explicit action has a one-deep snapshot and
|
||||
can also simply not be pressed.
|
||||
|
||||
`optimizePlans(config, layout)` (`src/plan-optimizer.ts`) is the pure
|
||||
orchestrator. It converts only legacy fields with an exact lossless
|
||||
mapping, materialises legacy `open_to`, calls the grid projection,
|
||||
rekeys exact wall/open-span endpoints onto the moved rooms, merges
|
||||
touching virtual spans per room pair, compacts consecutive real-wall
|
||||
intervals of equal thickness and stamps `model_version`. Unknown fields
|
||||
are preserved and every pass is idempotent.
|
||||
|
||||
The optimizer deliberately does **not** alter backdrop calibration or
|
||||
saved view boxes, delete unattached layout entries (a device may only be
|
||||
temporarily unavailable), deduplicate markers, or delete files. File
|
||||
collection remains the backend's reference-aware scheduled job.
|
||||
|
||||
`alignAllToGrid(spaces, layout)` (`src/align-grid.ts`) is pure: it
|
||||
copies its input, never mutates it, and returns the new spaces, the new
|
||||
layout and the report. The dialog therefore measures and commits the
|
||||
**same object** — the numbers it promises cannot differ from what it
|
||||
does. The resulting config+layout pair is sent to
|
||||
`houseplan/plan/optimize`; the backend persists a durable intent before
|
||||
either store changes, commits both revisions, and retains one snapshot.
|
||||
`houseplan/plan/optimize_undo` restores it only while neither revision
|
||||
has changed since the optimization. A crash between store writes is
|
||||
completed from the intent on the next integration setup.
|
||||
|
||||
Guarantees are covered by `test/align-grid.test.mjs` and the orchestration/
|
||||
idempotence case in `test/plan-optimizer.test.mjs`:
|
||||
|
||||
* every grid-bound element ends on a node; a rect's FAR corner too (a
|
||||
snapped *size* on an off-grid origin leaves the other side between
|
||||
the nodes);
|
||||
* an opening ends on its wall, at whole steps along it, inside it, and
|
||||
**with the wall's own angle** — the angle is written, so it is part of
|
||||
the diff (AUD-158B1-02: an opening already on its wall with a wrong
|
||||
angle used to be returned changed inside `changed: false`, which made
|
||||
it unfixable);
|
||||
* a stray opening with no wall within 6 steps is left exactly where it
|
||||
is rather than teleported;
|
||||
* **idempotent**: a second run reports `moved: 0`, `changed: false`, and
|
||||
returns objects deep-equal to the first run's;
|
||||
* the report is an **upper bound**, not a sample (AUD-158B1-01).
|
||||
|
||||
### The report is a promise
|
||||
|
||||
The confirmation is the decision gate in front of a geometry rewrite, so
|
||||
`maxShift`/`maxShiftCm` must never be smaller than what the run does:
|
||||
|
||||
* displacement is measured on the geometry **actually written back** —
|
||||
all FOUR corners of a rect, minimum-size correction included. The two
|
||||
corners nobody used to measure are exactly the two that can be worst:
|
||||
they carry the X error of one side together with the Y error of the
|
||||
other, which is √2 of either;
|
||||
* an opening is measured on its **ends**, flip-invariantly, so turning
|
||||
it in place costs what it really costs and a 180° rewrite costs
|
||||
nothing;
|
||||
* the maximum is accumulated in **centimetres**, each space through its
|
||||
own `cell_cm`, and the report names the space it belongs to. One
|
||||
normalised maximum converted through the *first* space's cell size
|
||||
promised 2.5 cm for a vertex that moved 50 cm on a 100 cm floor;
|
||||
* the dialog rounds the last tenth **up** and, on a multi-space plan,
|
||||
says which space the maximum is in; openings corrected in angle alone
|
||||
are counted on a line of their own.
|
||||
|
||||
One undo is available until the next config or layout edit. It restores
|
||||
the stored snapshot; re-running optimization itself is never treated as
|
||||
undo because a grid projection is not invertible.
|
||||
|
||||
## Every place that assumed the unit square
|
||||
|
||||
|
||||
| Place | Assumption | Decision |
|
||||
| --- | --- | --- |
|
||||
| `contentBounds` envelope `-25 %..125 %` | content outside the square does not count | **removed** — replaced by §4.1 outlier rejection |
|
||||
| `_baseVb()` `if (mode !== 'view') return m.vb` | editors need the whole square to have room to draw | **removed** — the content frame plus §5 pan slack and 3x zoom-out gives more room than the square ever did |
|
||||
| `_baseVb()` `if (m.bg) return m.vb` | image plans frame on the square | image rect is now just one content item (§4) |
|
||||
| `--icon-size` scaled by `vb.w / view.w` | the canvas is what an icon is a fraction OF | numerator becomes `iconUnit()`; the icon still scales with the plan (§6) |
|
||||
| `defaultPositions` `minDist` from `NORM_W` | one canvas = one plan | `iconUnit()` (§6) |
|
||||
| `markerPos` / `_pos` fallback = `view_box` centre | a device with no position belongs in the middle of the square | `spaceCenter()` — the middle of the content |
|
||||
| grid `<rect>` over `vb` | the grid ends with the square | rect follows the view (§7) |
|
||||
| grid pitch fixed | fine at 1 canvas wide | `gridLevels()` (§7) |
|
||||
| `_clampView` pinned content over the scene | you cannot pan past the edge | §5 pan slack |
|
||||
| `_stagePointerMove` panned only while `zoom > 1` | below 100% the content already covered the scene, so a drag had nowhere to go | **removed** — §5, panning at every zoom |
|
||||
| `ZOOM_MIN = 0.4` | fraction of the square | `MIN_ZOOM = 1/3` of the content frame (§5) |
|
||||
| `_decorMoveUpdate` clamp `-0.25 .. 1.25` | decor may hang a quarter past the edge | clamp widened to the sane range (`+/-CANVAS_LIMIT`) — corruption insurance, not a frame |
|
||||
| `_pointerMove` clamp to `_baseVb()`, `_labelMove` clamp to `view_box` | a marker/label belongs inside the canvas | **removed** — §9.1; missed in v1.57.0 and reported by the owner |
|
||||
| static card `aspect-ratio` + `viewBox` from `space.vb` | the static card frames the square | `spaceFrame()` — same content frame as the full card |
|
||||
| `validation.py` `+/-4`, `_EXTENT <= 4`, decor `-1..2`, opening `length <= 1` | the square plus slack | §3 |
|
||||
| `safeViewBox` fallback `[0,0,1,1]` | a broken `view_box` means the square | kept — it is only the last-resort hint (§4) |
|
||||
| `fitInSquare` (image placement) | image is centred in the square | **kept** — it defines the image's own rectangle in canvas units, which is exactly what §4 wants as a content item. Since v1.58.0 it is only the DEFAULT placement: `planRect()` adds the owner's `plan_x`/`plan_y`/`plan_scale` on top, and the moved rectangle is what §4 counts (docs/BACKDROP.md) |
|
||||
| image plan papers the image rect | the picture IS the sheet | **removed** in v1.58.0 — the opaque paper is the room contours in every case, and the picture is drawn on top of it (docs/BACKDROP.md §3) |
|
||||
| `_spaceH` / `_decorH` = `NORM_W` | the canvas is square | **kept** — this is the coordinate system's aspect, not a frame |
|
||||
| `_gridPitch = NORM_W / GRID_N` | grid pitch is tied to the canvas unit | **kept** — the pitch is the real-world cell (`cell_cm`), it must not change with the plan's size |
|
||||
| sun wedges / glow radii / resize maths | all in render units, relative to their own geometry | **unaffected** — verified: no `NORM_W`-relative constants |
|
||||
|
||||
## What is deliberately NOT done
|
||||
|
||||
* No new stored field. The frame is derived every time; there is
|
||||
nothing to migrate, nothing to keep in sync, nothing to corrupt.
|
||||
* `view_box` is still WRITTEN as `[0,0,1,1]` on space creation and is
|
||||
still required by the schema — removing a required field is a
|
||||
breaking storage change for old clients and buys nothing.
|
||||
* The outlier hint has no "hide this object" action. Deciding what to
|
||||
do with a stray marker is the device editor's job.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user