mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bd0c4a033 | ||
|
|
588f1188c3 | ||
|
|
17bfdaaed0 | ||
|
|
07ba2ffbd1 | ||
|
|
5692a2880f | ||
|
|
d7e97687ce | ||
|
|
444562e47c | ||
|
|
046efe9613 | ||
|
|
d513f13afa | ||
|
|
a7bee0f5fb | ||
|
|
4b6b4abc70 | ||
|
|
8d787530b1 | ||
|
|
fe03eae213 | ||
|
|
cb68492c99 | ||
|
|
f8b3445dc3 | ||
|
|
1850eb18b5 | ||
|
|
5254fb9ef5 | ||
|
|
67c000f17b | ||
|
|
8ef0b6a1fa | ||
|
|
5d594e70c8 | ||
|
|
c0d61ca3d2 | ||
|
|
137e160f1a | ||
|
|
66c2eec1f9 | ||
|
|
338700bdcb | ||
|
|
029aba6b88 | ||
|
|
5784a0e271 | ||
|
|
2a883217c1 | ||
|
|
d5e114524c | ||
|
|
9e7ae520a8 | ||
|
|
4b443d8cf5 | ||
|
|
dc76c5b2b8 | ||
|
|
8d8263202f | ||
|
|
cc9387163e | ||
|
|
f70a973834 | ||
|
|
36b4e1cf31 | ||
|
|
5016aa01cc | ||
|
|
28a4cb5eec | ||
|
|
9e56842bc1 | ||
|
|
1d8edf4a01 | ||
|
|
aa51ed2bf5 | ||
|
|
a158d75712 | ||
|
|
05ef318133 | ||
|
|
b39f99b3a4 | ||
|
|
85a064430b | ||
|
|
070c276e26 | ||
|
|
1a7488d695 | ||
|
|
1ce62613ed | ||
|
|
b8a31e90ca | ||
|
|
5da19f1793 | ||
|
|
a8ccb2b90f | ||
|
|
8935a736cb | ||
|
|
411a0a20a9 | ||
|
|
89647c10bd | ||
|
|
ed4ac972b3 | ||
|
|
ee9b28ed98 | ||
|
|
658f284b44 | ||
|
|
169fed01f0 | ||
|
|
7230e4248a | ||
|
|
0ec0785dfd | ||
|
|
e846833ed6 | ||
|
|
ed0fdd4859 | ||
|
|
112ec0eb66 | ||
|
|
775e55f0ed | ||
|
|
3f9ef807d8 | ||
|
|
f0ed526a59 | ||
|
|
4203a01b04 | ||
|
|
aab5c3ca95 | ||
|
|
a2867df9a2 | ||
|
|
2202b20730 | ||
|
|
85702afc0c | ||
|
|
e5587aed7c | ||
|
|
aed0b5b65a | ||
|
|
c53702cdfa | ||
|
|
a6a3df9310 | ||
|
|
ec9824f227 | ||
|
|
260767e2f8 | ||
|
|
3a1f2b2d86 | ||
|
|
6eea4d1c7a | ||
|
|
a54addb694 | ||
|
|
6a7ef48ae8 | ||
|
|
6190234c51 | ||
|
|
39ba4e2b88 | ||
|
|
15cfa96553 | ||
|
|
4aad9facdf | ||
|
|
81026539d3 | ||
|
|
f672de674f | ||
|
|
68992d5582 | ||
|
|
f29c4c5b79 | ||
|
|
d5bfa0e3e1 | ||
|
|
f62dd6b632 | ||
|
|
6b07b70d7c | ||
|
|
62ecb8566e | ||
|
|
818ad2d2bb | ||
|
|
6c668d4bfe | ||
|
|
a1fbea07d2 | ||
|
|
effb36295d | ||
|
|
7c4cb187fe |
@@ -37,6 +37,7 @@ jobs:
|
||||
- large-house
|
||||
- isometric
|
||||
- plan-snap
|
||||
- interaction
|
||||
- blend
|
||||
- overlay
|
||||
- space-default
|
||||
@@ -190,6 +191,10 @@ jobs:
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
|
||||
;;
|
||||
interaction)
|
||||
npm run benchmark:large-house-interaction -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/interaction-baseline.json
|
||||
npm run benchmark:large-house-interaction -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/interaction-candidate.json
|
||||
;;
|
||||
blend)
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
|
||||
@@ -235,6 +240,9 @@ jobs:
|
||||
plan-snap)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
|
||||
;;
|
||||
interaction)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-interaction.json --baseline=../artifacts/performance/interaction-baseline.json --candidate=../artifacts/performance/interaction-candidate.json --output=../artifacts/performance/interaction-comparison.json
|
||||
;;
|
||||
blend)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
|
||||
;;
|
||||
|
||||
@@ -43,9 +43,19 @@ jobs:
|
||||
spent: ${{ steps.decide.outputs.spent }}
|
||||
limit: ${{ steps.decide.outputs.limit }}
|
||||
steps:
|
||||
# Мелкий checkout: guard остаётся лёгким, но ему нужен
|
||||
# scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что
|
||||
# inline-shell не покрывается тестами (#454). Node на раннере
|
||||
# предустановлен, setup-node не нужен.
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
- id: decide
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
LABEL: ${{ github.event.label.name }}
|
||||
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
|
||||
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
|
||||
@@ -87,19 +97,78 @@ jobs:
|
||||
#
|
||||
# Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал
|
||||
# цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по
|
||||
# имени документа в теле комментария; документа нет — вердикт не
|
||||
# посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы
|
||||
# работу досрочно: из двух ошибок выбрана обратимая.
|
||||
# имени документа — раньше по подстроке маркера в теле комментария,
|
||||
# теперь по имени документа ЭТОЙ задачи, `<MARKER>-<NUM>`: голая
|
||||
# подстрока протекала на прозе. #454 поймала это на себе — разбор
|
||||
# чужих задач в комментарии содержал `CODE-REVIEW`, и первый же
|
||||
# код-ревью получил заход r3.
|
||||
#
|
||||
# Счёт по комментариям остаётся ровно тем же, но он БОЛЬШЕ НЕ
|
||||
# ЕДИНСТВЕННЫЙ (#454). Маркер этапа попадает в тело комментария,
|
||||
# только если ревьюер сам назвал имя файла, — то есть прежний счёт
|
||||
# зависел от формулировки. На #449 первый спек-вердикт файла не
|
||||
# назвал, заход r2 получил номер r1, и документ второго раунда лёг
|
||||
# ПОВЕРХ документа первого. Оценка «недосчёт обратим» была неверна
|
||||
# ровно здесь: номер захода входит в имя файла, и повтор номера — не
|
||||
# лишний заход, а потеря артефакта.
|
||||
#
|
||||
# Поэтому рядом встаёт второй источник — опубликованные документы:
|
||||
# их имена несёт сам конвейер, и подделать их прозой нельзя. Берётся
|
||||
# МАКСИМУМ двух источников: недосчёт возможен только при отказе
|
||||
# обоих, перерасчёт невозможен по построению.
|
||||
attempt=1; spent=0; spent_list=""
|
||||
if [ -n "$stage" ]; then
|
||||
comments=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json comments)
|
||||
of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))]"
|
||||
# Блокирующим считается вердикт, у которого в строке вердикта стоит
|
||||
# «жёлтый» или «красный». Регистр и окружение слова не важны.
|
||||
blocking="$of_stage | map(select(.body | test(\"Вердикт:[^\\n]*(жёлт|красн)\"; \"i\")))"
|
||||
attempt=$(( $(printf '%s' "$comments" | jq -r "$of_stage | length") + 1 ))
|
||||
spent=$(printf '%s' "$comments" | jq -r "$blocking | length")
|
||||
spent_list=$(printf '%s' "$comments" | jq -r "$blocking | map(\"- \" + .url) | join(\"\\n\")")
|
||||
comments=$(mktemp)
|
||||
gh issue view "$NUM" --repo "$REPO" --json comments > "$comments"
|
||||
|
||||
# Ветка задачи — та же, что выберет шаг ревью: свежая по коммиту.
|
||||
# Её нет у задач, размеченных до появления конвейера; тогда счёт по
|
||||
# файлам даёт ноль и работает страховка по комментариям.
|
||||
branch=""; newest=""
|
||||
for ref in $(gh api "repos/$REPO/git/matching-refs/heads/issue/$NUM-" \
|
||||
--jq '.[].ref' 2>/dev/null | sed 's|^refs/heads/||'); do
|
||||
date=$(gh api "repos/$REPO/commits/$ref" --jq '.commit.committer.date' 2>/dev/null || true)
|
||||
if [ -n "$date" ] && { [ -z "$newest" ] || [ "$date" \> "$newest" ]; }; then
|
||||
newest="$date"; branch="$ref"
|
||||
fi
|
||||
done
|
||||
target="${branch:-dev}"
|
||||
names=$(mktemp); docs=$(mktemp -d)
|
||||
if ! gh api "repos/$REPO/contents/docs/reviews?ref=$target" --jq '.[].name' \
|
||||
> "$names" 2>/dev/null; then
|
||||
: > "$names"
|
||||
echo "::warning::список docs/reviews на $target не получен — счёт по файлам отключён"
|
||||
fi
|
||||
# Каталог перечисляется одним ответом до 1000 записей; за этой
|
||||
# границей ответ молча обрежется, и счёт по файлам занизится.
|
||||
if [ "$(grep -c . "$names")" -ge 1000 ]; then
|
||||
echo "::warning::в docs/reviews не меньше 1000 файлов — листинг contents обрезается, счёт по файлам ненадёжен"
|
||||
fi
|
||||
# Тела нужны только своим документам этапа: их единицы.
|
||||
for name in $(grep -E "^${marker}-${NUM}-r[0-9]+\\.md$" "$names" || true); do
|
||||
gh api "repos/$REPO/contents/docs/reviews/$name?ref=$target" \
|
||||
-H 'Accept: application/vnd.github.raw' > "$docs/$name" 2>/dev/null || rm -f "$docs/$name"
|
||||
done
|
||||
list=$(mktemp)
|
||||
counters=$(node scripts/review-doc-guard.mjs --counters \
|
||||
--marker="$marker" --num="$NUM" --names="$names" --docs="$docs" \
|
||||
--comments="$comments" --spent-list="$list")
|
||||
spent_list=$(cat "$list")
|
||||
# Пустой ответ означает, что скрипт не отработал. Тогда остаются
|
||||
# значения по умолчанию (заход 1, циклов 0): guard обязан
|
||||
# продолжить работу, а не встать.
|
||||
new_attempt=$(printf '%s\n' "$counters" | sed -n 's/^attempt=//p')
|
||||
new_spent=$(printf '%s\n' "$counters" | sed -n 's/^spent=//p')
|
||||
new_blocking=$(printf '%s\n' "$counters" | sed -n 's/^blocking=//p')
|
||||
case "$new_attempt" in ''|*[!0-9]*) echo "::warning::счётчик раундов не дал числа — работают значения по умолчанию" ;; *) attempt="$new_attempt" ;; esac
|
||||
case "$new_spent" in ''|*[!0-9]*) : ;; *) spent="$new_spent" ;; esac
|
||||
# Перечень учтённого обязан сходиться с числом: если цикл виден
|
||||
# только документом, ссылка на комментарий его не объяснит.
|
||||
if [ -n "$new_blocking" ]; then
|
||||
spent_list="$spent_list
|
||||
- документы: $new_blocking"
|
||||
fi
|
||||
echo "ветка материала: ${branch:-нет, читался dev}"
|
||||
fi
|
||||
|
||||
# Отказ обязан быть виден в issue, а не только в логе прогона.
|
||||
|
||||
@@ -344,9 +344,27 @@ whole review round that a local run would have caught immediately.
|
||||
|
||||
The full smoke set, `golden` and `performance_smoke` still belong to the
|
||||
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
|
||||
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
|
||||
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
|
||||
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
|
||||
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
|
||||
the beta gate is CI at the exact SHA.
|
||||
|
||||
**Verifying and capturing are different things (#455).** `golden:verify` is
|
||||
advisory and legal anywhere, Windows included: it reports differences and
|
||||
accepts nothing. **Capturing** frames is refused outside Linux
|
||||
before the browser even starts — `golden:capture` through
|
||||
`demo/golden/policy.mjs`, documentation screenshots through
|
||||
`npm run docs:capture` (use that script, not a bare `node
|
||||
demo/docs/capture.mjs`: the gate sits one step earlier because editing the
|
||||
capture script invalidates the committed screenshot index). The refusal prints
|
||||
the WSL command. The reason is not policy but physics: Windows
|
||||
rasterizes text through DirectWrite with different subpixel and DPI behaviour,
|
||||
so no frame ever matches an accepted baseline byte for byte, no environment
|
||||
witness can exist, and acceptance would refuse anyway (#401 accepts any
|
||||
environment that proves itself with byte-identical undeclared frames — Linux is
|
||||
simply the only one we have). The deliberate override is
|
||||
`HP_ALLOW_FOREIGN_CAPTURE="reason"`; the reason travels into the output and the
|
||||
manifest. Baselines are still accepted only via
|
||||
`npm run golden:accept -- --reviewed` on a complete artefact, and the accepted
|
||||
index records the platform next to the Chromium build.
|
||||
|
||||
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
|
||||
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
|
||||
@@ -399,21 +417,20 @@ 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.
|
||||
|
||||
## Labs flags
|
||||
## Alpha experiments
|
||||
|
||||
`src/labs.ts` is the single registry and resolver for hidden presentation
|
||||
experiments. Activate a live flag through `?hp-labs=<id>` or the shared hash
|
||||
grammar, remove it with `-<id>`, and use `off` to clear the set. Do not add a
|
||||
YAML/config switch for a Labs-only experiment. A new entry needs a unique
|
||||
lowercase id, issue, numeric-core `since`, numeric-core `expires`, summary and
|
||||
unit/browser coverage. Invalid or duplicate registry entries fail closed.
|
||||
experiments. One browser-local switch controls the complete set known to the
|
||||
installed build: `hp_alpha=1` in query or the shared hash grammar enables and
|
||||
persists it, while `hp_alpha=0` disables and persists it. Do not add per-feature
|
||||
URL/storage keys or a YAML/config switch. The legacy `hp-labs` grammar and
|
||||
`houseplan_card_labs_v1` storage are ignored and never migrated.
|
||||
|
||||
Expiry is exclusive and ignores prerelease suffixes: an entry expiring at
|
||||
`1.65.0` is unavailable in `1.65.0-beta.1`. Before that cycle, either remove the
|
||||
experiment or graduate it through its own reviewed issue; never extend expiry as
|
||||
an incidental change. Labs may alter presentation only and must not gate data,
|
||||
migrations, stores, HA actions or network calls. Current renderer details are in
|
||||
`docs/ISOMETRIC.md`.
|
||||
A new registry entry needs a unique lowercase id, issue, summary and
|
||||
unit/browser coverage; it does not get `since` or `expires`. Invalid or duplicate
|
||||
entries fail closed. Alpha capabilities may alter presentation only and must not
|
||||
gate data, migrations, stores, HA actions or network calls. Current renderer
|
||||
details are in `docs/ISOMETRIC.md`.
|
||||
|
||||
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
|
||||
|
||||
@@ -51,7 +51,7 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
ASSETS_DIR = "houseplan/assets"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.71.0-beta.4"
|
||||
VERSION = "1.72.0-beta.1"
|
||||
DECOR_ASSETS_API_VERSION = 1
|
||||
|
||||
# #43: the support transport is deliberately not configurable. A user supplied
|
||||
|
||||
@@ -1,125 +1,182 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15",
|
||||
"fingerprint": "3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-n2PxGqZY.js",
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 292752,
|
||||
"initialViewGzipBytes": 296719,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
|
||||
"houseplan-assets/de-BP_2FY3A.js",
|
||||
"houseplan-assets/editor-BoI8ovCT.js",
|
||||
"houseplan-assets/fr-DOlNp4ji.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-De35hz5H.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-dO-9we_v.js"
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/de-B9ewWWX2.js",
|
||||
"houseplan-assets/editor-CDcQqv3k.js",
|
||||
"houseplan-assets/fr-4rsWX0CK.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
|
||||
"houseplan-assets/live-interaction-runtime-ClI0mgY3.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
|
||||
],
|
||||
"lazyGzipBytes": 223409,
|
||||
"lazyGzipBytes": 240651,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
|
||||
"houseplan-assets/editor-BoI8ovCT.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-De35hz5H.js"
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/editor-CDcQqv3k.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 167369,
|
||||
"lazyEditorGzipBytes": 178324,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-dO-9we_v.js"
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14244,
|
||||
"lazyOnboardingGzipBytes": 14239,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-BP_2FY3A.js",
|
||||
"houseplan-assets/fr-DOlNp4ji.js"
|
||||
"houseplan-assets/de-B9ewWWX2.js",
|
||||
"houseplan-assets/fr-4rsWX0CK.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 49080,
|
||||
"lazyLocaleGzipBytes": 49104,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-DMGa8wc7.js",
|
||||
"sha256": "6226595cb5753e94dbba8a99943f5d7133eb19cfb43d2c7af574b2cd1c995c7a",
|
||||
"rawBytes": 21418,
|
||||
"gzipBytes": 7284,
|
||||
"path": "houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"sha256": "473ca1094a2f837fffc71026ba5f9c5787a6a1e18db7b37328f586bf7bdeb73a",
|
||||
"rawBytes": 21417,
|
||||
"gzipBytes": 7280,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-n2PxGqZY.js"
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-BP_2FY3A.js",
|
||||
"sha256": "0925d353b13fb9ed03f06fa910fb44f9f7460f1edf8efaf1299c91117d223697",
|
||||
"rawBytes": 82140,
|
||||
"gzipBytes": 24809,
|
||||
"path": "houseplan-assets/de-B9ewWWX2.js",
|
||||
"sha256": "3c291653c70e131993810e78b7ace06a10758b9fbdfa54636f890b36aa8cfc1a",
|
||||
"rawBytes": 82182,
|
||||
"gzipBytes": 24822,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-BoI8ovCT.js",
|
||||
"sha256": "4c882564e63a08f64b1b34ad231bd85d897e12cd2973a1ffcbcfe0d091ceff02",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1585,
|
||||
"path": "houseplan-assets/editor-CDcQqv3k.js",
|
||||
"sha256": "76a014106e36d05f727ecc2bfb654708df0d5d3db54d498bb58254eb9ebec15b",
|
||||
"rawBytes": 3831,
|
||||
"gzipBytes": 1583,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-n2PxGqZY.js"
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-DOlNp4ji.js",
|
||||
"sha256": "d54f2c24b83c924eba22439817b54d7793af00d28fd4f16e18e0cf35e8737ea3",
|
||||
"rawBytes": 84267,
|
||||
"gzipBytes": 24271,
|
||||
"path": "houseplan-assets/fr-4rsWX0CK.js",
|
||||
"sha256": "b0508cefcba06761e14436fd8ab8bef916c0bdcb70a71e1d338ba7722057d6f1",
|
||||
"rawBytes": 84312,
|
||||
"gzipBytes": 24282,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-n2PxGqZY.js",
|
||||
"sha256": "f2e5ff91866347e77fe2d924294e119651f41641356d1f761188f175500c2af1",
|
||||
"rawBytes": 1039630,
|
||||
"gzipBytes": 291954,
|
||||
"path": "houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"sha256": "24597a66c6c0b7ddb3859a5bc2e8b58e08ef57d4612d0cf9222895122edfe92b",
|
||||
"rawBytes": 1053904,
|
||||
"gzipBytes": 295923,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-BP_2FY3A.js",
|
||||
"houseplan-assets/editor-BoI8ovCT.js",
|
||||
"houseplan-assets/fr-DOlNp4ji.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-De35hz5H.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-dO-9we_v.js"
|
||||
"houseplan-assets/de-B9ewWWX2.js",
|
||||
"houseplan-assets/editor-CDcQqv3k.js",
|
||||
"houseplan-assets/fr-4rsWX0CK.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
|
||||
"houseplan-assets/live-interaction-runtime-ClI0mgY3.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-De35hz5H.js",
|
||||
"sha256": "9f44dafa722abba6a47073110496e99d178ee661f38bed2e189280f629980f73",
|
||||
"rawBytes": 601529,
|
||||
"gzipBytes": 158500,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
|
||||
"sha256": "fe6417012721db8c1124e8d1a1b9f037db6edd8edb1ccf328bbc1c9697186cbd",
|
||||
"rawBytes": 622491,
|
||||
"gzipBytes": 164615,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
|
||||
"houseplan-assets/houseplan-card-n2PxGqZY.js"
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
|
||||
"sha256": "69cf99a15ce74f74a309241f88f2cf0eade072507f2ec463560956367549d5d2",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6959,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-dO-9we_v.js",
|
||||
"sha256": "429df2912042da7959c13847d96b6a957a5e097c2899b82e82bb0f9820e86067",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6960,
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
|
||||
"sha256": "e64d8d0dae12904eba4aa3f87fb7f046962091eb7284016e0442c838be3cdfb2",
|
||||
"rawBytes": 6006,
|
||||
"gzipBytes": 2376,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
|
||||
"houseplan-assets/houseplan-card-n2PxGqZY.js"
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-ClI0mgY3.js",
|
||||
"sha256": "05f378c2eef76f35ea98d05821b4680cae1bd599e410d27fbe69ad5ad76eb0d6",
|
||||
"rawBytes": 5196,
|
||||
"gzipBytes": 2127,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-Q4h3gz_F.js",
|
||||
"sha256": "c6f7dc8aad18071bd7766901c382201b0252725fae2c77993483030b3f73ca67",
|
||||
"rawBytes": 12382,
|
||||
"gzipBytes": 4846,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-CVolox2I.js",
|
||||
"sha256": "e4dcb60eb07d78c23782ac134e498fff6b348f4f35a2a7820a13582ceb1887fa",
|
||||
"rawBytes": 3941,
|
||||
"gzipBytes": 1761,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "8c663d28d60b83468ee17e7b8e6891d9579e96e1c8c89cd5ca2ecd23c89e84a3",
|
||||
"sha256": "03b15da3629659eba3779885ccf8f0206455ba95378dd1fa246c8ea4bb372a8e",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 798,
|
||||
"gzipBytes": 796,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-n2PxGqZY.js"
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
+7
-7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-n2PxGqZY.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{v as e,l as o,z as t,dt as s,A as a,du as i,dv as l,dw as n,E as r,c}from"./houseplan-card-CeQyO4TX.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2939
File diff suppressed because one or more lines are too long
-2850
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15";import{l as t,aQ as s,A as e,t as o,c as a,cd as l,ce as i,cf as h,cg as c,aN as n,ch as r,cm as p,ci as d,cj as _,ck as u,cl as g,dc as m,aM as b,aO as $,dd as v}from"./houseplan-card-n2PxGqZY.js";import{i as f,c as y,e as w,r as D,a as k,b as S,d as C,t as M}from"./backdrop-pick-DMGa8wc7.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{l as t,a$ as s,A as e,z as o,c as a,cj as l,ck as i,cl as h,cm as c,aY as n,cn as r,cs as p,co as d,cp as _,cq as u,cr as g,dj as m,aX as b,aZ as $,dk as v}from"./houseplan-card-CeQyO4TX.js";import{i as f,c as y,e as w,r as D,a as k,b as S,d as C,t as M}from"./backdrop-pick-Br9hp4fM.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
|
||||
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,p="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:r(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:p,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=p(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:_,tempMax:d,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){const s=this.host._spaceDialog,e=s?.saved?.find(s=>s.name===t);if(!s||!e||e.used_by.length||e.url===s.planUrl)return;const o=await this.host._confirmDanger({key:"delete-plan",kind:"destructive",title:this.host._t("confirm.delete_plan_title"),message:this.host._t("confirm.delete_plan_body"),objectName:t,confirmLabel:this.host._t("btn.delete"),cancelLabel:this.host._t("btn.cancel")}),a=this.host._spaceDialog,l=a?.saved?.find(s=>s.name===t);if(o&&a&&l&&l.url===e.url&&l.modified===e.modified&&!l.used_by.length&&l.url!==a.planUrl)try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
|
||||
${s.map(s=>{return a`
|
||||
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{v as e,A as t,dx as i,w as s,c as o,l as r,u as n}from"./houseplan-card-CeQyO4TX.js";import{a,r as h,t as d}from"./zigbee-topology-Q4h3gz_F.js";import{zigbeeTopologyRuntimeSnapshot as p,subscribeZigbeeTopology as c}from"./zigbee-topology-runtime-CVolox2I.js";const l={revision:0,topologies:[],states:{}};class v extends e{constructor(){super(...arguments),this.devices=[],this.currentSpace="",this._runtime=l,this._hovered="",this._pointerOver=e=>{if(!this._mouseAllowed(e))return void this._clear();const t=this._deviceFromEvent(e),i=t?.dataset.id||"";i&&i!==this._hovered&&(this._hovered=i,this.requestUpdate())},this._pointerOut=e=>{const t=this._deviceFromEvent(e);if(!t||t.dataset.id!==this._hovered)return;const i=e.relatedTarget instanceof Element?e.relatedTarget.closest('[data-hp="device"]'):null;i?.dataset.id!==this._hovered&&this._clear()},this._pointerDown=e=>{"mouse"!==e.pointerType&&this._clear()}}connectedCallback(){super.connectedCallback();const e=this.getRootNode();e.host&&"undefined"!=typeof MutationObserver&&(this._hoverGateObserver=new MutationObserver(()=>{e.host.hasAttribute("data-pointer-hover")||this._clear()}),this._hoverGateObserver.observe(e.host,{attributes:!0,attributeFilter:["data-pointer-hover"]})),queueMicrotask(()=>this._connectParent())}disconnectedCallback(){this._release?.(),this._release=void 0,this._disconnectParent(),this._hoverGateObserver?.disconnect(),this._hoverGateObserver=void 0,this._hovered="",super.disconnectedCallback()}updated(e){e.has("hass")&&(this._release?.(),this._runtime=p(this.hass),this._mappedMemo=void 0,this._release=c(this.hass,()=>{this._runtime=p(this.hass),this._mappedMemo=void 0,this.requestUpdate()})),(e.has("currentSpace")||e.has("devices")||e.has("registry"))&&(this._mappedMemo=void 0,this.devices.some(e=>e.id===this._hovered&&e.space===this.currentSpace)||(this._hovered=""))}_connectParent(){const e=this.parentElement;e&&e!==this._parent&&(this._disconnectParent(),this._parent=e,e.addEventListener("pointerover",this._pointerOver),e.addEventListener("pointerout",this._pointerOut),e.addEventListener("pointerdown",this._pointerDown,!0))}_disconnectParent(){this._parent?.removeEventListener("pointerover",this._pointerOver),this._parent?.removeEventListener("pointerout",this._pointerOut),this._parent?.removeEventListener("pointerdown",this._pointerDown,!0),this._parent=void 0}_deviceFromEvent(e){for(const t of e.composedPath())if(t instanceof HTMLElement&&"device"===t.dataset.hp)return t;return null}_mouseAllowed(e){const t=this.getRootNode();return"mouse"===e.pointerType&&t.host?.hasAttribute?.("data-pointer-hover")}_clear(){this._hovered&&(this._hovered="",this.requestUpdate())}_position(e){const t=[...this._parent?.querySelectorAll(".dev[data-id]")||[]].find(t=>t.dataset.id===e);if(!t)return null;const i=Number.parseFloat(t.style.left),s=Number.parseFloat(t.style.top),o=t.getBoundingClientRect();return Number.isFinite(i)&&Number.isFinite(s)?{x:i,y:s,width:o.width,height:o.height}:null}render(){if(!this._hovered||!this.registry||!this._runtime.topologies.length)return t;const e=this._mappedMemo,n=e&&e.runtime===this._runtime&&e.devices===this.devices&&e.registry===this.registry?e.mapped:a(this._runtime.topologies,this.devices,this.registry);e&&n===e.mapped||(this._mappedMemo={runtime:this._runtime,devices:this.devices,registry:this.registry,mapped:n});const p=h(n,this.currentSpace,this._hovered),c=this._position(this._hovered);if(!c)return t;const l=p.lines.map(e=>({...e,point:this._position(e.neighborMarkerId)})).filter(e=>!!e.point);return o`
|
||||
${l.length?s`<svg viewBox="0 0 100 100" preserveAspectRatio="none"
|
||||
aria-hidden="true" data-hp="zigbee-topology-lines">
|
||||
${l.map(e=>s`<line x1=${c.x} y1=${c.y}
|
||||
x2=${e.point.x} y2=${e.point.y}
|
||||
stroke=${void 0===e.lqi?"rgba(145,155,165,.85)":i(e.lqi)}
|
||||
stroke-width=${void 0===e.lqi?2:2.2}
|
||||
stroke-dasharray=${void 0===e.lqi?"5 5":t} opacity=".9"></line>`)}
|
||||
</svg>`:t}
|
||||
${l.map(e=>o`<div class="halo" data-hp="zigbee-topology-neighbor"
|
||||
data-id=${e.neighborMarkerId} style="left:${e.point.x}%;top:${e.point.y}%;width:${1.22*e.point.width}px;height:${1.22*e.point.height}px"></div>`)}
|
||||
${p.remoteCount?o`<div class="remote" data-hp="zigbee-topology-remote"
|
||||
style="left:${c.x}%;top:${c.y}%">${d(r(this.hass),"remote_count",{n:p.remoteCount})}</div>`:t}
|
||||
`}}v.properties={hass:{attribute:!1},devices:{attribute:!1},registry:{attribute:!1},currentSpace:{type:String,attribute:"current-space"},viewKey:{attribute:!1}},v.styles=n`
|
||||
:host { position: absolute; inset: 0; z-index: 5; display: block; pointer-events: none; }
|
||||
svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
|
||||
line { vector-effect: non-scaling-stroke; stroke-linecap: round; }
|
||||
.halo {
|
||||
position: absolute; width: calc(var(--device-base-size, 4cqw) * 1.22);
|
||||
height: calc(var(--device-base-size, 4cqw) * 1.22); transform: translate(-50%, -50%);
|
||||
box-sizing: border-box; border: 2px solid rgba(120, 190, 220, .82); border-radius: 50%;
|
||||
box-shadow: 0 0 0 4px rgba(120, 190, 220, .16);
|
||||
}
|
||||
.remote {
|
||||
position: absolute; transform: translate(12px, calc(-100% - 12px));
|
||||
border: 1px solid rgba(255,255,255,.7); border-radius: 999px;
|
||||
padding: 3px 7px; color: #fff; background: rgba(28,31,36,.88);
|
||||
box-shadow: 0 2px 7px rgba(0,0,0,.28); white-space: nowrap;
|
||||
font: 600 11px/1.2 system-ui, sans-serif;
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
line { stroke: Highlight !important; }
|
||||
.halo { border-color: Highlight; box-shadow: none; }
|
||||
.remote { color: CanvasText; background: Canvas; border-color: CanvasText; }
|
||||
}
|
||||
`,customElements.get("hp-zigbee-topology-overlay")||customElements.define("hp-zigbee-topology-overlay",v);export{v as HpZigbeeTopologyOverlay};
|
||||
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{n as t,b as n}from"./zigbee-topology-Q4h3gz_F.js";import{I as e}from"./houseplan-card-CeQyO4TX.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15";try{await import("./houseplan-assets/houseplan-card-n2PxGqZY.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";try{await import("./houseplan-assets/houseplan-card-CeQyO4TX.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.71.0-beta.4"
|
||||
"version": "1.72.0-beta.1"
|
||||
}
|
||||
|
||||
+449
-11
@@ -15,13 +15,15 @@ const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
|
||||
const output = valueArg('output') ? resolve(valueArg('output')) : null;
|
||||
const targetRoot = resolve(valueArg('target-root') ?? '.');
|
||||
const profile = valueArg('profile') ?? 'large-house-v1';
|
||||
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
|
||||
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1', 'large-house-interaction-v1'].includes(profile))
|
||||
throw new Error(`unknown large-house profile: ${profile}`);
|
||||
const isometric = profile === 'large-house-isometric-v1';
|
||||
const planSnap = profile === 'large-house-plan-snap-v1';
|
||||
const interaction = profile === 'large-house-interaction-v1';
|
||||
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
|
||||
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
|
||||
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
|
||||
const requiresInteraction = interaction && existsSync(resolve(targetRoot, 'src/live-viewport.ts'));
|
||||
const fixture = makeLargeHouseFixture();
|
||||
if (planSnap) {
|
||||
for (const [floor, space] of fixture.config.spaces.entries()) {
|
||||
@@ -71,7 +73,7 @@ try {
|
||||
const measuredSample = iteration - warmups;
|
||||
const row = await page.evaluate(async ({
|
||||
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
requiresWallFace,
|
||||
requiresWallFace, interaction, requiresInteraction,
|
||||
}) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -135,11 +137,11 @@ try {
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
if (isometric) {
|
||||
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(['iso']));
|
||||
localStorage.setItem('houseplan_card_alpha_v1', '1');
|
||||
localStorage.setItem('houseplan_card_view_v1', JSON.stringify(Object.fromEntries(
|
||||
fixture.config.spaces.map((space) => [space.id, 'iso']),
|
||||
)));
|
||||
history.replaceState(null, '', '?hp-labs=iso');
|
||||
history.replaceState(null, '', '?hp_alpha=1');
|
||||
} else history.replaceState(null, '', location.pathname);
|
||||
const host = document.getElementById('host');
|
||||
const card = document.createElement('houseplan-card');
|
||||
@@ -151,10 +153,10 @@ try {
|
||||
subscribeEvents: async () => () => undefined,
|
||||
subscribeMessage: async () => () => undefined,
|
||||
};
|
||||
const hassFor = (states) => ({
|
||||
const hassBase = {
|
||||
language: 'en', locale: { language: 'en' },
|
||||
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
|
||||
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas, states,
|
||||
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas,
|
||||
floors: {
|
||||
one: { floor_id: 'one', name: 'One', level: 0 },
|
||||
two: { floor_id: 'two', name: 'Two', level: 1 },
|
||||
@@ -178,17 +180,19 @@ try {
|
||||
localize: () => null,
|
||||
formatEntityState: (state) => state.state,
|
||||
config: { unit_system: { length: 'km' } },
|
||||
});
|
||||
};
|
||||
const hassFor = (states) => ({ ...hassBase, states });
|
||||
|
||||
const loadLongTasks = startLongTaskWindow();
|
||||
const loadStarted = performance.now();
|
||||
host.replaceChildren(card);
|
||||
if (requiresIsometric) {
|
||||
if (requiresIsometric && window.__hpAlpha !== true) {
|
||||
if (typeof card._onLabsSnapshot !== 'function')
|
||||
throw new Error('large-house-isometric-v1 candidate has no Labs fixture hook');
|
||||
// The product flag expires at 1.65.0. Performance keeps exercising
|
||||
// the dormant renderer without changing the public registry contract.
|
||||
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
// Comparison bundles before #448 do not understand hp_alpha. Preserve
|
||||
// the cross-version benchmark only for that base; current candidates
|
||||
// must activate through the real URL/storage contract above.
|
||||
card._onLabsSnapshot({ alpha: true, active: Object.freeze(['iso']), space: '' });
|
||||
}
|
||||
card.hass = hassFor(fixture.states);
|
||||
// launch() warms its own bootstrap card, but each measured sample creates
|
||||
@@ -206,9 +210,33 @@ try {
|
||||
await frame();
|
||||
const modelReadyMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
await until(() => card._booting === false);
|
||||
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
|
||||
await frame();
|
||||
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
const loadLongTaskResult = await loadLongTasks.stop();
|
||||
let fullRenderCount = 0;
|
||||
let diagnosticsScanCount = 0;
|
||||
let physicalPreflightCount = 0;
|
||||
let physicalPreflightMs = 0;
|
||||
const fullRenderReasons = [];
|
||||
if (interaction) {
|
||||
const renderBody = card._renderBody.bind(card);
|
||||
card._renderBody = (...args) => { fullRenderCount++; return renderBody(...args); };
|
||||
const bindingStatus = card._bindingStatus.bind(card);
|
||||
card._bindingStatus = (...args) => { diagnosticsScanCount++; return bindingStatus(...args); };
|
||||
const checkSpacePhysicalGeometry = card._checkSpacePhysicalGeometry.bind(card);
|
||||
card._checkSpacePhysicalGeometry = (...args) => {
|
||||
physicalPreflightCount++;
|
||||
const started = performance.now();
|
||||
try { return checkSpacePhysicalGeometry(...args); }
|
||||
finally { physicalPreflightMs += performance.now() - started; }
|
||||
};
|
||||
const willUpdate = card.willUpdate.bind(card);
|
||||
card.willUpdate = (changed) => {
|
||||
fullRenderReasons.push([...changed.keys()].map(String));
|
||||
return willUpdate(changed);
|
||||
};
|
||||
}
|
||||
const viewToggle = isometric ? await duration(async () => {
|
||||
if (typeof card._setProjection === 'function') {
|
||||
card._setProjection('flat');
|
||||
@@ -236,6 +264,406 @@ try {
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
let interactionDiagnostics = null;
|
||||
let interactionTimings = null;
|
||||
let interactionLongTasks = null;
|
||||
const interactionSeries = interaction ? await duration(async () => {
|
||||
const deltas = {};
|
||||
const timings = {};
|
||||
interactionLongTasks = {};
|
||||
const diagnosticsBefore = diagnosticsScanCount;
|
||||
const reasonsBefore = fullRenderReasons.length;
|
||||
const beforeIrrelevant = fullRenderCount;
|
||||
const intakeBefore = card._hassSequence;
|
||||
const irrelevantStarted = performance.now();
|
||||
let currentStates = nextStates;
|
||||
for (let index = 0; index < 30; index++) {
|
||||
currentStates = {
|
||||
...currentStates,
|
||||
'sensor.performance_unrelated': {
|
||||
entity_id: 'sensor.performance_unrelated', state: `${sample}:${index}`,
|
||||
},
|
||||
};
|
||||
card.hass = hassFor(currentStates);
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
timings.irrelevantHaTicksMs = performance.now() - irrelevantStarted;
|
||||
deltas.irrelevantFullRenders = fullRenderCount - beforeIrrelevant;
|
||||
deltas.irrelevantHassIntakes = card._hassSequence - intakeBefore;
|
||||
|
||||
const relevantBefore = fullRenderCount;
|
||||
const relevantStarted = performance.now();
|
||||
currentStates = {
|
||||
...currentStates,
|
||||
[firstEntity]: {
|
||||
...currentStates[firstEntity],
|
||||
state: currentStates[firstEntity].state === 'on' ? 'off' : 'on',
|
||||
},
|
||||
};
|
||||
card.hass = hassFor(currentStates);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
timings.relevantHaTickMs = performance.now() - relevantStarted;
|
||||
deltas.relevantOutsideGestureFullRenders = fullRenderCount - relevantBefore;
|
||||
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const device = card.renderRoot.querySelector('[data-hp="device"]');
|
||||
const room = card.renderRoot.querySelector('[data-hp="room"]');
|
||||
const heavy = card.renderRoot.querySelector('.wallbodies');
|
||||
if (!stage || !device || !room || !heavy)
|
||||
throw new Error('interaction profile has no stage/device/room/heavy scene');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const hoverBefore = fullRenderCount;
|
||||
const hoverLongTasks = startLongTaskWindow();
|
||||
const hoverStarted = performance.now();
|
||||
device.dispatchEvent(new PointerEvent('pointerover', {
|
||||
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
for (let index = 0; index < 40; index++) {
|
||||
device.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 100 + index, clientY: rect.top + 100 + index / 2,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 20 === 0) {
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
}
|
||||
const liveTip = card.renderRoot.querySelector('[data-hp-live-tip]');
|
||||
deltas.tooltipVisible = !!liveTip && !liveTip.hidden && !!liveTip.textContent?.trim();
|
||||
device.dispatchEvent(new PointerEvent('pointerleave', {
|
||||
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
room.dispatchEvent(new PointerEvent('pointerenter', {
|
||||
clientX: rect.left + 300, clientY: rect.top + 300,
|
||||
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
for (let index = 0; index < 40; index++) {
|
||||
room.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 300 + index, clientY: rect.top + 300 + index / 2,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 20 === 0) { await card.updateComplete; await frame(); }
|
||||
}
|
||||
room.dispatchEvent(new PointerEvent('pointerleave', {
|
||||
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
for (let index = 0; index < 40; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 20 + index, clientY: rect.top + 20,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 20 === 0) { await card.updateComplete; await frame(); }
|
||||
}
|
||||
await card.updateComplete;
|
||||
timings.hoverSeriesMs = performance.now() - hoverStarted;
|
||||
deltas.hoverFullRenders = fullRenderCount - hoverBefore;
|
||||
deltas.hoverTargets = ['device', 'room', 'miss'];
|
||||
interactionLongTasks.hoverSeries = await hoverLongTasks.stop();
|
||||
|
||||
const cameraSvg = card.renderRoot.querySelector('[data-hp-live-viewbox="camera"], [data-hp-live-viewbox="floor"], .zoomwrap > svg');
|
||||
const initialViewBox = cameraSvg?.getAttribute('viewBox') || '';
|
||||
const panLongTasks = startLongTaskWindow();
|
||||
const panStarted = performance.now();
|
||||
const panMoveRenders = [];
|
||||
const panTerminalRenders = [];
|
||||
let relevantDuringGestureFullRenders = 0;
|
||||
let relevantDuringGestureIntakes = 0;
|
||||
for (let dragIndex = 0; dragIndex < 4; dragIndex++) {
|
||||
const pointerId = 992 + dragIndex;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: rect.left + 200, clientY: rect.top + 200, button: 0, buttons: 1,
|
||||
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
const panMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 205 + index * 3, clientY: rect.top + 202 + index,
|
||||
button: 0, buttons: 1, bubbles: true, composed: true,
|
||||
pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if (dragIndex === 0 && index === 10) {
|
||||
const deferredBefore = fullRenderCount;
|
||||
const deferredIntakeBefore = card._hassSequence;
|
||||
for (let tick = 0; tick < 3; tick++) {
|
||||
currentStates = {
|
||||
...currentStates,
|
||||
[firstEntity]: { ...currentStates[firstEntity], state: `gesture-${tick}` },
|
||||
};
|
||||
card.hass = hassFor(currentStates);
|
||||
}
|
||||
await card.updateComplete;
|
||||
relevantDuringGestureFullRenders += fullRenderCount - deferredBefore;
|
||||
relevantDuringGestureIntakes += card._hassSequence - deferredIntakeBefore;
|
||||
}
|
||||
if ((index + 1) % 10 === 0) {
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
}
|
||||
const beforeTerminal = fullRenderCount;
|
||||
panMoveRenders.push(beforeTerminal - panMoveBefore);
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', {
|
||||
clientX: rect.left + 265, clientY: rect.top + 222, button: 0, buttons: 0,
|
||||
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
panTerminalRenders.push(fullRenderCount - beforeTerminal);
|
||||
}
|
||||
timings.panSeriesMs = performance.now() - panStarted;
|
||||
interactionLongTasks.panSeries = await panLongTasks.stop();
|
||||
const movedViewBox = cameraSvg?.getAttribute('viewBox') || '';
|
||||
const currentView = card._viewOr(card._baseVb());
|
||||
const currentDevice = card._devices.find((item) => item.id === device.dataset.id);
|
||||
const position = currentDevice ? card._pos(currentDevice) : null;
|
||||
const scene = position ? card._scenePoint([position.x, position.y]) : null;
|
||||
const deviceRect = device.getBoundingClientRect();
|
||||
const expectedX = scene ? rect.left + ((scene[0] - currentView.x) / currentView.w) * rect.width : 0;
|
||||
const expectedY = scene ? rect.top + ((scene[1] - currentView.y) / currentView.h) * rect.height : 0;
|
||||
const overlayErrorPx = scene ? Math.hypot(
|
||||
deviceRect.left + deviceRect.width / 2 - expectedX,
|
||||
deviceRect.top + deviceRect.height / 2 - expectedY,
|
||||
) : Infinity;
|
||||
|
||||
const cameraLongTasks = startLongTaskWindow();
|
||||
const cameraStarted = performance.now();
|
||||
const cameraBefore = fullRenderCount;
|
||||
const pinchPointerA = 1001, pinchPointerB = 1002;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: rect.left + 360, clientY: rect.top + 300, button: 0, buttons: 1,
|
||||
bubbles: true, composed: true, pointerId: pinchPointerA,
|
||||
pointerType: 'touch', isPrimary: true,
|
||||
}));
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: rect.left + 460, clientY: rect.top + 300, button: 0, buttons: 1,
|
||||
bubbles: true, composed: true, pointerId: pinchPointerB,
|
||||
pointerType: 'touch', isPrimary: false,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
const pinchMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 360 - index, clientY: rect.top + 300,
|
||||
button: 0, buttons: 1, bubbles: true, composed: true,
|
||||
pointerId: pinchPointerA, pointerType: 'touch', isPrimary: true,
|
||||
}));
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 460 + index, clientY: rect.top + 300,
|
||||
button: 0, buttons: 1, bubbles: true, composed: true,
|
||||
pointerId: pinchPointerB, pointerType: 'touch', isPrimary: false,
|
||||
}));
|
||||
if ((index + 1) % 5 === 0) await frame();
|
||||
}
|
||||
const pinchTerminalBefore = fullRenderCount;
|
||||
for (const pointerId of [pinchPointerA, pinchPointerB]) {
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', {
|
||||
clientX: rect.left + 410, clientY: rect.top + 300,
|
||||
button: 0, buttons: 0, bubbles: true, composed: true,
|
||||
pointerId, pointerType: 'touch', isPrimary: pointerId === pinchPointerA,
|
||||
}));
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const pinchTerminalRenders = fullRenderCount - pinchTerminalBefore;
|
||||
for (let index = 0; index < 4; index++) {
|
||||
stage.dispatchEvent(new WheelEvent('wheel', {
|
||||
deltaY: index % 2 ? 120 : -120,
|
||||
clientX: rect.left + rect.width / 2,
|
||||
clientY: rect.top + rect.height / 2,
|
||||
bubbles: true, cancelable: true,
|
||||
}));
|
||||
}
|
||||
await until(() => !card._cameraTransition.active);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
timings.cameraSeriesMs = performance.now() - cameraStarted;
|
||||
interactionLongTasks.cameraSeries = await cameraLongTasks.stop();
|
||||
deltas.cameraMoveFullRenders = pinchTerminalBefore - pinchMoveBefore;
|
||||
deltas.cameraTerminalFullRenders = pinchTerminalRenders;
|
||||
deltas.cameraFullRenders = fullRenderCount - cameraBefore;
|
||||
|
||||
const editorLongTaskWindows = [];
|
||||
const editorConfigBefore = JSON.stringify(card._serverCfg);
|
||||
const editorCallsBefore = wsCalls;
|
||||
const editorPaintBefore = card._liveEditorPaintCount || 0;
|
||||
let editorElapsed = 0;
|
||||
const editorMoveRenders = [];
|
||||
const editorTerminalRenders = [];
|
||||
|
||||
card._setMode('plan', false);
|
||||
card._tool = 'draw';
|
||||
card._path = [[100, 100]];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
|
||||
for (let index = 0; index < 5; index++) await frame();
|
||||
let editorStage = card.renderRoot.querySelector('.stage');
|
||||
let editorRect = editorStage.getBoundingClientRect();
|
||||
let editorView = card._viewOr(card._baseVb());
|
||||
const fromScene = (x, y) => ({
|
||||
clientX: editorRect.left + ((x - editorView.x) / editorView.w) * editorRect.width,
|
||||
clientY: editorRect.top + ((y - editorView.y) / editorView.h) * editorRect.height,
|
||||
});
|
||||
let editorPartLongTasks = startLongTaskWindow();
|
||||
let partStarted = performance.now();
|
||||
let editorMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 40; index++) {
|
||||
editorStage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
...fromScene(110 + index * 2, 110 + index),
|
||||
bubbles: true, composed: true, pointerId: 1101,
|
||||
pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 10 === 0) await frame();
|
||||
}
|
||||
let editorTerminalBefore = fullRenderCount;
|
||||
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
|
||||
card._cursorPt = null;
|
||||
card._path = [];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
|
||||
editorElapsed += performance.now() - partStarted;
|
||||
editorLongTaskWindows.push(await editorPartLongTasks.stop());
|
||||
|
||||
card._tool = 'resize';
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
const resizeRoom = card._rszRooms()[0];
|
||||
const resizeEvent = {
|
||||
pointerId: 1102, stopPropagation: () => undefined,
|
||||
preventDefault: () => undefined, target: null,
|
||||
};
|
||||
card._rszEdgeDown(resizeEvent, resizeRoom.id, 1);
|
||||
const resizePlan = card._resize?.plan || card._rszDrag?.plan;
|
||||
if (!resizePlan) throw new Error('interaction resize plan was not created');
|
||||
const resizePreflightBefore = physicalPreflightCount;
|
||||
const resizePreflightMsBefore = physicalPreflightMs;
|
||||
editorStage = card.renderRoot.querySelector('.stage');
|
||||
editorRect = editorStage.getBoundingClientRect();
|
||||
editorView = card._viewOr(card._baseVb());
|
||||
// A browser delivers pointerdown and pointermove as separate tasks.
|
||||
// Keep the synthetic harness from attributing both handlers to one
|
||||
// impossible long task while still timing every move-side preflight.
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
editorPartLongTasks = startLongTaskWindow();
|
||||
partStarted = performance.now();
|
||||
editorMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 40; index++) {
|
||||
const target = [
|
||||
resizePlan.a[0] + resizePlan.n[0] * card._gridPitch * (1 + index / 40),
|
||||
resizePlan.a[1] + resizePlan.n[1] * card._gridPitch * (1 + index / 40),
|
||||
];
|
||||
card._rszMove({
|
||||
...resizeEvent,
|
||||
clientX: editorRect.left + ((target[0] - editorView.x) / editorView.w) * editorRect.width,
|
||||
clientY: editorRect.top + ((target[1] - editorView.y) / editorView.h) * editorRect.height,
|
||||
});
|
||||
if ((index + 1) % 10 === 0) await frame();
|
||||
}
|
||||
editorTerminalBefore = fullRenderCount;
|
||||
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
|
||||
deltas.resizeLivePreflightChecks = physicalPreflightCount - resizePreflightBefore;
|
||||
deltas.resizeLivePreflightMs = physicalPreflightMs - resizePreflightMsBefore;
|
||||
card._rszCancelDrag();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
|
||||
editorElapsed += performance.now() - partStarted;
|
||||
editorLongTaskWindows.push(await editorPartLongTasks.stop());
|
||||
|
||||
card._setMode('decor', false);
|
||||
card._decorTool = 'select';
|
||||
const decorShape = card._decorList.find((shape) =>
|
||||
shape.kind === 'furniture' || shape.kind === 'rect' || shape.kind === 'ellipse');
|
||||
if (!decorShape) throw new Error('interaction profile has no transformable decor');
|
||||
card._decorSel = decorShape.id;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
|
||||
for (let index = 0; index < 5; index++) await frame();
|
||||
card._dtMeasure();
|
||||
await card.updateComplete;
|
||||
editorStage = card.renderRoot.querySelector('.stage');
|
||||
editorRect = editorStage.getBoundingClientRect();
|
||||
editorView = card._viewOr(card._baseVb());
|
||||
const box = card._decorBoxOf(decorShape);
|
||||
const cornerX = box.x + box.w, cornerY = box.y + box.h;
|
||||
const decorPointer = (x, y) => ({
|
||||
pointerId: 1103, pointerType: 'mouse', shiftKey: false,
|
||||
clientX: editorRect.left + ((x - editorView.x) / editorView.w) * editorRect.width,
|
||||
clientY: editorRect.top + ((y - editorView.y) / editorView.h) * editorRect.height,
|
||||
stopPropagation: () => undefined, preventDefault: () => undefined,
|
||||
currentTarget: null, target: null,
|
||||
});
|
||||
card._dtStart(decorPointer(cornerX, cornerY), 'scale', [1, 1]);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorPartLongTasks = startLongTaskWindow();
|
||||
partStarted = performance.now();
|
||||
editorMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 40; index++) {
|
||||
card._dtMove(decorPointer(cornerX + index, cornerY + index / 2));
|
||||
if ((index + 1) % 10 === 0) await frame();
|
||||
}
|
||||
editorTerminalBefore = fullRenderCount;
|
||||
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
|
||||
card._cancelDecorGesture();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
|
||||
editorElapsed += performance.now() - partStarted;
|
||||
editorLongTaskWindows.push(await editorPartLongTasks.stop());
|
||||
timings.editorSeriesMs = editorElapsed;
|
||||
interactionLongTasks.editorSeries = {
|
||||
supported: editorLongTaskWindows.every((window) => window.supported),
|
||||
count: editorLongTaskWindows.reduce((sum, window) => sum + window.count, 0),
|
||||
maxMs: Math.max(0, ...editorLongTaskWindows.map((window) => window.maxMs)),
|
||||
totalMs: editorLongTaskWindows.reduce((sum, window) => sum + window.totalMs, 0),
|
||||
};
|
||||
deltas.editorMoveFullRenders = editorMoveRenders;
|
||||
deltas.editorTerminalFullRenders = editorTerminalRenders;
|
||||
deltas.editorLivePaints = (card._liveEditorPaintCount || 0) - editorPaintBefore;
|
||||
deltas.editorConfigStable = JSON.stringify(card._serverCfg) === editorConfigBefore;
|
||||
deltas.editorWsWrites = wsCalls - editorCallsBefore;
|
||||
deltas.editorLongTaskParts = editorLongTaskWindows;
|
||||
|
||||
card._setMode('view', false);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
deltas.panMoveFullRenders = panMoveRenders;
|
||||
deltas.terminalFullRenders = panTerminalRenders;
|
||||
deltas.relevantDuringGestureFullRenders = relevantDuringGestureFullRenders;
|
||||
deltas.relevantDuringGestureIntakes = relevantDuringGestureIntakes;
|
||||
deltas.viewBoxMoved = !!initialViewBox && initialViewBox !== movedViewBox;
|
||||
deltas.overlayErrorPx = Number(overlayErrorPx.toFixed(2));
|
||||
deltas.heavyNodeStable = card.renderRoot.querySelector('.wallbodies') === heavy;
|
||||
deltas.diagnosticsScans = diagnosticsScanCount - diagnosticsBefore;
|
||||
deltas.fullRenderReasons = fullRenderReasons.slice(reasonsBefore);
|
||||
interactionTimings = timings;
|
||||
interactionDiagnostics = { supported: requiresInteraction, ...deltas };
|
||||
if (requiresInteraction && (
|
||||
deltas.irrelevantFullRenders !== 0 || deltas.irrelevantHassIntakes !== 30
|
||||
|| deltas.relevantOutsideGestureFullRenders !== 1 || deltas.hoverFullRenders !== 0
|
||||
|| deltas.hoverTargets.join(',') !== 'device,room,miss'
|
||||
|| deltas.panMoveFullRenders.some((count) => count !== 0)
|
||||
|| deltas.terminalFullRenders.some((count) => count !== 1)
|
||||
|| deltas.relevantDuringGestureFullRenders !== 0 || !deltas.tooltipVisible
|
||||
|| deltas.relevantDuringGestureIntakes !== 3
|
||||
|| deltas.cameraMoveFullRenders !== 0 || deltas.cameraTerminalFullRenders !== 1
|
||||
|| deltas.editorMoveFullRenders.some((count) => count !== 0)
|
||||
|| deltas.editorTerminalFullRenders.some((count) => count > 1)
|
||||
|| deltas.resizeLivePreflightChecks < 1
|
||||
|| deltas.editorLivePaints < 3 || !deltas.editorConfigStable || deltas.editorWsWrites !== 0
|
||||
|| !deltas.viewBoxMoved || deltas.overlayErrorPx > 1 || !deltas.heavyNodeStable
|
||||
|| deltas.diagnosticsScans !== 0
|
||||
)) throw new Error(`interaction structural contract failed: ${JSON.stringify(interactionDiagnostics)}`);
|
||||
}) : null;
|
||||
|
||||
let planSnapDiagnostics = null;
|
||||
const planSnapPointer = planSnap ? await duration(async () => {
|
||||
card._setMode('plan');
|
||||
@@ -432,6 +860,11 @@ try {
|
||||
planSnapPointerMs: planSnapPointer.ms,
|
||||
planSnapDiagnostics,
|
||||
} : {}),
|
||||
...(interactionSeries ? {
|
||||
interactionSeriesMs: interactionSeries.ms,
|
||||
...interactionTimings,
|
||||
interactionDiagnostics,
|
||||
} : {}),
|
||||
spaceSwitchMs: spaceSwitch.ms,
|
||||
stateUpdateMs: stateUpdate.ms,
|
||||
resizePreviewMs: resizePreview.ms,
|
||||
@@ -442,6 +875,7 @@ try {
|
||||
load: loadLongTaskResult,
|
||||
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
|
||||
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
|
||||
...(interactionSeries ? interactionLongTasks || {} : {}),
|
||||
spaceSwitch: spaceSwitch.longTasks,
|
||||
stateUpdate: stateUpdate.longTasks,
|
||||
resizePreview: resizePreview.longTasks,
|
||||
@@ -461,6 +895,7 @@ try {
|
||||
}, {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
|
||||
interaction, requiresInteraction,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
@@ -474,6 +909,9 @@ const metricNames = [
|
||||
];
|
||||
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
|
||||
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
|
||||
if (interaction) metricNames.splice(2, 0,
|
||||
'interactionSeriesMs', 'hoverSeriesMs', 'panSeriesMs', 'cameraSeriesMs',
|
||||
'editorSeriesMs', 'irrelevantHaTicksMs', 'relevantHaTickMs');
|
||||
const report = {
|
||||
schema: 2,
|
||||
profile,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/** #54: bounded provider normalization and incident-only hover performance. */
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { mapTopologies, normalizeZhaTopology, resolveMappedTopologyHover } from '../test-build/zigbee-topology.js';
|
||||
|
||||
const NODES = 500;
|
||||
const SAMPLES = 9;
|
||||
const BUDGETS = { normalizeMs: 80, mapMs: 160, firstHoverMs: 180, repeatedHoverMs: 120 };
|
||||
const hex = (index) => index.toString(16).padStart(16, '0');
|
||||
const rows = Array.from({ length: NODES }, (_, index) => ({
|
||||
ieee: hex(index + 1), device_reg_id: `d${index}`, device_type: index ? 'Router' : 'Coordinator',
|
||||
neighbors: [1, 2, 3, 4, 5, 6].flatMap((offset) => {
|
||||
const other = index + offset;
|
||||
return other < NODES ? [{ ieee: hex(other + 1), lqi: (index * 17 + offset * 13) % 256 }] : [];
|
||||
}),
|
||||
}));
|
||||
const registry = {
|
||||
revision: 1, authoritative: true, access: 'full', lastSuccess: Date.now(), entities: {},
|
||||
devices: Object.fromEntries(rows.map((_, index) => [`d${index}`, { id: `d${index}` }])),
|
||||
};
|
||||
const active = { kind: 'active', enabledEntityIds: [], allEntityIds: [] };
|
||||
const devices = rows.map((_, index) => ({
|
||||
id: `m${index}`, name: '', model: '', area: '', space: index % 5 ? 'main' : 'remote',
|
||||
icon: '', entities: [], bindingKind: 'device', bindingRef: `d${index}`, bindingStatus: active,
|
||||
}));
|
||||
|
||||
const measured = (fn) => {
|
||||
const samples = [];
|
||||
for (let i = 0; i < SAMPLES; i++) {
|
||||
const started = performance.now(); fn(); samples.push(performance.now() - started);
|
||||
}
|
||||
samples.sort((a, b) => a - b);
|
||||
return samples[Math.floor(samples.length * .9)];
|
||||
};
|
||||
|
||||
let topology; let mapped;
|
||||
const normalizeMs = measured(() => { topology = normalizeZhaTopology(rows); });
|
||||
const mapMs = measured(() => { mapped = mapTopologies([topology], devices, registry); });
|
||||
const firstHoverMs = measured(() => resolveMappedTopologyHover(mapped, 'main', 'm1'));
|
||||
const repeatedHoverMs = measured(() => {
|
||||
for (let i = 1; i <= 20; i++) resolveMappedTopologyHover(mapped, 'main', `m${i}`);
|
||||
});
|
||||
const result = { nodes: topology.nodes.length, links: topology.links.length,
|
||||
normalizeMs, mapMs, firstHoverMs, repeatedHoverMs };
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
for (const [name, budget] of Object.entries(BUDGETS)) {
|
||||
if (result[name] > budget) throw new Error(`${name} ${result[name].toFixed(2)} ms > ${budget} ms`);
|
||||
}
|
||||
+28
-1
@@ -10,6 +10,9 @@ import {
|
||||
goldenAcceptancePlan, goldenAcceptanceRefusal, goldenSilentDeclarations,
|
||||
goldenWitnessRefusal,
|
||||
} from '../../scripts/golden-acceptance.mjs';
|
||||
import {
|
||||
assertCaptureEnvironment, captureEnvironment, environmentNote,
|
||||
} from '../../scripts/capture-environment.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const reviewed = process.argv.includes('--reviewed');
|
||||
@@ -38,6 +41,16 @@ if (typeof report.chromium !== 'string' || !report.chromium)
|
||||
throw new Error('candidate report does not identify its Chromium build');
|
||||
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
|
||||
|
||||
// Среда приёмки (#455). Отчёт съёмки платформу не несёт — `run.mjs` входит в
|
||||
// корпус sourceFingerprint, и его правка объявила бы устаревшими бандл,
|
||||
// скриншоты документации и сам индекс эталонов. Но приёмка идёт там же, где
|
||||
// лежат артефакты, поэтому её платформа — честный признак среды кадров.
|
||||
const environment = captureEnvironment();
|
||||
const foreignAllowed = assertCaptureEnvironment({ kind: 'golden', stage: 'accept' });
|
||||
if (foreignAllowed) {
|
||||
console.log(`Чужая среда приёмки разрешена осознанно: ${foreignAllowed}`);
|
||||
}
|
||||
|
||||
const refusal = goldenAcceptanceRefusal(report.results, declared, declaredNew);
|
||||
if (refusal) throw new Error(refusal);
|
||||
|
||||
@@ -72,7 +85,18 @@ const witnessCheck = goldenWitnessRefusal({
|
||||
skipWitnesses,
|
||||
skipReason,
|
||||
});
|
||||
if (witnessCheck.refusal) throw new Error(witnessCheck.refusal);
|
||||
if (witnessCheck.refusal) {
|
||||
// Приписка про среду — то, чего не хватало отказу: «свидетелей 0 из 10» без
|
||||
// неё читается как «объяви больше сцен», и обход в одну команду выглядит
|
||||
// решением (#455).
|
||||
const note = environmentNote({
|
||||
capturedOn: environment.platform,
|
||||
acceptedOn: existsSync(manifestPath)
|
||||
? JSON.parse(readFileSync(manifestPath, 'utf8')).platform || null
|
||||
: null,
|
||||
});
|
||||
throw new Error(note ? `${witnessCheck.refusal}\n${note}` : witnessCheck.refusal);
|
||||
}
|
||||
// Кандидат проверяется целиком, до всякого решения о замене: сломанный отчёт
|
||||
// не имеет права оставить каталог эталонов половинным.
|
||||
for (const scenario of GOLDEN_SCENARIOS) {
|
||||
@@ -102,6 +126,9 @@ writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify
|
||||
acceptedAt: new Date().toISOString(),
|
||||
sourceFingerprint: report.buildFingerprint,
|
||||
chromium: report.chromium,
|
||||
// Платформа рядом с версией браузера: провенанс среды, по которому следующая
|
||||
// приёмка отличит «объявили не то» от «сняли не там» (#455).
|
||||
platform: environment.platform,
|
||||
// #355: след приёмки в артефакте, не только в истории shell.
|
||||
witnesses: skipWitnesses
|
||||
? { skipped: true, reason: skipReason }
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 54,
|
||||
"acceptedAt": "2026-09-03T06:25:02.855Z",
|
||||
"sourceFingerprint": "b69bdfe3df225cf1b96fea0159a03a620e38cb94ec6106f9ee3b0e57cdefd183",
|
||||
"acceptedAt": "2026-09-04T13:10:56.492Z",
|
||||
"sourceFingerprint": "7acb1b9d3bb384bc1a0c70fb01347386c157ae88b12b00ccd876b7658fc84a1c",
|
||||
"chromium": "151.0.7922.34",
|
||||
"witnesses": {
|
||||
"count": 132,
|
||||
"count": 127,
|
||||
"floor": 10
|
||||
},
|
||||
"scenarios": {
|
||||
@@ -142,9 +142,9 @@
|
||||
"device-help-popover-light-ru": "c2146f13d03841c395b0772f1a21de358c62a8703d3d02e7d026fac8ae29955e",
|
||||
"decor-color-popover-mobile-ru": "c65318327c6e4631c769419e791a749d667b4dfc36f2e3fc46d6fac53180cf1b",
|
||||
"decor-color-popover-desktop-en": "60de64b1bc072327cb88d2e876732d50d5cb7441c850f52b7af6f4ed5c6de22b",
|
||||
"general-color-popover-desktop-en": "5b47ab921f889c9ad41cab195e6efeed283f014d37f5377714b54ef30282ebf6",
|
||||
"settings-help-zoom-200-en-light": "bb1192a52bab5ebc6984a5d799a32d05f9ce374ce6b493f5f618b639eedce8d5",
|
||||
"settings-help-zoom-200-ru-dark": "13e7fdd19017c38a5659394e622ff54ac8a426f9cb6e85b3248864767aa9f42b",
|
||||
"general-color-popover-desktop-en": "ba147ee0a21d0ad0986fbdf933b4b36b8c8ae7dee005efb8befe7fc59a9dc28d",
|
||||
"settings-help-zoom-200-en-light": "9f5e2ae5ac30c99c52870cebd5114096b413a6e4a5ee060b87f3a0f327065ee6",
|
||||
"settings-help-zoom-200-ru-dark": "07b3a635617d9b592c7b51e3a34bd1f2b88447814b60d694746ba85bd178520d",
|
||||
"support-desktop-empty-light-en": "fecabb63521f258acc66ecdf7a438db10c73d0eac39618c55fb51287080f01f5",
|
||||
"support-desktop-preview-dark-en": "a866aa74e3385ac45e837b328d64ed65bc4b108eb9dc4149fcccf393975e72b6",
|
||||
"support-phone-validation-light-ru": "7a461c00631a05872b9aab1f4c0b0b65d1c918273ff06bba25c7bd2e87fba1ca",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
+10
-13
@@ -760,10 +760,9 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
window.__goldenEditor?.remove?.();
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
history.replaceState(null, '', scenario.labs?.length
|
||||
? `?hp-labs=${encodeURIComponent(scenario.labs.join(','))}` : location.pathname);
|
||||
if (scenario.labs?.length) {
|
||||
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(scenario.labs));
|
||||
history.replaceState(null, '', scenario.alpha ? '?hp_alpha=1' : location.pathname);
|
||||
if (scenario.alpha) {
|
||||
localStorage.setItem('houseplan_card_alpha_v1', '1');
|
||||
}
|
||||
if (scenario.projection && scenario.space) {
|
||||
localStorage.setItem('houseplan_card_view_v1', JSON.stringify({
|
||||
@@ -818,21 +817,16 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig(cardConfig);
|
||||
host.replaceChildren(card);
|
||||
if (scenario.testOnlyLabsSnapshot) {
|
||||
if (!scenario.labs?.length || typeof card._onLabsSnapshot !== 'function') {
|
||||
throw new Error(`invalid test-only Labs contract: ${scenario.id}`);
|
||||
}
|
||||
// connectedCallback has already received the real (expired) registry
|
||||
// snapshot. Inject only the renderer fixture before hass/model boot so
|
||||
// fit and warm-remount follow the former live-Labs lifecycle exactly.
|
||||
card._onLabsSnapshot({ active: Object.freeze([...scenario.labs]), space: '' });
|
||||
}
|
||||
card.hass = hassFor();
|
||||
await until(() => card._loadOk && card._model?.length === fixture.config.spaces.length);
|
||||
await card.updateComplete;
|
||||
const expectedDevices = Object.keys(fixture.devices || {}).length;
|
||||
if (expectedDevices) await until(() => card._devices?.length >= expectedDevices);
|
||||
await until(() => card._booting === false);
|
||||
if (scenario.alpha
|
||||
&& (window.__hpAlpha !== true || JSON.stringify(window.__hpLabs) !== '["iso"]')) {
|
||||
throw new Error(`alpha contract did not activate isometric capability: ${scenario.id}`);
|
||||
}
|
||||
// Golden scenarios intentionally call internal editor commands directly.
|
||||
// Preload the lazy runtime for that legacy harness contract; cold-View
|
||||
// loading and retry semantics are covered by smoke_lazy_editor_chunk.
|
||||
@@ -1066,6 +1060,9 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
card._setProjection('iso');
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
if (card._effectiveProjection?.() !== 'iso') {
|
||||
throw new Error(`isometric golden rendered Flat: ${scenario.id}`);
|
||||
}
|
||||
await until(() => card._renderProjection === 'iso');
|
||||
}
|
||||
if (Number.isFinite(scenario.zoom)) {
|
||||
|
||||
+8
-13
@@ -6,11 +6,6 @@ export const GOLDEN_MATRIX_VERSION = 54;
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
|
||||
// The product flag expires at 1.65.0 by contract. Golden keeps the dormant
|
||||
// renderer covered through an explicit card-only snapshot; this never changes
|
||||
// URL/storage/registry behaviour and must fail if the rendered view stays flat.
|
||||
const expiredIsoFixture = { testOnlyLabsSnapshot: true };
|
||||
|
||||
const openingRoomContract = {
|
||||
kind: 'room', wallCm: 30, surface: 'flat',
|
||||
openings: [
|
||||
@@ -200,23 +195,23 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
cornerSplitWall: 'zero-taper', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
// Stage 2 material/floor-edge plus door, window, gate and nested-room coverage.
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
alpha: true, projection: 'iso',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
alpha: true, projection: 'iso',
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-live-layers-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
alpha: true, projection: 'iso',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-no-borders-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, showBorders: false, fillMode: 'custom',
|
||||
alpha: true, projection: 'iso', showBorders: false, fillMode: 'custom',
|
||||
customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-touch-kiosk-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, kiosk: true,
|
||||
alpha: true, projection: 'iso', kiosk: true,
|
||||
theme: 'dark', viewport: { width: 390, height: 760 }, ...stage },
|
||||
{ id: 'isometric-large-warm-remount-dark', fixture: 'large', space: 'perf-floor-2', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, warmRemount: true,
|
||||
alpha: true, projection: 'iso', warmRemount: true,
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'geometry-view-dark-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
@@ -348,7 +343,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-wall-junctions-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
alpha: true, projection: 'iso',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'opening-placement-door-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
|
||||
// The shared centre edge is a long 25 cm physical wall. It can contain the
|
||||
@@ -375,7 +370,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
mode: 'view', openingSymbolContract: openingFlipContract,
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-opening-symbol-parity-dark', fixture: 'visual', space: 'golden-opening-symbols',
|
||||
mode: 'view', labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
mode: 'view', alpha: true, projection: 'iso',
|
||||
openingSymbolContract: openingIsoContract,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
|
||||
|
||||
+28
-1
@@ -1,12 +1,39 @@
|
||||
import {
|
||||
captureEnvironment, foreignCaptureAllowance, foreignCaptureRefusal,
|
||||
} from '../../scripts/capture-environment.mjs';
|
||||
|
||||
// The name must NOT end with `manifest.json`: the HACS submission check globs
|
||||
// `*manifest.json` over the whole clone of the default branch and refuses a
|
||||
// repository with more than one match (test/repo-hygiene.test.mjs).
|
||||
export const GOLDEN_BASELINE_MANIFEST = 'baselines-index.json';
|
||||
|
||||
export const assertGoldenInvocation = (mode, scenarioFilter = '') => {
|
||||
/**
|
||||
* Проверка вызова — и заодно проверка среды съёмки (#455).
|
||||
*
|
||||
* Почему здесь, а не в `run.mjs`: `run.mjs` входит в корпус
|
||||
* `sourceFingerprint`, и его правка объявляет устаревшими закоммиченный бандл,
|
||||
* манифест скриншотов документации и индекс эталонов. Этот файл из корпуса
|
||||
* исключён именно как «предикаты, вызываемые до и после съёмки», и уже
|
||||
* вызывается первой строкой `run.mjs` — гейт встаёт без единой правки
|
||||
* фингерпринтуемого файла.
|
||||
*
|
||||
* Платформа — ПАРАМЕТР, а не `process.platform` внутри: иначе юнит стал бы
|
||||
* зелёным на Linux и красным на машине владельца, то есть тестом про хост, а
|
||||
* не про правило.
|
||||
*/
|
||||
export const assertGoldenInvocation = (mode, scenarioFilter = '', options = {}) => {
|
||||
if (!['capture', 'verify'].includes(mode)) throw new Error(`unknown golden mode: ${mode}`);
|
||||
if (mode === 'verify' && scenarioFilter)
|
||||
throw new Error('golden verify must run the complete matrix; use capture for a diagnostic --scenario run');
|
||||
// Диагностический прогон verify в чужой среде законен: он ничего не принимает.
|
||||
if (mode !== 'capture') return;
|
||||
const platform = options.platform ?? captureEnvironment().platform;
|
||||
const allowance = options.allowance ?? foreignCaptureAllowance();
|
||||
const { refusal, allowance: accepted } = foreignCaptureRefusal({
|
||||
platform, kind: 'golden', stage: 'capture', allowance,
|
||||
});
|
||||
if (refusal) throw new Error(refusal);
|
||||
if (accepted) console.log(`Чужая среда съёмки разрешена осознанно: ${accepted}`);
|
||||
};
|
||||
|
||||
/** A reviewed golden matrix is exact: neither an orphan PNG nor a stale hash
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
fixture. The fixture has 60 rooms, 200 devices, 100 openings, 60 partitions,
|
||||
40 columns and 500 decor objects on three floors.
|
||||
|
||||
Issue #89 adds `large-house-isometric-v1` through the same runner. Because the
|
||||
product flag expires at 1.65.0, the runner injects an explicit test-only Labs
|
||||
snapshot, selects iso per fixture space, measures the View toggle and records
|
||||
the capped `isoGeometry` cache. A comparison SHA predating #89 remains flat
|
||||
while reporting the same profile. The dedicated
|
||||
Issue #89 adds `large-house-isometric-v1` through the same runner. The runner
|
||||
enables the current candidate through the real `hp_alpha=1` URL/storage
|
||||
contract, selects iso per fixture space, measures the View toggle and records
|
||||
the capped `isoGeometry` cache. A compatibility-only snapshot keeps comparison
|
||||
bundles from before #448 measurable without restoring a legacy user-facing
|
||||
activation path; a comparison SHA predating #89 remains flat while reporting
|
||||
the same profile. The dedicated
|
||||
`budgets-large-house-isometric.json` applies the reviewed 20% relative allowance
|
||||
plus absolute noise/ceiling checks. Only the exact-SHA Linux workflow is gate
|
||||
evidence; a local report is diagnostic.
|
||||
@@ -23,6 +25,24 @@ websocket traffic change. Its dedicated budget retains every original timing,
|
||||
heap and cache ceiling and adds the measured pointer series plus a one-entry
|
||||
snap-geometry cache cap. Exact-SHA Linux output is the only gate evidence.
|
||||
|
||||
Issue #451 adds `large-house-interaction-v1`. It dispatches 120 real
|
||||
device/room/free-background hover moves, four 20-move pans, pinch/wheel camera input, three editor
|
||||
drag series and 30 unrelated HA snapshots. It also injects relevant snapshots
|
||||
inside and outside a gesture and counts heavy `_renderBody()` passes plus full
|
||||
marker-binding diagnostic scans. Current bundles fail inside the runner unless
|
||||
hover, editor moves and unrelated ticks perform zero full renders, a relevant
|
||||
tick is deferred during pan, each terminal event reconciles at most one full
|
||||
frame, the heavy wall DOM remains identical, and the projected HTML device
|
||||
layer stays within one CSS pixel of its SVG scene position. Older comparison
|
||||
bundles still produce timing baselines; structural assertions become mandatory
|
||||
only when the target source contains the live viewport implementation. Its
|
||||
dedicated budget preserves every existing large-house ceiling and adds the
|
||||
individual interaction timing gates.
|
||||
|
||||
Structural assertions are evaluated independently of timing budgets: a fast
|
||||
run still fails when an interaction performs an unexpected full render or
|
||||
changes the heavy scene DOM.
|
||||
|
||||
The runner records seven measured samples after one discarded warm-up. With
|
||||
this intentionally small CI sample, the nearest-rank `p95` is the observed
|
||||
maximum; reports keep the conventional field name but should be read as a
|
||||
@@ -124,6 +144,7 @@ Build and copy a fresh demo bundle first, then run:
|
||||
```bash
|
||||
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
|
||||
npm run benchmark:large-house-plan-snap -- --samples=7 --warmups=1 --output=artifacts/performance/plan-snap-local.json
|
||||
npm run benchmark:large-house-interaction -- --samples=7 --warmups=1 --output=artifacts/performance/interaction-local.json
|
||||
```
|
||||
|
||||
A local report is diagnostic only; it cannot replace the CI comparison.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-interaction-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"modelReadyMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 200,
|
||||
"hardMaxMs": 2500
|
||||
},
|
||||
"firstStableRenderMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"interactionSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 150,
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"hoverSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"panSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"cameraSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"editorSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 100,
|
||||
"hardMaxMs": 750
|
||||
},
|
||||
"irrelevantHaTicksMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 40,
|
||||
"hardMaxMs": 250
|
||||
},
|
||||
"relevantHaTickMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 40,
|
||||
"hardMaxMs": 250
|
||||
},
|
||||
"spaceSwitchMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 75,
|
||||
"hardMaxMs": 1500
|
||||
},
|
||||
"stateUpdateMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 75,
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"resizePreviewMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.4,
|
||||
"noiseAllowanceMs": 150,
|
||||
"hardMaxMs": 2000
|
||||
},
|
||||
"panZoomMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"settingsDialogMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 100,
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"switchCycleMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.35,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 7000
|
||||
}
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 3000,
|
||||
"maxSingleRegressionRatio": 0.3,
|
||||
"maxSingleNoiseAllowanceMs": 250,
|
||||
"maxCountP95": 30,
|
||||
"maxCountRegressionRatio": 0.35,
|
||||
"countNoiseAllowance": 3,
|
||||
"maxTotalP95Ms": 12000,
|
||||
"maxTotalRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 150
|
||||
},
|
||||
"longTaskWindows": {
|
||||
"hoverSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
|
||||
"panSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
|
||||
"cameraSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
|
||||
"editorSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 }
|
||||
},
|
||||
"heap": {
|
||||
"required": true,
|
||||
"hardMaxGrowthBytes": 67108864,
|
||||
"maxRegressionRatio": 0.75,
|
||||
"noiseAllowanceBytes": 16777216
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 100,
|
||||
"glowClip": 200,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
"openingWallIndex": 1,
|
||||
"planSnapGeometry": 1,
|
||||
"wallFaceGraph": 4
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
"glowClip": 0,
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0,
|
||||
"planSnapGeometry": 0,
|
||||
"wallFaceGraph": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -17,22 +17,41 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
label: 'large-house-v1',
|
||||
methods: Object.freeze([
|
||||
'_baseVb',
|
||||
'_bindingStatus',
|
||||
'_cancelDecorGesture',
|
||||
'_checkSpacePhysicalGeometry',
|
||||
'_decorBoxOf',
|
||||
'_dtMeasure',
|
||||
'_dtMove',
|
||||
'_dtStart',
|
||||
'_openSettingsDialog',
|
||||
'_pickSpace',
|
||||
'_pos',
|
||||
'_renderBody',
|
||||
'_rszCancelDrag',
|
||||
'_rszEdgeDown',
|
||||
'_rszMove',
|
||||
'_rszRooms',
|
||||
'_setMode',
|
||||
'_scenePoint',
|
||||
'_viewOr',
|
||||
]),
|
||||
fields: Object.freeze([
|
||||
'_booting',
|
||||
'_bootSoft',
|
||||
'_cameraTransition',
|
||||
...CACHE_FIELDS,
|
||||
'_continuity',
|
||||
'_cursorPt',
|
||||
'_decorList',
|
||||
'_decorSel',
|
||||
'_decorTool',
|
||||
'_devices',
|
||||
'_gridPitch',
|
||||
'_hassSequence',
|
||||
'_loadOk',
|
||||
'_model',
|
||||
'_modeTransitionBusy',
|
||||
'_path',
|
||||
'_serverCfg',
|
||||
'_settingsDialog',
|
||||
@@ -42,7 +61,7 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
// checks these members only when the target source tree supports Stage 1.
|
||||
optionalFields: Object.freeze([
|
||||
'_activeDraftId', '_draftSegmentCms', '_isoGeometryCache', '_offerWallFaces',
|
||||
'_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
|
||||
'_liveEditorPaintCount', '_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
|
||||
'_wallFaceBatch', '_wallFaceGraphCache',
|
||||
]),
|
||||
// #380: v1.68.1 owns the same resize session directly on the card; newer
|
||||
@@ -54,12 +73,19 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
]),
|
||||
fieldTypes: Object.freeze({
|
||||
_booting: 'boolean',
|
||||
_bootSoft: 'boolean',
|
||||
_cameraTransition: 'object',
|
||||
_cleanFloorCache: 'map',
|
||||
_devices: 'array',
|
||||
_continuity: 'object',
|
||||
_decorList: 'array',
|
||||
_decorTool: 'string',
|
||||
_draftSegmentCms: 'array',
|
||||
_glowClipCache: 'map',
|
||||
_gridPitch: 'number',
|
||||
_hassSequence: 'number',
|
||||
_loadOk: 'boolean',
|
||||
_liveEditorPaintCount: 'number',
|
||||
_model: 'array',
|
||||
_onLabsSnapshot: 'function',
|
||||
_offerWallFaces: 'function',
|
||||
|
||||
@@ -143,6 +143,20 @@ export const evaluatePerformanceBudget = ({ candidate, baseline, budgets, absolu
|
||||
},
|
||||
));
|
||||
|
||||
for (const [windowName, budget] of Object.entries(budgets.longTaskWindows ?? {})) {
|
||||
const windows = candidate.rows.map((row) => row.longTasks?.[windowName]);
|
||||
const available = windows.every((window) => window?.supported === true);
|
||||
checks.push({
|
||||
id: `longTask.${windowName}.available`, actual: available ? 1 : 0, limit: 1, pass: available,
|
||||
});
|
||||
const maxSingle = available ? Math.max(...windows.map((window) => window.maxMs)) : NaN;
|
||||
const countP95 = available ? percentile(windows.map((window) => window.count), 0.95) : NaN;
|
||||
const totalP95 = available ? percentile(windows.map((window) => window.totalMs), 0.95) : NaN;
|
||||
checks.push(makeCheck(`longTask.${windowName}.maxSingleMs`, maxSingle, budget.maxSingleMs));
|
||||
checks.push(makeCheck(`longTask.${windowName}.countP95`, countP95, budget.maxCountP95));
|
||||
checks.push(makeCheck(`longTask.${windowName}.totalP95Ms`, totalP95, budget.maxTotalP95Ms));
|
||||
}
|
||||
|
||||
const candidateHeap = candidate.rows
|
||||
.map((row) => row.heapGrowthBytes)
|
||||
.filter(finite)
|
||||
|
||||
@@ -325,6 +325,7 @@ const drawn = await page.evaluate(async () => {
|
||||
},
|
||||
options: { minDim: 1, eps: 0.01 }, rooms: resizeRooms, openings: [],
|
||||
snapshotIdentity: snapshot, before: JSON.parse(snapshot), wallUnionBefore: null,
|
||||
epochBefore: c._cfgEpoch,
|
||||
});
|
||||
c._resize.move({
|
||||
pointerId: 912, point: [687.5, 250], step: 0.5, snap: (point) => point,
|
||||
|
||||
+130
-4
@@ -10,6 +10,11 @@ const res = await page.evaluate(async () => {
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
const settleLive = async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
await c.updateComplete;
|
||||
};
|
||||
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
|
||||
// 1) третья вкладка есть
|
||||
const tabs = [...sr().querySelectorAll('.modetab')];
|
||||
@@ -272,10 +277,10 @@ const res = await page.evaluate(async () => {
|
||||
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;
|
||||
await settleLive();
|
||||
out.noBadgeBeforeTheDrag = !label(); // нулевая длина — молчим
|
||||
c._decorDraft = { ...c._decorDraft, b: [g * 16, g * 20] }; // 12 клеток вправо
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
out.lineBadgeShown = !!label();
|
||||
// 12 клеток × 5 см = 0.60 m, угол 0° — ровно то, что пишет плашка стены
|
||||
out.lineBadgeText = txt();
|
||||
@@ -287,12 +292,12 @@ const res = await page.evaluate(async () => {
|
||||
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;
|
||||
await settleLive();
|
||||
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;
|
||||
await settleLive();
|
||||
out.rectBadgeHasSizeAndArea = txt().startsWith(fmt(g * 8) + ' × ' + fmt(g * 6) + ' · ')
|
||||
&& /(?:m²|ft²)$/.test(txt());
|
||||
// отпустили — плашки нет
|
||||
@@ -493,6 +498,127 @@ const res = await page.evaluate(async () => {
|
||||
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
|
||||
c._decorTool = 'select'; await c.updateComplete;
|
||||
out.decorRestored = c._decorList.length === decorBefore.length;
|
||||
// #447: Arrow is a separate rigid transaction, not a synthetic pointer
|
||||
// move. It preserves off-grid phase, bypasses both magnets and keeps one
|
||||
// history/save boundary per browser keydown.
|
||||
const keyboardOriginal = JSON.parse(JSON.stringify(c._decorList));
|
||||
const keyboardShapes = [
|
||||
{ id: 'key-rect', kind: 'rect', x: 0.1013, y: 0.2017, w: 0.08, h: 0.05,
|
||||
angle: 17, color: '#123456', fill: true },
|
||||
{ id: 'key-furniture', kind: 'furniture', x: 0.3013, y: 0.4017, w: 0.08, h: 0.05,
|
||||
symbol: 'chair', angle: 90, flip_h: true },
|
||||
{ id: 'key-image', kind: 'image', x: 0.5013, y: 0.6017, w: 0.08, h: 0.05,
|
||||
asset_id: 'smoke-missing-asset', opacity: 0.6 },
|
||||
];
|
||||
c._curSpaceCfg.decor = keyboardShapes;
|
||||
c._decorTool = 'select'; c._decorSel = 'key-furniture';
|
||||
c._decorDraft = null; c._decorMove = null; c._dtDrag = null; c._bdDrag = null;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._geometryHistory.clear();
|
||||
c._saveConfigDebounced.cancel();
|
||||
const realDebouncedSave = c._saveConfigDebounced;
|
||||
let keyboardSaves = 0, decorSnapCalls = 0, furnitureMoveCalls = 0;
|
||||
const countedSave = () => { keyboardSaves += 1; };
|
||||
countedSave.pending = () => false; countedSave.cancel = () => {}; countedSave.flush = () => {};
|
||||
c._saveConfigDebounced = countedSave;
|
||||
const realDecorSnap = c._decorSnap;
|
||||
const realFurnitureMove = c._editorRuntime._furnMoveUpdate;
|
||||
c._decorSnap = (...args) => { decorSnapCalls += 1; return realDecorSnap.apply(c, args); };
|
||||
c._editorRuntime._furnMoveUpdate = (...args) => {
|
||||
furnitureMoveCalls += 1;
|
||||
return realFurnitureMove.apply(c._editorRuntime, args);
|
||||
};
|
||||
const arrow = (key, target = window, init = {}) => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key, bubbles: true, composed: true, cancelable: true, ...init,
|
||||
});
|
||||
return target.dispatchEvent(event) === false;
|
||||
};
|
||||
const keyed = (id) => c._decorList.find((shape) => shape.id === id);
|
||||
const keyStep = g / 1000;
|
||||
const furnitureBeforeKey = JSON.parse(JSON.stringify(keyed('key-furniture')));
|
||||
const epochBeforeKey = c._cfgEpoch;
|
||||
const firstArrowPrevented = arrow('ArrowRight');
|
||||
const furnitureAfterRight = JSON.parse(JSON.stringify(keyed('key-furniture')));
|
||||
out.arrowMovesFurnitureOneCellWithoutMagnet = firstArrowPrevented
|
||||
&& Math.abs(furnitureAfterRight.x - furnitureBeforeKey.x - keyStep) < 1e-12
|
||||
&& furnitureAfterRight.y === furnitureBeforeKey.y
|
||||
&& furnitureAfterRight.angle === furnitureBeforeKey.angle
|
||||
&& furnitureAfterRight.flip_h === furnitureBeforeKey.flip_h
|
||||
&& decorSnapCalls === 0 && furnitureMoveCalls === 0;
|
||||
out.arrowIsOneHistoryAndSaveTransaction = c._geometryHistory.size === 1
|
||||
&& c._geometryHistory.undoName === c._t('history.decor_move')
|
||||
&& keyboardSaves === 1 && c._cfgEpoch === epochBeforeKey + 1
|
||||
&& c._decorSel === 'key-furniture';
|
||||
const shiftArrowPrevented = arrow('ArrowDown', window, { shiftKey: true });
|
||||
const furnitureAfterDown = JSON.parse(JSON.stringify(keyed('key-furniture')));
|
||||
out.shiftArrowKeepsTheSameOneCellStep = shiftArrowPrevented
|
||||
&& Math.abs(furnitureAfterDown.y - furnitureAfterRight.y - keyStep) < 1e-12
|
||||
&& c._geometryHistory.size === 2 && keyboardSaves === 2;
|
||||
arrow('z', window, { ctrlKey: true });
|
||||
const furnitureAfterUndo = JSON.parse(JSON.stringify(keyed('key-furniture')));
|
||||
arrow('y', window, { ctrlKey: true });
|
||||
const furnitureAfterRedo = JSON.parse(JSON.stringify(keyed('key-furniture')));
|
||||
out.arrowUndoRedoIsExactlyOneStep = Math.abs(furnitureAfterUndo.y - furnitureAfterRight.y) < 1e-8
|
||||
&& Math.abs(furnitureAfterRedo.y - furnitureAfterDown.y) < 1e-8
|
||||
&& Math.abs(furnitureAfterRedo.x - furnitureAfterRight.x) < 1e-8
|
||||
&& c._decorSel === 'key-furniture';
|
||||
|
||||
c._decorSel = 'key-image';
|
||||
const imageX = keyed('key-image').x;
|
||||
arrow('ArrowLeft');
|
||||
c._decorSel = 'key-rect';
|
||||
const rectY = keyed('key-rect').y;
|
||||
arrow('ArrowUp');
|
||||
out.imageAndOrdinaryDecorUseTheSameArrowPath = Math.abs(keyed('key-image').x - imageX + keyStep) < 1e-12
|
||||
&& Math.abs(keyed('key-rect').y - rectY + keyStep) < 1e-12
|
||||
&& decorSnapCalls === 0 && furnitureMoveCalls === 0;
|
||||
|
||||
const guardX = () => keyed('key-rect').x;
|
||||
const guardStart = guardX();
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const controls = ['input', 'textarea', 'select'].map((tag) => document.createElement(tag));
|
||||
const editable = document.createElement('div'); editable.contentEditable = 'true';
|
||||
controls.push(editable);
|
||||
const fieldResults = [];
|
||||
for (const control of controls) {
|
||||
document.body.append(control); control.focus();
|
||||
fieldResults.push(!arrow('ArrowRight', control) && guardX() === guardStart);
|
||||
control.remove();
|
||||
}
|
||||
out.arrowLeavesFormControlsAlone = fieldResults.every(Boolean);
|
||||
const toolbarControl = sr().querySelector('.editor-secondary button');
|
||||
toolbarControl?.focus();
|
||||
const toolbarGuarded = !!toolbarControl && !arrow('ArrowRight', toolbarControl) && guardX() === guardStart;
|
||||
toolbarControl?.blur();
|
||||
c._decorEraseConfirm = { id: 'key-rect', kind: 'rect' };
|
||||
const dialogGuarded = !arrow('ArrowRight') && guardX() === guardStart;
|
||||
c._decorEraseConfirm = null;
|
||||
c._decorMove = { id: 'key-rect', start: [0, 0], orig: JSON.parse(JSON.stringify(keyed('key-rect'))),
|
||||
pid: 447, moved: false, before: c._geometrySnapshot() };
|
||||
const gestureGuarded = !arrow('ArrowRight') && guardX() === guardStart;
|
||||
c._decorMove = null;
|
||||
out.arrowRespectsToolbarDialogAndGestureGuards = toolbarGuarded && dialogGuarded && gestureGuarded;
|
||||
const modifierResults = [
|
||||
{ ctrlKey: true }, { metaKey: true }, { altKey: true },
|
||||
].map((init) => !arrow('ArrowRight', window, init) && guardX() === guardStart);
|
||||
out.modifiedArrowsRemainBrowserCommands = modifierResults.every(Boolean);
|
||||
|
||||
c._curSpaceCfg.decor = [{ id: 'key-limit', kind: 'rect', x: 4999.9, y: 0, w: 0.1, h: 0.1 }];
|
||||
c._decorSel = 'key-limit'; c._geometryHistory.clear();
|
||||
const savesBeforeNoop = keyboardSaves, epochBeforeNoop = c._cfgEpoch;
|
||||
const noopPrevented = arrow('ArrowRight');
|
||||
out.boundaryNoopIsConsumedButNotSaved = noopPrevented
|
||||
&& c._curSpaceCfg.decor[0].x === 4999.9
|
||||
&& c._geometryHistory.size === 0 && keyboardSaves === savesBeforeNoop
|
||||
&& c._cfgEpoch === epochBeforeNoop;
|
||||
|
||||
c._decorSnap = realDecorSnap;
|
||||
c._editorRuntime._furnMoveUpdate = realFurnitureMove;
|
||||
c._saveConfigDebounced = realDebouncedSave;
|
||||
c._curSpaceCfg.decor = keyboardOriginal;
|
||||
c._decorSel = null; c._geometryHistory.clear(); c._cfgEpoch++;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
// Switching modes during a live move cancels the transaction and restores
|
||||
// its start instead of leaving mutated geometry with a forgotten pointer.
|
||||
const liveShape = c._decorList[0];
|
||||
|
||||
@@ -27,9 +27,15 @@ const res = await page.evaluate(async () => {
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
const settleLive = async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
await c.updateComplete;
|
||||
};
|
||||
// null-safe: на сборке ДО этих правок у надписи нет ни data-id, ни tspan —
|
||||
// смок должен показать список провалов, а не упасть с исключением
|
||||
const el = (id) => sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
|
||||
const el = (id) => sr().querySelector(`[data-hp-live-editor] .decorlayer text.dtext[data-id="${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');
|
||||
@@ -101,7 +107,7 @@ const res = await page.evaluate(async () => {
|
||||
out.noFrameWithoutSelection = !sr().querySelector('.dtframe');
|
||||
c._decorSel = 'dtm'; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
const frame = () => sr().querySelector('.dtframe');
|
||||
const frame = () => sr().querySelector('[data-hp-live-editor] .dtframe') || sr().querySelector('.dtframe');
|
||||
out.frameOnSelection = !!frame();
|
||||
out.fourCornersAndRotate = !!frame()
|
||||
&& frame().querySelectorAll('.dthandle').length === 5
|
||||
@@ -147,7 +153,7 @@ const res = await page.evaluate(async () => {
|
||||
ev('pointerdown', corner, ax + 40, ay2);
|
||||
out.dragStarted = !!c._dtDrag && c._dtDrag.kind === 'scale';
|
||||
ev('pointermove', stageEl(), ax + 80, ay2);
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const scaled = c._decorList.find((x) => x.id === 'dtm');
|
||||
out.cornerStoresPhysicalSize = scaled.scale === undefined && scaled.size === undefined
|
||||
&& Math.abs((scaled.size_cm || 0) - mediumSizeCm0 * 2) < 0.01;
|
||||
|
||||
@@ -131,6 +131,7 @@ const out = await page.evaluate(async () => {
|
||||
beginDeviceDrag(dev3, r0, 13);
|
||||
c._pointerMove(mk(2.5 * NORM_W + halfStep - r0.x, 2.5 * NORM_W - r0.y,
|
||||
400, 400, { shiftKey: true, pointerId: 13 }), dev3);
|
||||
await Promise.resolve();
|
||||
o.shiftStillSnapsToTheNodes = onGrid(c._layout[dev3.id].x);
|
||||
c._cancelDeviceDrag();
|
||||
|
||||
|
||||
+67
-26
@@ -23,8 +23,14 @@ const res = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const settleLive = async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
await c.updateComplete;
|
||||
};
|
||||
const stageEl = () => sr().querySelector('.stage');
|
||||
const el = (id) => (id ? sr().querySelector(`.decorlayer [data-id="${id}"]`) : null);
|
||||
const el = (id) => (id ? sr().querySelector(`[data-hp-live-editor] .decorlayer [data-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();
|
||||
@@ -46,6 +52,7 @@ const res = await page.evaluate(async () => {
|
||||
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; };
|
||||
const box = () => c._decorBoxOf(sofaNow());
|
||||
|
||||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [];
|
||||
@@ -148,7 +155,7 @@ const res = await page.evaluate(async () => {
|
||||
redo: c._geometryHistory.redoName,
|
||||
};
|
||||
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const firstPreview = c._editorRuntime?._furniturePreviewPlacement()
|
||||
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
|
||||
const firstTransform = ghost()?.getAttribute('transform');
|
||||
@@ -176,7 +183,7 @@ const res = await page.evaluate(async () => {
|
||||
stageEl().dispatchEvent(new PointerEvent('pointerleave', {
|
||||
bubbles: true, composed: true, pointerId: 11, pointerType: 'mouse',
|
||||
}));
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
out.pointerLeaveClearsPreview = !ghost() && c._furnPreviewInput === null;
|
||||
|
||||
// Unknown/stale symbols fail dark: neither an invisible ghost nor a decor
|
||||
@@ -184,7 +191,7 @@ const res = await page.evaluate(async () => {
|
||||
c._furnPalette = { symbol: 'future_unknown_symbol', w: 180, h: 90 };
|
||||
await c.updateComplete;
|
||||
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const beforeUnknown = c._decorList.length;
|
||||
ev('pointerdown', stageEl(), 300, 300, { pointerType: 'mouse' });
|
||||
await c.updateComplete;
|
||||
@@ -196,7 +203,7 @@ const res = await page.evaluate(async () => {
|
||||
// ================= 4. размещение в реальном размере ======================
|
||||
// середина комнаты r1 (40..550 × 140..580) — до стен дальше порога магнита
|
||||
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const committedPreview = c._editorRuntime?._furniturePreviewPlacement()
|
||||
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
|
||||
const committedPreviewStroke = Number(ghost()?.getAttribute('stroke-width'));
|
||||
@@ -266,7 +273,7 @@ const res = await page.evaluate(async () => {
|
||||
category('bed')?.click(); await c.updateComplete;
|
||||
pick('bed_double'); await c.updateComplete;
|
||||
ev('pointermove', stageEl(), 300, 140 + wallHalf, { pointerType: 'mouse' });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const thickWallPreview = c._editorRuntime?._furniturePreviewPlacement()
|
||||
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
|
||||
ev('pointerdown', stageEl(), 300, 140 + wallHalf);
|
||||
@@ -282,6 +289,39 @@ const res = await page.evaluate(async () => {
|
||||
&& ['x', 'y', 'w', 'h'].every((key) => near(bed[key], thickWallPreview[key], 1e-12))
|
||||
&& (bed.angle || 0) === thickWallPreview.angle;
|
||||
|
||||
// #447: the same finite outer atom also has its exterior physical face.
|
||||
// Raw intent above the wall must never be pulled through to the room side;
|
||||
// preview, commit and exact-axis drag all share that decision.
|
||||
await openFurniture();
|
||||
c._furnPalette = { symbol: 'chair', w: 50, h: 50 };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const exteriorIntentY = 140 - wallHalf;
|
||||
ev('pointermove', stageEl(), 300, exteriorIntentY, { pointerType: 'mouse' });
|
||||
await settleLive();
|
||||
const exteriorPreview = c._editorRuntime?._furniturePreviewPlacement()
|
||||
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
|
||||
const exteriorDepth = cmToUnits(50);
|
||||
const exteriorCentreY = 140 - wallHalf - exteriorDepth / 2;
|
||||
out.exteriorPreviewStaysOutside = !!exteriorPreview
|
||||
&& near((exteriorPreview.y + exteriorPreview.h / 2) * 1000, exteriorCentreY, 1e-6)
|
||||
&& Math.abs(Math.abs(exteriorPreview.angle) - 180) < 1e-6;
|
||||
ev('pointerdown', stageEl(), 300, exteriorIntentY);
|
||||
await c.updateComplete;
|
||||
const exteriorChair = c._decorList.find((shape) => shape.symbol === 'chair');
|
||||
const exteriorChairId = exteriorChair?.id ?? null;
|
||||
out.exteriorCommitMatchesPreview = !!exteriorChair && !!exteriorPreview
|
||||
&& ['x', 'y', 'w', 'h'].every((key) => near(exteriorChair[key], exteriorPreview[key], 1e-12))
|
||||
&& (exteriorChair.angle || 0) === exteriorPreview.angle;
|
||||
ev('pointerdown', el(exteriorChairId), 300, exteriorCentreY);
|
||||
ev('pointermove', stageEl(), 300, 140);
|
||||
await settleLive();
|
||||
const exteriorAxisDrag = c._decorList.find((shape) => shape.id === exteriorChairId);
|
||||
out.exteriorExactAxisDragPreservesSide = !!exteriorAxisDrag
|
||||
&& near((exteriorAxisDrag.y + exteriorAxisDrag.h / 2) * 1000, exteriorCentreY, 1e-6)
|
||||
&& Math.abs(Math.abs(exteriorAxisDrag.angle || 0) - 180) < 1e-6;
|
||||
ev('pointerup', stageEl(), 300, 140);
|
||||
await c.updateComplete;
|
||||
|
||||
// The regular decor snap resolves this pointer to the shared wall axis.
|
||||
// Surface-side selection must still use the raw point on the r2 side.
|
||||
await openFurniture();
|
||||
@@ -289,10 +329,10 @@ const res = await page.evaluate(async () => {
|
||||
pick('chair'); await c.updateComplete;
|
||||
const sharedIntentX = 550 + wallHalf / 2;
|
||||
ev('pointermove', stageEl(), sharedIntentX, 300, { pointerType: 'mouse' });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
ev('pointerdown', stageEl(), sharedIntentX, 300);
|
||||
await c.updateComplete;
|
||||
const sharedChair = c._decorList.find((s) => s.symbol === 'chair');
|
||||
const sharedChair = c._decorList.find((s) => s.symbol === 'chair' && s.id !== exteriorChairId);
|
||||
const chairDepth = (sharedChair?.h ?? NaN) * 1000;
|
||||
const rightSurfaceCentre = 550 + wallHalf + chairDepth / 2;
|
||||
out.sharedWallUsesRawPointerSide = !!sharedChair
|
||||
@@ -304,7 +344,7 @@ const res = await page.evaluate(async () => {
|
||||
const sharedChairY = sharedChair ? (sharedChair.y + sharedChair.h / 2) * 1000 : NaN;
|
||||
ev('pointerdown', el(sharedChairId), rightSurfaceCentre, sharedChairY);
|
||||
ev('pointermove', stageEl(), 550, sharedChairY);
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const axisDraggedChair = c._decorList.find((s) => s.id === sharedChairId);
|
||||
out.exactAxisDragPreservesWallSide = !!axisDraggedChair
|
||||
&& near((axisDraggedChair.x + axisDraggedChair.w / 2) * 1000, rightSurfaceCentre, 1e-6);
|
||||
@@ -352,14 +392,14 @@ const res = await page.evaluate(async () => {
|
||||
ev('pointerdown', el(sofaId), 300, 300);
|
||||
out.dragStarted = !!sofaId && c._decorMove?.id === sofaId;
|
||||
ev('pointermove', stageEl(), 540, 300);
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const pulled = sofaNow();
|
||||
out.dragMagnetTurnsItToTheWall = Math.abs(Math.abs(pulled.angle || 0) - 90) < 1e-6;
|
||||
out.dragMagnetPressesTheBack =
|
||||
near((pulled.x + pulled.w / 2) * 1000,
|
||||
550 - wallHalf - cmToUnits(90) / 2, 1e-6);
|
||||
ev('pointermove', stageEl(), 300, 300, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const shiftDragGridIndex = (sofaNow().x * 1000) / PITCH;
|
||||
out.shiftDragRemainsGridBound = near(shiftDragGridIndex, Math.round(shiftDragGridIndex), 1e-6);
|
||||
ev('pointerup', stageEl(), 300, 300, { shiftKey: true });
|
||||
@@ -377,7 +417,7 @@ const res = await page.evaluate(async () => {
|
||||
c._cfgEpoch++;
|
||||
c._decorSel = sofaId; c.requestUpdate();
|
||||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||||
const frame = () => sr().querySelector('.dtframe');
|
||||
const frame = () => sr().querySelector('[data-hp-live-editor] .dtframe') || sr().querySelector('.dtframe');
|
||||
out.frameOnSelection = !!frame();
|
||||
out.furnitureFrameHasFourSideHandles = !!frame()
|
||||
&& frame().classList.contains('dtfurnitureframe')
|
||||
@@ -391,9 +431,10 @@ const res = await page.evaluate(async () => {
|
||||
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 frameBox = frame()?.querySelector('.dtbox');
|
||||
out.frameBoxIsTheShapeBox = !!frameBox
|
||||
&& near(+frameBox.getAttribute('width'), sofaNow().w * 1000, 1e-6)
|
||||
&& near(+frameBox.getAttribute('height'), sofaNow().h * 1000, 1e-6);
|
||||
|
||||
const visiblePath = el(sofaId);
|
||||
const hitPath = sr().querySelector(`.dfurniturehit[data-id="${sofaId}"]`);
|
||||
@@ -406,7 +447,7 @@ const res = await page.evaluate(async () => {
|
||||
|
||||
// Shift: independent, continuous width/depth + live plates.
|
||||
const before = { w: sofaNow().w, h: sofaNow().h };
|
||||
const b = c._dtBox || { x: 0, y: 0, w: 0, h: 0 };
|
||||
const b = box() || { x: 0, y: 0, w: 0, h: 0 };
|
||||
const handles = [...(frame()?.querySelectorAll('.dthandle') || [])];
|
||||
// #400: угловая ручка ищется по РОЛИ, а не по индексу в DOM. Индекс держался
|
||||
// на порядке отрисовки, а порядок — это решение о приоритете хита (углы
|
||||
@@ -418,7 +459,7 @@ const res = await page.evaluate(async () => {
|
||||
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;
|
||||
await settleLive();
|
||||
const grown = sofaNow();
|
||||
out.cornerGrowsTheWidth = grown.w > before.w && near(grown.w * 1000, b.w + 100, 1e-6);
|
||||
out.cornerGrowsTheDepthINDEPENDENTLY = grown.h > before.h
|
||||
@@ -433,7 +474,7 @@ const res = await page.evaluate(async () => {
|
||||
&& plates.some((p) => /m|м|′/.test(p.textContent));
|
||||
// Without Shift the original ratio is preserved, still without grid snap.
|
||||
ev('pointermove', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const proportional = sofaNow();
|
||||
const wCells = (proportional.w * 1000) / PITCH;
|
||||
out.defaultResizePreservesRatio = near(proportional.w / proportional.h, before.w / before.h, 1e-5);
|
||||
@@ -445,12 +486,12 @@ const res = await page.evaluate(async () => {
|
||||
|
||||
// A middle handle changes only one axis and keeps the opposite edge fixed.
|
||||
const edgeBefore = { ...sofaNow() };
|
||||
const eb = c._dtBox;
|
||||
const eb = box();
|
||||
const rightEdge = [...frame().querySelectorAll('.dthandle.dtedge')].find((handle) =>
|
||||
near(+handle.getAttribute('cx'), eb.x + eb.w, 1e-6));
|
||||
ev('pointerdown', rightEdge, eb.x + eb.w, eb.y + eb.h / 2);
|
||||
ev('pointermove', stageEl(), eb.x + eb.w + 13.7, eb.y + eb.h / 2);
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const edgeAfter = sofaNow();
|
||||
out.edgeHandleChangesOneAxisContinuously = near(edgeAfter.h, edgeBefore.h, 1e-9)
|
||||
&& near(edgeAfter.w * 1000, edgeBefore.w * 1000 + 13.7, 1e-6)
|
||||
@@ -461,13 +502,13 @@ const res = await page.evaluate(async () => {
|
||||
// Crossing the fixed corner toggles only the crossed axis. pointercancel
|
||||
// must restore the exact pre-gesture object and create no persisted flip.
|
||||
const crossBefore = JSON.parse(JSON.stringify(sofaNow()));
|
||||
const cb = c._dtBox;
|
||||
const cb = box();
|
||||
const crossHandles = [...frame().querySelectorAll('.dthandle')]
|
||||
.filter((el) => !el.classList.contains('dtrot') && !el.classList.contains('dtedge'));
|
||||
const crossSe = crossHandles[2]; // #400: по роли, не по позиции в DOM
|
||||
ev('pointerdown', crossSe, cb.x + cb.w, cb.y + cb.h);
|
||||
ev('pointermove', stageEl(), cb.x - 25, cb.y + cb.h + 10, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const crossed = sofaNow();
|
||||
out.crossingTogglesOnlyHorizontalFlip = crossed.flip_h === true && !crossed.flip_v
|
||||
&& crossed.w > 0 && crossed.h > 0 && near(crossed.x * 1000 + crossed.w * 1000, cb.x, 1e-6);
|
||||
@@ -484,17 +525,17 @@ const res = await page.evaluate(async () => {
|
||||
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;
|
||||
await settleLive();
|
||||
out.rotates45 = near(sofaNow().angle || 0, 45, 1e-6);
|
||||
ev('pointermove', stageEl(), cx + 100, cy + 2); // ~1.1° → липнет к 0
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
const fine = sofaNow().angle;
|
||||
out.rotationIsFreeWithoutShift = fine > 0.5 && fine < 5;
|
||||
ev('pointermove', stageEl(), cx + 100, cy + 2, { shiftKey: true });
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
out.shiftSnapsRotationTo45 = (sofaNow().angle || 0) === 0;
|
||||
ev('pointermove', stageEl(), cx + 100, cy + 100);
|
||||
await c.updateComplete;
|
||||
await settleLive();
|
||||
ev('pointerup', stageEl(), cx + 100, cy + 100);
|
||||
await c.updateComplete;
|
||||
out.rotationIsRendered = /rotate\(/.test(attr(sofaId, 'transform') || '');
|
||||
|
||||
@@ -101,7 +101,11 @@ await page.evaluate(async () => {
|
||||
// raster pair and prove the precision ratio separately below.
|
||||
runtime._gridLevels = mode === 'view' ? originalGridLevels : () => null;
|
||||
card._gridLevels = () => runtime._gridLevels();
|
||||
card._labs = { ...card._labs, active: projection === 'iso' ? ['iso'] : [] };
|
||||
card._labs = {
|
||||
...card._labs,
|
||||
alpha: projection === 'iso',
|
||||
active: projection === 'iso' ? ['iso'] : [],
|
||||
};
|
||||
card._viewPreference = { ...card._viewPreference, f1: projection };
|
||||
card._isoFallback.clear();
|
||||
card._isoGeometryCache.clear();
|
||||
|
||||
@@ -271,9 +271,9 @@ const farIcon = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
c._resetZoom(); await window.__waitForCamera(c);
|
||||
const at1 = size();
|
||||
c._applyView(4); await c.updateComplete;
|
||||
c._applyView(4); await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const at4 = size();
|
||||
c._applyView(1 / 3); await c.updateComplete;
|
||||
c._applyView(1 / 3); await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const atOut = size();
|
||||
c._resetZoom(); await window.__waitForCamera(c);
|
||||
o.iconGrowsWithZoomIn = at1 > 4 && Math.abs(at4 / at1 - 4) < 0.05;
|
||||
|
||||
@@ -11,18 +11,15 @@ const out = await page.evaluate(async () => {
|
||||
return [rect.left + rect.width / 2, rect.top + rect.height / 2];
|
||||
};
|
||||
const enable = async () => {
|
||||
history.replaceState(null, '', '#space=f1&hp-labs=iso');
|
||||
history.replaceState(null, '', '#space=f1&hp_alpha=1');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
if (typeof card._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
|
||||
const active = Object.freeze(['iso']);
|
||||
// The product flag expires at 1.65.0. Keep this renderer/lifecycle smoke
|
||||
// explicit without extending the public Labs registry contract.
|
||||
card._onLabsSnapshot({ active, space: '' });
|
||||
window.__hpLabs = active;
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
};
|
||||
const result = {};
|
||||
result.cleanProfileStartsOff = window.__hpAlpha === false
|
||||
&& JSON.stringify(window.__hpLabs) === '[]'
|
||||
&& !root().querySelector('[data-hp="projection-toggle"]');
|
||||
const configSpace = card._serverCfg.spaces.find((space) => space.id === 'f1');
|
||||
configSpace.settings = {
|
||||
...(configSpace.settings || {}), show_borders: true, show_names: true,
|
||||
@@ -66,8 +63,9 @@ const out = await page.evaluate(async () => {
|
||||
result.isoGateFlipReversesTurn = centredGate?.leaves[0]?.turnDeg
|
||||
=== -flippedGate?.leaves[0]?.turnDeg;
|
||||
const toggle = root().querySelector('[data-hp="projection-toggle"]');
|
||||
result.labsSnapshotFrozen = Object.isFrozen(window.__hpLabs)
|
||||
result.alphaSnapshotFrozen = window.__hpAlpha === true && Object.isFrozen(window.__hpLabs)
|
||||
&& JSON.stringify(window.__hpLabs) === '["iso"]';
|
||||
result.alphaStored = localStorage.getItem('houseplan_card_alpha_v1') === '1';
|
||||
result.toggleShown = !!toggle;
|
||||
result.toggleMinHitTarget = toggle && toggle.getBoundingClientRect().width >= 44
|
||||
&& toggle.getBoundingClientRect().height >= 44;
|
||||
@@ -144,14 +142,20 @@ const out = await page.evaluate(async () => {
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
|
||||
history.replaceState(null, '', '#space=f1&hp-labs=-iso');
|
||||
history.replaceState(null, '', '#space=f1&hp_alpha=0');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
result.removalIsImmediateFlat = JSON.stringify(window.__hpLabs) === '[]'
|
||||
result.removalIsImmediateFlat = window.__hpAlpha === false
|
||||
&& localStorage.getItem('houseplan_card_alpha_v1') === '0'
|
||||
&& JSON.stringify(window.__hpLabs) === '[]'
|
||||
&& !root().querySelector('[data-hp="projection-toggle"]')
|
||||
&& !root().querySelector('[data-hp="iso-walls"]')
|
||||
&& !root().querySelector('[id^="hp-iso-"]');
|
||||
await enable();
|
||||
result.reenableRestoresPreference = window.__hpAlpha === true
|
||||
&& root().querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true'
|
||||
&& !!root().querySelector('[data-hp="iso-walls"]');
|
||||
return result;
|
||||
});
|
||||
|
||||
|
||||
@@ -9,14 +9,8 @@ const out = await page.evaluate(async () => {
|
||||
const root = (card) => card.renderRoot;
|
||||
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
history.replaceState(null, '', '?hp-labs=iso#space=f1');
|
||||
history.replaceState(null, '', '?hp_alpha=1#space=f1');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
if (typeof original._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
|
||||
const testOnlyIso = Object.freeze(['iso']);
|
||||
// The product registry remains expired; this direct snapshot exercises the
|
||||
// dormant Stage 2 renderer and is removed by the real -iso path below.
|
||||
original._onLabsSnapshot({ active: testOnlyIso, space: '' });
|
||||
window.__hpLabs = testOnlyIso;
|
||||
await original.updateComplete;
|
||||
const configSpace = original._serverCfg.spaces.find((space) => space.id === 'f1');
|
||||
configSpace.settings = {
|
||||
@@ -301,7 +295,6 @@ const out = await page.evaluate(async () => {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', kiosk });
|
||||
host.replaceChildren(card);
|
||||
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
card.hass = window.__mkHass();
|
||||
const started = performance.now();
|
||||
while ((!card._loadOk || card._booting) && performance.now() - started < 9000) await wait(30);
|
||||
@@ -333,11 +326,13 @@ out.orientationResizeKeepsIso = await page.evaluate(async () => {
|
||||
});
|
||||
out.kioskEmergencyOffIsFlat = await page.evaluate(async () => {
|
||||
const card = document.querySelector('houseplan-card');
|
||||
history.replaceState(null, '', '?hp-labs=-iso#space=f1');
|
||||
history.replaceState(null, '', '?hp_alpha=0#space=f1');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
return !card.renderRoot.querySelector('[data-hp="iso-walls"]')
|
||||
return window.__hpAlpha === false
|
||||
&& localStorage.getItem('houseplan_card_alpha_v1') === '0'
|
||||
&& !card.renderRoot.querySelector('[data-hp="iso-walls"]')
|
||||
&& !card.renderRoot.querySelector('[data-hp="projection-toggle"]');
|
||||
});
|
||||
|
||||
|
||||
@@ -210,7 +210,8 @@ const result = await page.evaluate(async (spaceFixture) => {
|
||||
staticCard.remove();
|
||||
|
||||
const labs = Object.freeze(['iso']);
|
||||
card._onLabsSnapshot({ active: labs, space: '' });
|
||||
card._onLabsSnapshot({ alpha: true, active: labs, space: '' });
|
||||
window.__hpAlpha = true;
|
||||
window.__hpLabs = labs;
|
||||
card._setProjection('iso');
|
||||
await update(false);
|
||||
|
||||
@@ -22,21 +22,22 @@ const res = await page.evaluate(async () => {
|
||||
// 3) свайп при 1:1 переключает пространство (и точки показываются)
|
||||
const s0 = c._space;
|
||||
const stage = sr().querySelector('.stage');
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, clientX: 600, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, clientX: 450, clientY: 305 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0, clientX: 600, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0, clientX: 450, clientY: 305 }));
|
||||
await c.updateComplete;
|
||||
out.swipeSwitches = c._space !== s0;
|
||||
out.dotsShown = !!sr().querySelector('.kioskdots');
|
||||
// 4) при зуме свайп не переключает
|
||||
c._zoom = 2; const s1 = c._space;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 2, clientX: 600, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 2, clientX: 450, clientY: 305 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 2, pointerType: 'touch', isPrimary: true, button: 0, clientX: 600, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 2, pointerType: 'touch', isPrimary: true, button: 0, clientX: 450, clientY: 305 }));
|
||||
await c.updateComplete;
|
||||
out.noSwipeZoomed = c._space === s1;
|
||||
// 5) двойной тап сбрасывает зум
|
||||
c._lastTap = Date.now() - 100;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 3, clientX: 500, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 3, clientX: 501, clientY: 300 }));
|
||||
for (const pointerId of [3, 4]) {
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId, pointerType: 'touch', isPrimary: true, button: 0, clientX: 500, clientY: 300 }));
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId, pointerType: 'touch', isPrimary: true, button: 0, clientX: 501, clientY: 300 }));
|
||||
}
|
||||
const resetStarted = performance.now();
|
||||
while (c._cameraTransition?.active) {
|
||||
if (performance.now() - resetStarted > 1000) throw new Error('double-tap camera transition did not settle');
|
||||
|
||||
@@ -30,7 +30,8 @@ const out = await page.evaluate(async () => {
|
||||
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,
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id,
|
||||
pointerType: 'touch', isPrimary: true, button: 0, clientX: x, clientY: y,
|
||||
}));
|
||||
o.kioskHasSeveralSpaces = k._model.length > 1;
|
||||
|
||||
@@ -108,9 +109,10 @@ const out = await page.evaluate(async () => {
|
||||
await home();
|
||||
k._applyView(2);
|
||||
await k.updateComplete;
|
||||
k._lastTap = Date.now() - 100;
|
||||
fire('pointerdown', 58, 500, 300);
|
||||
fire('pointerup', 58, 501, 300);
|
||||
for (const id of [58, 60]) {
|
||||
fire('pointerdown', id, 500, 300);
|
||||
fire('pointerup', id, 501, 300);
|
||||
}
|
||||
const resetStarted = performance.now();
|
||||
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
|
||||
while (k._cameraTransition.active && performance.now() - resetStarted < 1000);
|
||||
|
||||
@@ -201,7 +201,8 @@ const result = await page.evaluate(async (source) => {
|
||||
staticCard.remove();
|
||||
|
||||
const labs = Object.freeze(['iso']);
|
||||
card._onLabsSnapshot({ active: labs, space: '' });
|
||||
card._onLabsSnapshot({ alpha: true, active: labs, space: '' });
|
||||
window.__hpAlpha = true;
|
||||
window.__hpLabs = labs;
|
||||
card._setProjection('iso');
|
||||
await update(false);
|
||||
|
||||
@@ -211,7 +211,8 @@ const result = await page.evaluate(async (source) => {
|
||||
staticCard.remove();
|
||||
|
||||
const labs = Object.freeze(['iso']);
|
||||
card._onLabsSnapshot({ active: labs, space: '' });
|
||||
card._onLabsSnapshot({ alpha: true, active: labs, space: '' });
|
||||
window.__hpAlpha = true;
|
||||
window.__hpLabs = labs;
|
||||
card._setProjection('iso');
|
||||
await update(false);
|
||||
|
||||
@@ -42,7 +42,10 @@ await page.evaluate(() => {
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
await page.waitForTimeout(220); // editor chrome transition changes stage coordinates
|
||||
await page.waitForFunction(() => {
|
||||
const c = window.__card;
|
||||
return !!c._editorRuntime && !c._modeTransitionBusy && !c._cameraTransition?.active;
|
||||
});
|
||||
await settle();
|
||||
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
@@ -288,7 +291,7 @@ const previews = () => page.evaluate(() =>
|
||||
|
||||
// the click places it AT THE MAGNET and clears every hint
|
||||
{
|
||||
const [hx, hy] = await screenPt(293.7, 141);
|
||||
const [hx, hy] = await screenPt(295, 141);
|
||||
await page.mouse.move(hx, hy, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.click(hx, hy);
|
||||
|
||||
@@ -125,6 +125,7 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
fire(h, 'pointerdown', 21, r.left + r.width / 2, r.top + r.height / 2);
|
||||
o.resizeHandleTakesThePointer = c._resize?.dragging === true && c._panStart === null;
|
||||
fire(h, 'pointermove', 21, r.left + r.width / 2 + 40, r.top + r.height / 2 + 40);
|
||||
await Promise.resolve();
|
||||
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._resize?.dragging === true && c._resize.moved === true;
|
||||
@@ -143,6 +144,7 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
fire(dev, 'pointerdown', 22, r.left + r.width / 2, r.top + r.height / 2);
|
||||
o.deviceTakesThePointer = !!c._deviceDrag && c._panStart === null;
|
||||
fire(dev, 'pointermove', 22, r.left + r.width / 2 + 40, r.top + r.height / 2 + 30);
|
||||
await Promise.resolve();
|
||||
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;
|
||||
|
||||
@@ -81,6 +81,7 @@ const out = await page.evaluate(async () => {
|
||||
card._opPointerMove(new PointerEvent('pointermove', {
|
||||
pointerId: 186, clientX: 20, clientY: 0, bubbles: true,
|
||||
}), saved);
|
||||
await Promise.resolve();
|
||||
runtime._svgPoint = originalSvgPoint;
|
||||
card._opPointerUp(new PointerEvent('pointerup', { pointerId: 186, bubbles: true }), saved);
|
||||
await update();
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// #451: production-bundle guard for the boundary between HA intake, full Lit
|
||||
// renders and RAF-owned interaction layers. The large benchmark owns timings;
|
||||
// this fast smoke runs on every selected-smoke pass and owns wiring semantics.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const result = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 5000) => {
|
||||
const started = performance.now();
|
||||
while (!predicate()) {
|
||||
if (performance.now() - started > timeout) throw new Error('render invalidation smoke timed out');
|
||||
await new Promise((done) => setTimeout(done, 10));
|
||||
}
|
||||
};
|
||||
const out = {};
|
||||
card._setMode('view', false);
|
||||
await card.updateComplete;
|
||||
if ('_bootSoft' in card) await until(() => card._bootSoft === false);
|
||||
await frame();
|
||||
|
||||
let renders = 0;
|
||||
let intakes = 0;
|
||||
let scans = 0;
|
||||
const originalRenderBody = card._renderBody.bind(card);
|
||||
const originalObserve = card._renderLife.observe.bind(card._renderLife);
|
||||
const originalBindingStatus = card._bindingStatus.bind(card);
|
||||
card._renderBody = (...args) => { renders++; return originalRenderBody(...args); };
|
||||
card._renderLife.observe = (...args) => { intakes++; return originalObserve(...args); };
|
||||
card._bindingStatus = (...args) => { scans++; return originalBindingStatus(...args); };
|
||||
|
||||
const dependency = [...(card._visibleDeviceSnapshot?.entityIds || [])]
|
||||
.find((id) => card.hass.states?.[id]);
|
||||
const originalHass = card.hass;
|
||||
const stableConfig = JSON.stringify(card._serverCfg);
|
||||
const stableLayout = JSON.stringify(card._layout);
|
||||
const withState = (id, state) => ({
|
||||
...card.hass,
|
||||
states: { ...card.hass.states, [id]: { ...card.hass.states[id], state } },
|
||||
});
|
||||
|
||||
const irrelevantBefore = renders;
|
||||
const intakeBefore = intakes;
|
||||
for (let index = 0; index < 5; index++) {
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
states: {
|
||||
...card.hass.states,
|
||||
[`sensor.hp_451_irrelevant_${index}`]: { entity_id: `sensor.hp_451_irrelevant_${index}`, state: String(index), attributes: {} },
|
||||
},
|
||||
};
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
out.irrelevantHaHasNoFullRender = renders - irrelevantBefore === 0;
|
||||
out.irrelevantHaStillIntakes = intakes - intakeBefore === 5;
|
||||
|
||||
const relevantBefore = renders;
|
||||
if (dependency) card.hass = withState(dependency, '451-outside');
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
out.relevantHaRendersOnce = !!dependency && renders - relevantBefore === 1;
|
||||
|
||||
scans = 0;
|
||||
const heavy = card.renderRoot.querySelector('.wallbodies');
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const hoverBefore = renders;
|
||||
const device = card.renderRoot.querySelector('[data-hp="device"]');
|
||||
if (device) {
|
||||
device.dispatchEvent(new PointerEvent('pointerover', {
|
||||
bubbles: true, composed: true, pointerId: 450, pointerType: 'mouse', isPrimary: true,
|
||||
clientX: rect.left + 220, clientY: rect.top + 220,
|
||||
}));
|
||||
device.dispatchEvent(new PointerEvent('pointermove', {
|
||||
bubbles: true, composed: true, pointerId: 450, pointerType: 'mouse', isPrimary: true,
|
||||
clientX: rect.left + 225, clientY: rect.top + 225,
|
||||
}));
|
||||
}
|
||||
await frame();
|
||||
const liveTip = card.renderRoot.querySelector('[data-hp-live-tip]');
|
||||
out.hoverUsesLightweightPaint = !!device && renders === hoverBefore
|
||||
&& !!liveTip && !liveTip.hidden;
|
||||
device?.dispatchEvent(new PointerEvent('pointerleave', {
|
||||
bubbles: false, composed: true, pointerId: 450, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
await frame();
|
||||
|
||||
const pointer = (type, x, y, buttons) => stage.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, pointerId: 451, pointerType: 'mouse', isPrimary: true,
|
||||
button: 0, buttons, clientX: rect.left + x, clientY: rect.top + y,
|
||||
}));
|
||||
pointer('pointerdown', 300, 300, 1);
|
||||
await card.updateComplete;
|
||||
const panBefore = renders;
|
||||
const panIntakeBefore = intakes;
|
||||
for (let index = 0; index < 3; index++) {
|
||||
pointer('pointermove', 320 + index * 20, 315 + index * 10, 1);
|
||||
if (dependency) card.hass = withState(dependency, `451-drag-${index}`);
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
out.panAndDeferredHaSkipFullRender = renders === panBefore;
|
||||
out.deferredHaTicksStillIntake = intakes - panIntakeBefore === 3;
|
||||
const panTerminalBefore = renders;
|
||||
pointer('pointerup', 360, 335, 0);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
out.panTerminalRendersOnce = renders - panTerminalBefore === 1;
|
||||
out.deferredHaIsLastWins = !!dependency
|
||||
&& card._renderPlanHass.states?.[dependency]?.state === '451-drag-2';
|
||||
|
||||
card._setMode('plan', false);
|
||||
card._tool = 'draw';
|
||||
card._path = [[100, 100]];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
|
||||
for (let index = 0; index < 5; index++) await frame();
|
||||
await frame();
|
||||
const editorBefore = renders;
|
||||
const editorPaintBefore = card._liveEditorPaintCount || 0;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
bubbles: true, composed: true, pointerId: 452, pointerType: 'mouse', isPrimary: true,
|
||||
clientX: rect.left + 110 + index, clientY: rect.top + 120 + index,
|
||||
}));
|
||||
}
|
||||
await frame();
|
||||
out.editorMovesSkipFullRender = renders === editorBefore;
|
||||
out.editorMovesUseRafPaint = (card._liveEditorPaintCount || 0) > editorPaintBefore;
|
||||
card._cursorPt = null;
|
||||
card._path = [];
|
||||
const editorTerminalBefore = renders;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
out.editorTerminalHasAtMostOneRender = renders - editorTerminalBefore <= 1;
|
||||
|
||||
out.heavySceneIdentityStable = card.renderRoot.querySelector('.wallbodies') === heavy;
|
||||
out.pointerWindowsSkipDiagnosticScans = scans === 0;
|
||||
out.interactionsDoNotWriteConfig = JSON.stringify(card._serverCfg) === stableConfig;
|
||||
out.interactionsDoNotWriteLayout = JSON.stringify(card._layout) === stableLayout;
|
||||
card.hass = originalHass;
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||
@@ -114,10 +114,13 @@ const sharedX = () => page.evaluate(() => {
|
||||
))).map((point) => point[0]);
|
||||
});
|
||||
const domHasSharedX = (x) => page.evaluate((wanted) =>
|
||||
[...window.__card.renderRoot.querySelectorAll('.rszhandle[aria-disabled="false"]')]
|
||||
[...(window.__card.renderRoot.querySelector('[data-hp-live-editor] .hp-live-resize')
|
||||
|| window.__card.renderRoot).querySelectorAll('.rszhandle[aria-disabled="false"]')]
|
||||
.some((entry) => Math.abs(Number(entry.getAttribute('cx')) - wanted) < 1), x);
|
||||
const sharedHandleX = () => page.evaluate(() => {
|
||||
const candidates = [...window.__card.renderRoot.querySelectorAll(
|
||||
const activeRoot = window.__card.renderRoot.querySelector('[data-hp-live-editor] .hp-live-resize')
|
||||
|| window.__card.renderRoot;
|
||||
const candidates = [...activeRoot.querySelectorAll(
|
||||
'.rszhandle[aria-disabled="false"]',
|
||||
)].filter((entry) => Math.abs(Number(entry.getAttribute('cy')) - 529.166667) < 2);
|
||||
return candidates
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
// #152: the browser-painted room owns a clean View tap and the shared camera
|
||||
// fits its exact rendered geometry. This smoke is part of the pre-beta matrix;
|
||||
// the implementation cycle itself remains typecheck + unit + build.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 800 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const root = c.shadowRoot || c.renderRoot;
|
||||
const result = {};
|
||||
const config = {
|
||||
spaces: [{
|
||||
id: 'fit-floor', title: 'Fit floor', view_box: [0, 0, 1, 1],
|
||||
settings: { show_borders: true, show_names: true },
|
||||
partitions: [{ id: 'fit-wall', a: [0.1, 0.1], b: [0.9, 0.1], cm: 15 }],
|
||||
rooms: [
|
||||
{ id: 'outer', name: 'Outer', area: 'fit_outer',
|
||||
poly: [[0.05, 0.05], [0.95, 0.05], [0.95, 0.95], [0.05, 0.95]] },
|
||||
{ id: 'room-a', name: 'Office', area: 'fit_office',
|
||||
poly: [[0.55, 0.15], [0.85, 0.15], [0.85, 0.45], [0.72, 0.45], [0.72, 0.35], [0.55, 0.35]] },
|
||||
],
|
||||
}],
|
||||
markers: [], settings: { filter_seeded: true },
|
||||
};
|
||||
const server = (await c.hass.callWS({ type: 'houseplan/config/get' })).config;
|
||||
for (const key of Object.keys(server)) delete server[key];
|
||||
Object.assign(server, JSON.parse(JSON.stringify(config)));
|
||||
c._serverCfg = server;
|
||||
c._space = 'fit-floor';
|
||||
c._modelCache = null;
|
||||
c._frame = null;
|
||||
c._cfgEpoch++;
|
||||
c._mode = 'view';
|
||||
c._zoom = 1;
|
||||
c._view = null;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
|
||||
const waitCamera = async () => {
|
||||
const started = performance.now();
|
||||
while (c._cameraTransition?.active) {
|
||||
if (performance.now() - started > 1200) throw new Error('room camera did not settle');
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
await c.updateComplete;
|
||||
};
|
||||
const pointer = (target, type, id, x = 500, y = 300, extra = {}) =>
|
||||
target.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id,
|
||||
pointerType: 'mouse', isPrimary: true, button: 0,
|
||||
buttons: type === 'pointerdown' || type === 'pointermove' ? 1 : 0,
|
||||
clientX: x, clientY: y, ...extra,
|
||||
}));
|
||||
const roomNode = () => root.querySelector('[data-hp="room"][data-id="room-a"]');
|
||||
const room = () => c._spaceModel().rooms.find((item) => item.id === 'room-a');
|
||||
const margins = () => {
|
||||
const bounds = c._roomFitBounds(room(), c._spaceModel());
|
||||
const view = c._view;
|
||||
return {
|
||||
left: (bounds.x - view.x) / view.w,
|
||||
right: (view.x + view.w - bounds.x - bounds.w) / view.w,
|
||||
top: (bounds.y - view.y) / view.h,
|
||||
bottom: (view.y + view.h - bounds.y - bounds.h) / view.h,
|
||||
};
|
||||
};
|
||||
|
||||
localStorage.setItem('houseplan_card_zoom_v1', JSON.stringify({ 'fit-floor': 1 }));
|
||||
pointer(roomNode(), 'pointerdown', 15201);
|
||||
pointer(roomNode(), 'pointerup', 15201);
|
||||
const syncFrames = [];
|
||||
for (let frame = 0; frame < 3 && c._cameraTransition?.active; frame++) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
await c.updateComplete;
|
||||
const activeView = c._view;
|
||||
const activeLabel = root.querySelector('.roomlabel[data-id="room-a"]');
|
||||
const activeSvg = root.querySelector('.plan-svg') || root.querySelector('.zoomwrap > svg');
|
||||
const position = c._scenePoint([c._labelPos(room(), 'fit-floor').x,
|
||||
c._labelPos(room(), 'fit-floor').y]);
|
||||
const expectedLeft = ((position[0] - activeView.x) / activeView.w) * 100;
|
||||
const expectedTop = ((position[1] - activeView.y) / activeView.h) * 100;
|
||||
const actualView = activeSvg?.getAttribute('viewBox')?.trim().split(/\s+/).map(Number) || [];
|
||||
const labelRect = activeLabel?.getBoundingClientRect();
|
||||
const stageRect = root.querySelector('.stage')?.getBoundingClientRect();
|
||||
syncFrames.push(actualView.length === 4
|
||||
&& actualView.every((value, index) => Math.abs(value
|
||||
- [activeView.x, activeView.y, activeView.w, activeView.h][index]) < 1e-6)
|
||||
&& !!labelRect && !!stageRect
|
||||
&& Math.abs(labelRect.left + labelRect.width / 2
|
||||
- (stageRect.left + stageRect.width * expectedLeft / 100)) < 1
|
||||
&& Math.abs(labelRect.top + labelRect.height / 2
|
||||
- (stageRect.top + stageRect.height * expectedTop / 100)) < 1);
|
||||
}
|
||||
result.svgAndHtmlShareTweenFrames = syncFrames.length > 0 && syncFrames.every(Boolean);
|
||||
await waitCamera();
|
||||
const flatMargins = margins();
|
||||
result.cleanRoomTapFits = Object.values(flatMargins).every((value) => value >= 0.099);
|
||||
result.oneFlatAxisUsesTenPercent = Math.min(...Object.values(flatMargins)) <= 0.102;
|
||||
result.roomIntentIsSessionOnly = c._roomFocus?.roomId === 'room-a'
|
||||
&& JSON.parse(localStorage.getItem('houseplan_card_zoom_v1'))['fit-floor'] === 1;
|
||||
|
||||
const settled = JSON.stringify(c._view);
|
||||
pointer(roomNode(), 'pointerdown', 15202);
|
||||
pointer(roomNode(), 'pointerup', 15202);
|
||||
await waitCamera();
|
||||
result.repeatedFitIsNoOp = !c._cameraTransition.active && JSON.stringify(c._view) === settled;
|
||||
|
||||
const label = root.querySelector('.roomlabel[data-id="room-a"]');
|
||||
result.labelIsKeyboardAction = label?.getAttribute('role') === 'button'
|
||||
&& label?.getAttribute('tabindex') === '0'
|
||||
&& /Office/.test(label?.getAttribute('aria-label') || '');
|
||||
c._resetZoom(); await waitCamera();
|
||||
label.focus();
|
||||
label.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }));
|
||||
await waitCamera();
|
||||
result.keyboardUsesSameCommand = c._roomFocus?.roomId === 'room-a'
|
||||
&& (root.activeElement === label || document.activeElement === label);
|
||||
|
||||
const editorLabelsArePassive = [];
|
||||
for (const mode of ['devices', 'decor']) {
|
||||
c._mode = mode;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const editorLabel = root.querySelector('.roomlabel[data-id="room-a"]');
|
||||
editorLabelsArePassive.push(editorLabel
|
||||
&& !editorLabel.hasAttribute('role')
|
||||
&& !editorLabel.hasAttribute('tabindex')
|
||||
&& !editorLabel.hasAttribute('aria-label'));
|
||||
}
|
||||
result.editorsDoNotExposeRoomAction = editorLabelsArePassive.every(Boolean);
|
||||
c._mode = 'view';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
|
||||
c._resetZoom(); await waitCamera();
|
||||
c._clearRoomFocus(true);
|
||||
const currentLabel = root.querySelector('.roomlabel[data-id="room-a"]');
|
||||
const ownerProbe = document.createElement('span');
|
||||
ownerProbe.setAttribute('role', 'button');
|
||||
ownerProbe.dataset.roomFitBlock = 'smoke-action';
|
||||
currentLabel.append(ownerProbe);
|
||||
pointer(ownerProbe, 'pointerdown', 15203);
|
||||
pointer(ownerProbe, 'pointerup', 15203);
|
||||
await c.updateComplete;
|
||||
result.commonInteractiveOwnerSuppressesRoomFit = c._roomPointer === null
|
||||
&& c._roomFocus === null;
|
||||
const areaLink = currentLabel.querySelector('.rlgo');
|
||||
const beforeLink = JSON.stringify(c._view);
|
||||
pointer(areaLink, 'pointerdown', 15204);
|
||||
pointer(areaLink, 'pointerup', 15204);
|
||||
await c.updateComplete;
|
||||
result.areaLinkSuppressesRoomFit = JSON.stringify(c._view) === beforeLink
|
||||
&& c._roomPointer === null && c._roomFocus === null;
|
||||
|
||||
const stage = root.querySelector('.stage');
|
||||
const base = c._baseVb();
|
||||
const moveAway = async () => {
|
||||
c._doubleFit.clear();
|
||||
c._applyView(2, base[0] + base[2] * 0.65, base[1] + base[3] * 0.4);
|
||||
await c.updateComplete;
|
||||
};
|
||||
await moveAway();
|
||||
c._fitAll('fit');
|
||||
await waitCamera();
|
||||
const toolbarTarget = JSON.stringify({ zoom: c._zoom, view: c._view });
|
||||
|
||||
await moveAway();
|
||||
c._showFar = false;
|
||||
c._frame = null;
|
||||
c._roomFocus = { spaceId: 'fit-floor', roomId: 'outer' };
|
||||
const beforeFirstTap = JSON.stringify({ zoom: c._zoom, view: c._view });
|
||||
let firstTapUpdates = 0;
|
||||
const requestUpdate = c.requestUpdate;
|
||||
c.requestUpdate = (...args) => { firstTapUpdates++; return requestUpdate.apply(c, args); };
|
||||
pointer(stage, 'pointerdown', 44901, 400, 500);
|
||||
pointer(stage, 'pointerup', 44901, 401, 500);
|
||||
c.requestUpdate = requestUpdate;
|
||||
result.firstBackgroundTapIsPassive = firstTapUpdates === 0
|
||||
&& JSON.stringify({ zoom: c._zoom, view: c._view }) === beforeFirstTap;
|
||||
pointer(stage, 'pointerdown', 44902, 650, 500);
|
||||
pointer(stage, 'pointerup', 44902, 651, 500);
|
||||
result.normalViewUsesDoubleTapReason = c._cameraTransition.state?.reason === 'double-tap';
|
||||
await waitCamera();
|
||||
result.normalViewDoubleClickMatchesFitAll = c._showFar === true && c._roomFocus === null
|
||||
&& JSON.stringify({ zoom: c._zoom, view: c._view }) === toolbarTarget;
|
||||
|
||||
await moveAway();
|
||||
pointer(stage, 'pointerdown', 44903, 400, 500, { pointerType: 'touch' });
|
||||
pointer(stage, 'pointerup', 44903, 400, 500, { pointerType: 'touch' });
|
||||
pointer(stage, 'pointerdown', 44904, 500, 500, { pointerType: 'pen' });
|
||||
pointer(stage, 'pointerup', 44904, 500, 500, { pointerType: 'pen' });
|
||||
result.mixedModalitiesDoNotPair = c._zoom === 2 && !c._cameraTransition.active;
|
||||
pointer(stage, 'pointerdown', 44905, 550, 500, { pointerType: 'pen' });
|
||||
pointer(stage, 'pointerup', 44905, 550, 500, { pointerType: 'pen' });
|
||||
await waitCamera();
|
||||
result.penPairUsesSameFitAll = JSON.stringify({ zoom: c._zoom, view: c._view }) === toolbarTarget;
|
||||
|
||||
await moveAway();
|
||||
pointer(stage, 'pointerdown', 44906, 400, 500, { pointerType: 'touch' });
|
||||
pointer(stage, 'pointerup', 44906, 400, 500, { pointerType: 'touch' });
|
||||
pointer(stage, 'pointerdown', 44907, 400, 500, { pointerType: 'touch' });
|
||||
pointer(stage, 'pointercancel', 44907, 400, 500, { pointerType: 'touch' });
|
||||
pointer(stage, 'pointerdown', 44908, 400, 500, { pointerType: 'touch' });
|
||||
pointer(stage, 'pointerup', 44908, 400, 500, { pointerType: 'touch' });
|
||||
result.cancelDisarmsThePreviousTap = c._zoom === 2 && !c._cameraTransition.active;
|
||||
|
||||
c._mode = 'decor';
|
||||
c._doubleFit.clear();
|
||||
pointer(stage, 'pointerdown', 44909, 400, 500);
|
||||
pointer(stage, 'pointerup', 44909, 400, 500);
|
||||
pointer(stage, 'pointerdown', 44910, 400, 500);
|
||||
pointer(stage, 'pointerup', 44910, 400, 500);
|
||||
result.editorBackgroundDoesNotFit = c._zoom === 2 && !c._cameraTransition.active;
|
||||
c._mode = 'view';
|
||||
c._doubleFit.clear();
|
||||
|
||||
pointer(roomNode(), 'pointerdown', 15205, 500, 300);
|
||||
pointer(roomNode(), 'pointermove', 15205, 520, 300);
|
||||
pointer(roomNode(), 'pointerup', 15205, 520, 300);
|
||||
await c.updateComplete;
|
||||
result.panCancelsRoomIntent = c._roomFocus === null && c._roomPointer === null;
|
||||
|
||||
pointer(roomNode(), 'pointerdown', 15206, 500, 300);
|
||||
pointer(roomNode(), 'pointerup', 15206, 500, 300);
|
||||
await waitCamera();
|
||||
c.style.width = '760px';
|
||||
c._lastValidStageSize = [1000, c._stageEl.clientHeight];
|
||||
c._refitView();
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
await c.updateComplete;
|
||||
const resizedMargins = margins();
|
||||
result.resizeRefitsAtomically = c._roomFocus?.roomId === 'room-a'
|
||||
&& Object.values(resizedMargins).every((value) => value >= 0.099);
|
||||
|
||||
c.style.width = '1000px';
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
c._clearRoomFocus(true);
|
||||
c._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
await c.updateComplete;
|
||||
c._setProjection('iso');
|
||||
await c.updateComplete;
|
||||
pointer(roomNode(), 'pointerdown', 15207);
|
||||
pointer(roomNode(), 'pointerup', 15207);
|
||||
await waitCamera();
|
||||
const isoMargins = margins();
|
||||
result.isoUsesProjectedBounds = c._effectiveProjection() === 'iso'
|
||||
&& Object.values(isoMargins).every((value) => value >= 0.099)
|
||||
&& (c._zoom === 8 || Math.min(...Object.values(isoMargins)) <= 0.102);
|
||||
|
||||
const kiosk = document.createElement('houseplan-card');
|
||||
kiosk.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
kiosk.hass = c.hass;
|
||||
kiosk.style.cssText = 'position:fixed;inset:0;width:900px;height:700px;z-index:99';
|
||||
document.body.appendChild(kiosk);
|
||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||
kiosk.hass = { ...c.hass };
|
||||
await kiosk.updateComplete;
|
||||
const kioskRoot = kiosk.shadowRoot || kiosk.renderRoot;
|
||||
const kioskRoom = kioskRoot.querySelector('[data-hp="room"][data-id="room-a"]');
|
||||
const kioskPointer = (target, type, id) => target.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id,
|
||||
pointerType: 'touch', isPrimary: true, button: 0,
|
||||
buttons: type === 'pointerdown' ? 1 : 0, clientX: 450, clientY: 300,
|
||||
}));
|
||||
for (const id of [15208, 15209]) {
|
||||
kioskPointer(kioskRoom, 'pointerdown', id);
|
||||
kioskPointer(kioskRoom, 'pointerup', id);
|
||||
const started = performance.now();
|
||||
while (kiosk._cameraTransition.active && performance.now() - started < 1200) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
}
|
||||
const beforeKioskBackground = JSON.stringify({ zoom: kiosk._zoom, view: kiosk._view });
|
||||
kioskPointer(kiosk._stageEl, 'pointerdown', 15210);
|
||||
kioskPointer(kiosk._stageEl, 'pointerup', 15210);
|
||||
result.kioskRoomTapDoesNotEnterDoubleTap = kiosk._roomFocus?.roomId === 'room-a'
|
||||
&& JSON.stringify({ zoom: kiosk._zoom, view: kiosk._view }) === beforeKioskBackground;
|
||||
kiosk._clearRoomFocus(true);
|
||||
kiosk._applyView(2);
|
||||
for (const id of [15211, 15212]) {
|
||||
kioskPointer(kiosk._stageEl, 'pointerdown', id);
|
||||
kioskPointer(kiosk._stageEl, 'pointerup', id);
|
||||
}
|
||||
const resetStarted = performance.now();
|
||||
while (kiosk._cameraTransition.active && performance.now() - resetStarted < 1200) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
result.kioskBackgroundDoubleTapStillResets = Math.abs(kiosk._zoom - 1) < 1e-9;
|
||||
kiosk.remove();
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -94,7 +94,7 @@ const result = await page.evaluate(async () => {
|
||||
await card._saveSettingsDialog();
|
||||
await card.updateComplete;
|
||||
out.falsePersists = card._serverCfg.settings.show_room_tooltip === false;
|
||||
out.visibleRoomTipCleared = card._tip === null && !root().querySelector('.tip');
|
||||
out.visibleRoomTipCleared = card._tip === null && root().querySelector('.tip')?.hidden === true;
|
||||
|
||||
let roomAreaCalls = 0;
|
||||
const originalRoomArea = card._roomArea.bind(card);
|
||||
@@ -105,7 +105,7 @@ const result = await page.evaluate(async () => {
|
||||
room().dispatchEvent(mouse('pointerenter', 190, 190));
|
||||
room().dispatchEvent(mouse('pointermove', 190, 190));
|
||||
await card.updateComplete;
|
||||
out.disabledRoomTip = card._tip === null && !root().querySelector('.tip');
|
||||
out.disabledRoomTip = card._tip === null && root().querySelector('.tip')?.hidden === true;
|
||||
out.disabledSkipsArea = roomAreaCalls === 0;
|
||||
out.roomHighlightSurvives = card._hoverRoom !== null
|
||||
&& !!root().querySelector('.room-hover-fill-layer,.room-hover-outline-layer');
|
||||
|
||||
@@ -112,7 +112,7 @@ const out = await page.evaluate(async () => {
|
||||
directBaseline(2);
|
||||
if (reason === 'home') card._fitAll('home');
|
||||
else if (reason === 'fit') card._fitAll('fit');
|
||||
else card._resetZoom('double-tap');
|
||||
else card._fitAll('double-tap');
|
||||
reasons[reason] = {
|
||||
liveReason: card._cameraTransition.state?.reason,
|
||||
samples: (await settle()).map((sample) => sample.zoom),
|
||||
@@ -141,7 +141,11 @@ const out = await page.evaluate(async () => {
|
||||
card._reducedMotion = false;
|
||||
|
||||
// Projection owns an atomic structural conversion and cancels the camera.
|
||||
card._labs = { ...card._labs, active: [...new Set([...card._labs.active, 'iso'])] };
|
||||
card._labs = {
|
||||
...card._labs,
|
||||
alpha: true,
|
||||
active: [...new Set([...card._labs.active, 'iso'])],
|
||||
};
|
||||
card._viewPreference = { ...card._viewPreference, [card._space]: 'flat' };
|
||||
directBaseline(1);
|
||||
card._stepZoom(1);
|
||||
|
||||
@@ -110,10 +110,8 @@ const res = await page.evaluate(async () => {
|
||||
out.kioskParity = sr().querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
c._config.kiosk = kioskBefore;
|
||||
|
||||
history.replaceState(null, '', `?hp-labs=iso#space=${encodeURIComponent(c._space)}`);
|
||||
history.replaceState(null, '', `?hp_alpha=1#space=${encodeURIComponent(c._space)}`);
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
if (typeof c._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
|
||||
c._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
await c.updateComplete;
|
||||
c._setProjection('iso');
|
||||
await update();
|
||||
|
||||
@@ -54,7 +54,8 @@ const res = await page.evaluate(async () => {
|
||||
const humLine = metricLines.findIndex((line) => line.includes('средняя влажность'));
|
||||
const lqiLine = metricLines.findIndex((line) => line.includes(c._t('tip.lqi')));
|
||||
out.tipMetricOrder = tempLine >= 0 && humLine === tempLine + 1 && lqiLine === humLine + 1;
|
||||
out.tipPositionUnchanged = sr().querySelector('.tip')?.getAttribute('style') === 'left:212px;top:212px';
|
||||
out.tipPositionUnchanged = sr().querySelector('.tip')?.style.left === '212px'
|
||||
&& sr().querySelector('.tip')?.style.top === '212px';
|
||||
// _roomArea consumes the rendered room geometry (0..1000), not the stored
|
||||
// normalized config polygon (0..1).
|
||||
const expectedArea = c._roomArea(c._spaceModel().rooms[0]);
|
||||
|
||||
@@ -118,10 +118,8 @@ const out = await page.evaluate(async () => {
|
||||
await update();
|
||||
result.planViewParity = !!planD && root().querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
|
||||
history.replaceState(null, '', '?hp-labs=iso#space=junctions');
|
||||
history.replaceState(null, '', '?hp_alpha=1#space=junctions');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
if (typeof card._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
|
||||
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
await card.updateComplete;
|
||||
card._setProjection('iso');
|
||||
await update();
|
||||
|
||||
@@ -114,7 +114,8 @@ const result = await page.evaluate(async (source) => {
|
||||
staticCard.remove();
|
||||
|
||||
const labs = Object.freeze(['iso']);
|
||||
card._onLabsSnapshot({ active: labs, space: '' });
|
||||
card._onLabsSnapshot({ alpha: true, active: labs, space: '' });
|
||||
window.__hpAlpha = true;
|
||||
window.__hpLabs = labs;
|
||||
card._setProjection('iso');
|
||||
await update(false);
|
||||
|
||||
@@ -73,7 +73,8 @@ const result = await page.evaluate(async (fixtureConfig) => {
|
||||
staticCard.remove();
|
||||
|
||||
const labs = Object.freeze(['iso']);
|
||||
card._onLabsSnapshot({ active: labs, space: '' });
|
||||
card._onLabsSnapshot({ alpha: true, active: labs, space: '' });
|
||||
window.__hpAlpha = true;
|
||||
window.__hpLabs = labs;
|
||||
card._setProjection('iso');
|
||||
card.requestUpdate();
|
||||
|
||||
@@ -129,10 +129,8 @@ const res = await page.evaluate(async () => {
|
||||
out.kioskParity = sr().querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
c._config.kiosk = kioskBefore;
|
||||
|
||||
history.replaceState(null, '', `?hp-labs=iso#space=${encodeURIComponent(c._space)}`);
|
||||
history.replaceState(null, '', `?hp_alpha=1#space=${encodeURIComponent(c._space)}`);
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
if (typeof c._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
|
||||
c._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
await c.updateComplete;
|
||||
c._setProjection('iso');
|
||||
await update();
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const result = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const wait = (predicate, timeout = 3000) => new Promise((resolve, reject) => {
|
||||
const started = performance.now();
|
||||
const tick = () => {
|
||||
if (predicate()) return resolve();
|
||||
if (performance.now() - started > timeout) return reject(new Error('topology smoke timeout'));
|
||||
setTimeout(tick, 20);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
const mouse = (type, relatedTarget = null) => new PointerEvent(type, {
|
||||
pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200, relatedTarget,
|
||||
});
|
||||
const out = {};
|
||||
|
||||
await card.updateComplete;
|
||||
out.defaultOff = !root().querySelector('hp-zigbee-topology-overlay');
|
||||
out.offHasNoLazyChunk = !performance.getEntriesByType('resource')
|
||||
.some((entry) => /zigbee-topology-runtime|hp-zigbee-topology-overlay/.test(entry.name));
|
||||
|
||||
let zhaCalls = 0;
|
||||
const originalHass = card.hass;
|
||||
card.hass = { ...originalHass, callWS: async (message) => {
|
||||
if (message.type !== 'zha/devices') return originalHass.callWS(message);
|
||||
zhaCalls++;
|
||||
return [
|
||||
{ ieee: '00124b0000000001', device_reg_id: 'd_light1', neighbors: [
|
||||
{ ieee: '00124b0000000002', lqi: 180 },
|
||||
{ ieee: '00124b0000000003', lqi: 80 },
|
||||
] },
|
||||
{ ieee: '00124b0000000002', device_reg_id: 'd_lamp', neighbors: [] },
|
||||
{ ieee: '00124b0000000003', device_reg_id: 'd_mower', neighbors: [] },
|
||||
{ ieee: '00124b0000000004', device_reg_id: 'd_temp', neighbors: [
|
||||
{ ieee: '00124b0000000005', lqi: 20 },
|
||||
] },
|
||||
{ ieee: '00124b0000000005', device_reg_id: 'd_tv', neighbors: [] },
|
||||
];
|
||||
} };
|
||||
await card._ensureEditorRuntime();
|
||||
card._openSettingsDialog();
|
||||
await card.updateComplete;
|
||||
let settings = root().querySelector('hp-zigbee-topology-settings');
|
||||
await wait(() => !!settings?.shadowRoot);
|
||||
settings._emit({ enabled: true, z2mBaseTopics: [] });
|
||||
await card.updateComplete;
|
||||
await card._saveSettingsDialog();
|
||||
await card.updateComplete;
|
||||
await wait(() => !!root().querySelector('hp-zigbee-topology-overlay'));
|
||||
out.settingPersists = card._serverCfg.settings.zigbee_topology?.enabled === true;
|
||||
out.enableDoesNotFetch = zhaCalls === 0;
|
||||
|
||||
card._openSettingsDialog();
|
||||
await card.updateComplete;
|
||||
settings = root().querySelector('hp-zigbee-topology-settings');
|
||||
await wait(() => !!settings?.shadowRoot?.querySelector('button'));
|
||||
await settings._readZha();
|
||||
await wait(() => settings._snapshot?.states?.zha?.phase === 'ready');
|
||||
out.explicitZhaRead = zhaCalls === 1;
|
||||
card._settingsDialog = null;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
|
||||
const source = root().querySelector('.dev[data-id="d_light1"]');
|
||||
source.dispatchEvent(mouse('pointerover'));
|
||||
await wait(() => root().querySelector('hp-zigbee-topology-overlay')?.shadowRoot
|
||||
?.querySelector('[data-hp="zigbee-topology-lines"]'));
|
||||
const overlay = root().querySelector('hp-zigbee-topology-overlay');
|
||||
out.incidentOnly = overlay.shadowRoot.querySelectorAll('line').length === 1
|
||||
&& overlay.shadowRoot.querySelectorAll('[data-hp="zigbee-topology-neighbor"]').length === 1;
|
||||
out.crossSpaceCount = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-remote"]')
|
||||
?.textContent.trim() === '+1 in other spaces';
|
||||
out.pointerTransparent = getComputedStyle(overlay).pointerEvents === 'none'
|
||||
&& getComputedStyle(overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-remote"]')).pointerEvents === 'none';
|
||||
|
||||
source.dispatchEvent(mouse('pointerout', root().querySelector('.stage')));
|
||||
await card.updateComplete;
|
||||
out.leaveClears = !overlay.shadowRoot.querySelector('line');
|
||||
source.dispatchEvent(mouse('pointerover'));
|
||||
await wait(() => overlay.shadowRoot.querySelector('line'));
|
||||
root().querySelector('.stage').dispatchEvent(new PointerEvent('pointermove', {
|
||||
pointerType: 'touch', bubbles: true, composed: true, pointerId: 7,
|
||||
}));
|
||||
await wait(() => !overlay.shadowRoot.querySelector('line'));
|
||||
out.touchClears = !overlay.shadowRoot.querySelector('line');
|
||||
|
||||
card._setMode('plan');
|
||||
await card.updateComplete;
|
||||
out.editorHasNoOverlay = !root().querySelector('hp-zigbee-topology-overlay');
|
||||
card._setMode('view');
|
||||
card.hass = { ...card.hass, user: { ...card.hass.user, is_admin: false } };
|
||||
await card.updateComplete;
|
||||
out.nonAdminHasNoOverlay = !root().querySelector('hp-zigbee-topology-overlay') && zhaCalls === 1;
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||
Vendored
+119
-62
@@ -1,125 +1,182 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15",
|
||||
"fingerprint": "3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-n2PxGqZY.js",
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 292752,
|
||||
"initialViewGzipBytes": 296719,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
|
||||
"houseplan-assets/de-BP_2FY3A.js",
|
||||
"houseplan-assets/editor-BoI8ovCT.js",
|
||||
"houseplan-assets/fr-DOlNp4ji.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-De35hz5H.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-dO-9we_v.js"
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/de-B9ewWWX2.js",
|
||||
"houseplan-assets/editor-CDcQqv3k.js",
|
||||
"houseplan-assets/fr-4rsWX0CK.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
|
||||
"houseplan-assets/live-interaction-runtime-ClI0mgY3.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
|
||||
],
|
||||
"lazyGzipBytes": 223409,
|
||||
"lazyGzipBytes": 240651,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
|
||||
"houseplan-assets/editor-BoI8ovCT.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-De35hz5H.js"
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/editor-CDcQqv3k.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 167369,
|
||||
"lazyEditorGzipBytes": 178324,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-dO-9we_v.js"
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14244,
|
||||
"lazyOnboardingGzipBytes": 14239,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-BP_2FY3A.js",
|
||||
"houseplan-assets/fr-DOlNp4ji.js"
|
||||
"houseplan-assets/de-B9ewWWX2.js",
|
||||
"houseplan-assets/fr-4rsWX0CK.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 49080,
|
||||
"lazyLocaleGzipBytes": 49104,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-DMGa8wc7.js",
|
||||
"sha256": "6226595cb5753e94dbba8a99943f5d7133eb19cfb43d2c7af574b2cd1c995c7a",
|
||||
"rawBytes": 21418,
|
||||
"gzipBytes": 7284,
|
||||
"path": "houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"sha256": "473ca1094a2f837fffc71026ba5f9c5787a6a1e18db7b37328f586bf7bdeb73a",
|
||||
"rawBytes": 21417,
|
||||
"gzipBytes": 7280,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-n2PxGqZY.js"
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-BP_2FY3A.js",
|
||||
"sha256": "0925d353b13fb9ed03f06fa910fb44f9f7460f1edf8efaf1299c91117d223697",
|
||||
"rawBytes": 82140,
|
||||
"gzipBytes": 24809,
|
||||
"path": "houseplan-assets/de-B9ewWWX2.js",
|
||||
"sha256": "3c291653c70e131993810e78b7ace06a10758b9fbdfa54636f890b36aa8cfc1a",
|
||||
"rawBytes": 82182,
|
||||
"gzipBytes": 24822,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-BoI8ovCT.js",
|
||||
"sha256": "4c882564e63a08f64b1b34ad231bd85d897e12cd2973a1ffcbcfe0d091ceff02",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1585,
|
||||
"path": "houseplan-assets/editor-CDcQqv3k.js",
|
||||
"sha256": "76a014106e36d05f727ecc2bfb654708df0d5d3db54d498bb58254eb9ebec15b",
|
||||
"rawBytes": 3831,
|
||||
"gzipBytes": 1583,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-n2PxGqZY.js"
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-DOlNp4ji.js",
|
||||
"sha256": "d54f2c24b83c924eba22439817b54d7793af00d28fd4f16e18e0cf35e8737ea3",
|
||||
"rawBytes": 84267,
|
||||
"gzipBytes": 24271,
|
||||
"path": "houseplan-assets/fr-4rsWX0CK.js",
|
||||
"sha256": "b0508cefcba06761e14436fd8ab8bef916c0bdcb70a71e1d338ba7722057d6f1",
|
||||
"rawBytes": 84312,
|
||||
"gzipBytes": 24282,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-n2PxGqZY.js",
|
||||
"sha256": "f2e5ff91866347e77fe2d924294e119651f41641356d1f761188f175500c2af1",
|
||||
"rawBytes": 1039630,
|
||||
"gzipBytes": 291954,
|
||||
"path": "houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"sha256": "24597a66c6c0b7ddb3859a5bc2e8b58e08ef57d4612d0cf9222895122edfe92b",
|
||||
"rawBytes": 1053904,
|
||||
"gzipBytes": 295923,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-BP_2FY3A.js",
|
||||
"houseplan-assets/editor-BoI8ovCT.js",
|
||||
"houseplan-assets/fr-DOlNp4ji.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-De35hz5H.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-dO-9we_v.js"
|
||||
"houseplan-assets/de-B9ewWWX2.js",
|
||||
"houseplan-assets/editor-CDcQqv3k.js",
|
||||
"houseplan-assets/fr-4rsWX0CK.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
|
||||
"houseplan-assets/live-interaction-runtime-ClI0mgY3.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-De35hz5H.js",
|
||||
"sha256": "9f44dafa722abba6a47073110496e99d178ee661f38bed2e189280f629980f73",
|
||||
"rawBytes": 601529,
|
||||
"gzipBytes": 158500,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
|
||||
"sha256": "fe6417012721db8c1124e8d1a1b9f037db6edd8edb1ccf328bbc1c9697186cbd",
|
||||
"rawBytes": 622491,
|
||||
"gzipBytes": 164615,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
|
||||
"houseplan-assets/houseplan-card-n2PxGqZY.js"
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
|
||||
"sha256": "69cf99a15ce74f74a309241f88f2cf0eade072507f2ec463560956367549d5d2",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6959,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-dO-9we_v.js",
|
||||
"sha256": "429df2912042da7959c13847d96b6a957a5e097c2899b82e82bb0f9820e86067",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6960,
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
|
||||
"sha256": "e64d8d0dae12904eba4aa3f87fb7f046962091eb7284016e0442c838be3cdfb2",
|
||||
"rawBytes": 6006,
|
||||
"gzipBytes": 2376,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DMGa8wc7.js",
|
||||
"houseplan-assets/houseplan-card-n2PxGqZY.js"
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-ClI0mgY3.js",
|
||||
"sha256": "05f378c2eef76f35ea98d05821b4680cae1bd599e410d27fbe69ad5ad76eb0d6",
|
||||
"rawBytes": 5196,
|
||||
"gzipBytes": 2127,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-Q4h3gz_F.js",
|
||||
"sha256": "c6f7dc8aad18071bd7766901c382201b0252725fae2c77993483030b3f73ca67",
|
||||
"rawBytes": 12382,
|
||||
"gzipBytes": 4846,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-CVolox2I.js",
|
||||
"sha256": "e4dcb60eb07d78c23782ac134e498fff6b348f4f35a2a7820a13582ceb1887fa",
|
||||
"rawBytes": 3941,
|
||||
"gzipBytes": 1761,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "8c663d28d60b83468ee17e7b8e6891d9579e96e1c8c89cd5ca2ecd23c89e84a3",
|
||||
"sha256": "03b15da3629659eba3779885ccf8f0206455ba95378dd1fa246c8ea4bb372a8e",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 798,
|
||||
"gzipBytes": 796,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-n2PxGqZY.js"
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
Vendored
+7
-7
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
-14
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15";import{k as e,l as o,t,dm as s,A as a,dn as i,dp as l,dq as n,E as r,c}from"./houseplan-card-n2PxGqZY.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{v as e,l as o,z as t,dt as s,A as a,du as i,dv as l,dw as n,E as r,c}from"./houseplan-card-CeQyO4TX.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
+5671
File diff suppressed because one or more lines are too long
-5661
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15";import{l as t,aQ as s,A as e,t as o,c as a,cd as l,ce as i,cf as h,cg as c,aN as n,ch as r,cm as p,ci as d,cj as _,ck as u,cl as g,dc as m,aM as b,aO as $,dd as v}from"./houseplan-card-n2PxGqZY.js";import{i as f,c as y,e as w,r as D,a as k,b as S,d as C,t as M}from"./backdrop-pick-DMGa8wc7.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{l as t,a$ as s,A as e,z as o,c as a,cj as l,ck as i,cl as h,cm as c,aY as n,cn as r,cs as p,co as d,cp as _,cq as u,cr as g,dj as m,aX as b,aZ as $,dk as v}from"./houseplan-card-CeQyO4TX.js";import{i as f,c as y,e as w,r as D,a as k,b as S,d as C,t as M}from"./backdrop-pick-Br9hp4fM.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";class L{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
|
||||
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,p="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:r(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:p,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=p(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:_,tempMax:d,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){const s=this.host._spaceDialog,e=s?.saved?.find(s=>s.name===t);if(!s||!e||e.used_by.length||e.url===s.planUrl)return;const o=await this.host._confirmDanger({key:"delete-plan",kind:"destructive",title:this.host._t("confirm.delete_plan_title"),message:this.host._t("confirm.delete_plan_body"),objectName:t,confirmLabel:this.host._t("btn.delete"),cancelLabel:this.host._t("btn.cancel")}),a=this.host._spaceDialog,l=a?.saved?.find(s=>s.name===t);if(o&&a&&l&&l.url===e.url&&l.modified===e.modified&&!l.used_by.length&&l.url!==a.planUrl)try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
|
||||
${s.map(s=>{return a`
|
||||
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
|
||||
@@ -0,0 +1,36 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{v as e,A as t,dx as i,w as s,c as o,l as r,u as n}from"./houseplan-card-CeQyO4TX.js";import{a,r as h,t as d}from"./zigbee-topology-Q4h3gz_F.js";import{zigbeeTopologyRuntimeSnapshot as p,subscribeZigbeeTopology as c}from"./zigbee-topology-runtime-CVolox2I.js";const l={revision:0,topologies:[],states:{}};class v extends e{constructor(){super(...arguments),this.devices=[],this.currentSpace="",this._runtime=l,this._hovered="",this._pointerOver=e=>{if(!this._mouseAllowed(e))return void this._clear();const t=this._deviceFromEvent(e),i=t?.dataset.id||"";i&&i!==this._hovered&&(this._hovered=i,this.requestUpdate())},this._pointerOut=e=>{const t=this._deviceFromEvent(e);if(!t||t.dataset.id!==this._hovered)return;const i=e.relatedTarget instanceof Element?e.relatedTarget.closest('[data-hp="device"]'):null;i?.dataset.id!==this._hovered&&this._clear()},this._pointerDown=e=>{"mouse"!==e.pointerType&&this._clear()}}connectedCallback(){super.connectedCallback();const e=this.getRootNode();e.host&&"undefined"!=typeof MutationObserver&&(this._hoverGateObserver=new MutationObserver(()=>{e.host.hasAttribute("data-pointer-hover")||this._clear()}),this._hoverGateObserver.observe(e.host,{attributes:!0,attributeFilter:["data-pointer-hover"]})),queueMicrotask(()=>this._connectParent())}disconnectedCallback(){this._release?.(),this._release=void 0,this._disconnectParent(),this._hoverGateObserver?.disconnect(),this._hoverGateObserver=void 0,this._hovered="",super.disconnectedCallback()}updated(e){e.has("hass")&&(this._release?.(),this._runtime=p(this.hass),this._mappedMemo=void 0,this._release=c(this.hass,()=>{this._runtime=p(this.hass),this._mappedMemo=void 0,this.requestUpdate()})),(e.has("currentSpace")||e.has("devices")||e.has("registry"))&&(this._mappedMemo=void 0,this.devices.some(e=>e.id===this._hovered&&e.space===this.currentSpace)||(this._hovered=""))}_connectParent(){const e=this.parentElement;e&&e!==this._parent&&(this._disconnectParent(),this._parent=e,e.addEventListener("pointerover",this._pointerOver),e.addEventListener("pointerout",this._pointerOut),e.addEventListener("pointerdown",this._pointerDown,!0))}_disconnectParent(){this._parent?.removeEventListener("pointerover",this._pointerOver),this._parent?.removeEventListener("pointerout",this._pointerOut),this._parent?.removeEventListener("pointerdown",this._pointerDown,!0),this._parent=void 0}_deviceFromEvent(e){for(const t of e.composedPath())if(t instanceof HTMLElement&&"device"===t.dataset.hp)return t;return null}_mouseAllowed(e){const t=this.getRootNode();return"mouse"===e.pointerType&&t.host?.hasAttribute?.("data-pointer-hover")}_clear(){this._hovered&&(this._hovered="",this.requestUpdate())}_position(e){const t=[...this._parent?.querySelectorAll(".dev[data-id]")||[]].find(t=>t.dataset.id===e);if(!t)return null;const i=Number.parseFloat(t.style.left),s=Number.parseFloat(t.style.top),o=t.getBoundingClientRect();return Number.isFinite(i)&&Number.isFinite(s)?{x:i,y:s,width:o.width,height:o.height}:null}render(){if(!this._hovered||!this.registry||!this._runtime.topologies.length)return t;const e=this._mappedMemo,n=e&&e.runtime===this._runtime&&e.devices===this.devices&&e.registry===this.registry?e.mapped:a(this._runtime.topologies,this.devices,this.registry);e&&n===e.mapped||(this._mappedMemo={runtime:this._runtime,devices:this.devices,registry:this.registry,mapped:n});const p=h(n,this.currentSpace,this._hovered),c=this._position(this._hovered);if(!c)return t;const l=p.lines.map(e=>({...e,point:this._position(e.neighborMarkerId)})).filter(e=>!!e.point);return o`
|
||||
${l.length?s`<svg viewBox="0 0 100 100" preserveAspectRatio="none"
|
||||
aria-hidden="true" data-hp="zigbee-topology-lines">
|
||||
${l.map(e=>s`<line x1=${c.x} y1=${c.y}
|
||||
x2=${e.point.x} y2=${e.point.y}
|
||||
stroke=${void 0===e.lqi?"rgba(145,155,165,.85)":i(e.lqi)}
|
||||
stroke-width=${void 0===e.lqi?2:2.2}
|
||||
stroke-dasharray=${void 0===e.lqi?"5 5":t} opacity=".9"></line>`)}
|
||||
</svg>`:t}
|
||||
${l.map(e=>o`<div class="halo" data-hp="zigbee-topology-neighbor"
|
||||
data-id=${e.neighborMarkerId} style="left:${e.point.x}%;top:${e.point.y}%;width:${1.22*e.point.width}px;height:${1.22*e.point.height}px"></div>`)}
|
||||
${p.remoteCount?o`<div class="remote" data-hp="zigbee-topology-remote"
|
||||
style="left:${c.x}%;top:${c.y}%">${d(r(this.hass),"remote_count",{n:p.remoteCount})}</div>`:t}
|
||||
`}}v.properties={hass:{attribute:!1},devices:{attribute:!1},registry:{attribute:!1},currentSpace:{type:String,attribute:"current-space"},viewKey:{attribute:!1}},v.styles=n`
|
||||
:host { position: absolute; inset: 0; z-index: 5; display: block; pointer-events: none; }
|
||||
svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
|
||||
line { vector-effect: non-scaling-stroke; stroke-linecap: round; }
|
||||
.halo {
|
||||
position: absolute; width: calc(var(--device-base-size, 4cqw) * 1.22);
|
||||
height: calc(var(--device-base-size, 4cqw) * 1.22); transform: translate(-50%, -50%);
|
||||
box-sizing: border-box; border: 2px solid rgba(120, 190, 220, .82); border-radius: 50%;
|
||||
box-shadow: 0 0 0 4px rgba(120, 190, 220, .16);
|
||||
}
|
||||
.remote {
|
||||
position: absolute; transform: translate(12px, calc(-100% - 12px));
|
||||
border: 1px solid rgba(255,255,255,.7); border-radius: 999px;
|
||||
padding: 3px 7px; color: #fff; background: rgba(28,31,36,.88);
|
||||
box-shadow: 0 2px 7px rgba(0,0,0,.28); white-space: nowrap;
|
||||
font: 600 11px/1.2 system-ui, sans-serif;
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
line { stroke: Highlight !important; }
|
||||
.halo { border-color: Highlight; box-shadow: none; }
|
||||
.remote { color: CanvasText; background: Canvas; border-color: CanvasText; }
|
||||
}
|
||||
`,customElements.get("hp-zigbee-topology-overlay")||customElements.define("hp-zigbee-topology-overlay",v);export{v as HpZigbeeTopologyOverlay};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{n as t,b as n}from"./zigbee-topology-Q4h3gz_F.js";import{I as e}from"./houseplan-card-CeQyO4TX.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="9745c31cb7efba6ca6a53b0d3c4db9948844f285f4d9dbc1b4f17966979b9d15";try{await import("./houseplan-assets/houseplan-card-n2PxGqZY.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";try{await import("./houseplan-assets/houseplan-card-CeQyO4TX.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
|
||||
@@ -1636,6 +1636,30 @@ diffs the real `seedHiddenBindings`/`buildDevices` outputs — there is no
|
||||
second copy of the filter logic to drift. The field registry (#33) carries
|
||||
their passports; `scripts/config-audit.mjs` treats both as `current`.
|
||||
|
||||
## Contextual Zigbee topology (#54, 2026-09-04)
|
||||
|
||||
The initial View graph owns only the fail-closed settings reader and a dynamic
|
||||
overlay bridge. A saved `settings.zigbee_topology.enabled === true`, an actual
|
||||
HA admin, full-card View and non-kiosk surface are all required before the
|
||||
topology overlay chunk is requested. Opening the lazy General Settings runtime
|
||||
does not load provider transport until an enabled saved setting needs status or
|
||||
the admin presses a provider action.
|
||||
|
||||
`zigbee-topology.ts` normalizes ZHA and Zigbee2MQTT into unordered edge pairs
|
||||
with separate directional observations, maps IEEE nodes through exact HA
|
||||
registry ownership and resolves only edges incident to the hovered marker.
|
||||
Routes never invent neighbor edges. `zigbee-topology-runtime.ts` owns a
|
||||
per-connection memory cache and in-flight dedupe: ZHA reads `zha/devices`
|
||||
without requesting a scan; Z2M verifies the retained bridge-info topic, sends
|
||||
one correlated raw `routes:false` request through `mqtt.publish`, rejects
|
||||
retained/foreign/late replies and always releases subscriptions.
|
||||
|
||||
The pointer-transparent overlay is stacked above architectural/isometric
|
||||
layers and below device markers. It reads the already projected marker centres,
|
||||
so flat/isometric camera and zoom remain single-source. Cache data, IEEE
|
||||
addresses and raw payloads are never persisted, logged, exported or admitted
|
||||
to support diagnostics.
|
||||
|
||||
## The initial bundle carries English and Russian whole (#400, 2026-08-31)
|
||||
|
||||
`en` and `ru` are synchronous dictionaries in the initial chunk; `de` and `fr`
|
||||
|
||||
+20
-2
@@ -252,7 +252,8 @@ produce a zero-sized SVG `viewBox`.
|
||||
* **Zoom out** — `MIN_ZOOM = 1/3`: you can see three times the content
|
||||
frame and no further. Empty space beyond that is not information.
|
||||
* **Discrete camera motion** (#82) — wheel, `−`/`+`, Fit all, the home arrow
|
||||
and kiosk double-tap interpolate the existing exact camera target for
|
||||
and the free-background double-click/tap in View or kiosk interpolate the
|
||||
existing exact camera target for
|
||||
160–220 ms with `cubic-bezier(0.2, 0.7, 0.2, 1)`. Zoom is logarithmic and
|
||||
the world centre is linear; the final frame is the same clamped `viewBox`
|
||||
the immediate path used before #82. Rapid wheel input retargets one RAF from
|
||||
@@ -283,6 +284,23 @@ produce a zero-sized SVG `viewBox`.
|
||||
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.
|
||||
* **Room fit (#152).** A clean primary click/tap on the browser-painted room
|
||||
target fits that room's final floor plus its visible boundary wall body into
|
||||
the middle 80% of the stage. Devices, openings/actions and the HA Area link
|
||||
keep their own gesture; the non-interactive room-label surface maps to the
|
||||
same room id. The action uses the existing camera transition controller and
|
||||
does not write the per-space zoom preference. Its session-only room intent
|
||||
is reapplied atomically on a stable stage resize, then cleared by manual
|
||||
camera input, Fit all/home, mode/space/projection changes, structural
|
||||
adoption, hidden state or disconnect. In kiosk, room-owned taps never enter
|
||||
the free-background double-tap sequence.
|
||||
* **Free-background fit (#449).** Two clean primary clicks/taps within 350 ms
|
||||
on the stage background invoke the same Fit all command in View and kiosk.
|
||||
Room, device, vacuum, opening, link and control paths disarm the sequence;
|
||||
so do pan, pinch, swipe, long press, cancellation, editor/mode/space/projection
|
||||
changes and lifecycle adoption. The new recognizer adds no timer or render on
|
||||
the first tap. Mouse, touch and pen sequences are separate; editors do not
|
||||
expose this shortcut.
|
||||
* **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
|
||||
@@ -292,7 +310,7 @@ produce a zero-sized SVG `viewBox`.
|
||||
'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.
|
||||
nothing, so a clean free-background double-tap remains available.
|
||||
* **"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
|
||||
|
||||
@@ -2,6 +2,72 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Large plans now keep their heavy room, wall, lighting, decor and device scene
|
||||
stable during hover, pan, pinch and editor drags, and unrelated Home Assistant
|
||||
state updates no longer rebuild the full card; interactions remain visually
|
||||
and functionally unchanged while responding much more smoothly
|
||||
([#451](https://github.com/Matysh/houseplan-card/issues/451)).
|
||||
- Double-clicking or double-tapping free plan background in View or kiosk now
|
||||
runs the same **Fit all** action as the toolbar button; rooms, devices,
|
||||
openings and editor surfaces keep their own gestures
|
||||
([#449](https://github.com/Matysh/houseplan-card/issues/449)).
|
||||
- Zigbee2MQTT topology refresh now accepts the real camelCase raw network-map
|
||||
fields (while retaining snake_case compatibility), so valid maps populate
|
||||
devices and links instead of failing as unsupported data
|
||||
([#450](https://github.com/Matysh/houseplan-card/issues/450)).
|
||||
|
||||
## v1.72.0-beta.1 — 2026-09-04
|
||||
|
||||
- Hidden experiments now use one indefinite browser-local switch:
|
||||
`hp_alpha=1` enables the complete alpha set and restores the existing 3-D
|
||||
View, while `hp_alpha=0` disables it. Previous `hp-labs=iso` state is not
|
||||
migrated, so testers must enable alpha again
|
||||
([#448](https://github.com/Matysh/houseplan-card/issues/448)).
|
||||
- Administrators can optionally load a ZHA or Zigbee2MQTT topology snapshot and
|
||||
see only the observed direct neighbours of the device currently under a real
|
||||
mouse pointer. The diagnostic layer is off by default, never scans on hover,
|
||||
counts rather than draws cross-space links, and stays absent from touch,
|
||||
kiosk, editors and the static card
|
||||
([#54](https://github.com/Matysh/houseplan-card/issues/54)).
|
||||
- In View and kiosk, clicking or tapping a room now centres it and fits its
|
||||
visible floor and boundary walls with 10% screen margins; a visible room
|
||||
label offers the same action from the keyboard
|
||||
([#152](https://github.com/Matysh/houseplan-card/issues/152)).
|
||||
- Furniture can now snap to either physical face of an exterior wall without
|
||||
jumping through the masonry, and Arrow keys move any selected Background
|
||||
object by exactly one current grid cell without re-running wall or decor
|
||||
magnets ([#447](https://github.com/Matysh/houseplan-card/issues/447)).
|
||||
|
||||
## v1.71.0 — 2026-09-04
|
||||
|
||||
- The Background editor can now upload and reuse private PNG, JPEG, WebP and
|
||||
safe SVG images, then place, resize, mirror, rotate, replace and layer them
|
||||
like furniture without wall magnetism; missing files keep a repairable
|
||||
placeholder and portable exports retain integrity metadata without embedding
|
||||
image bytes ([#51](https://github.com/Matysh/houseplan-card/issues/51)).
|
||||
- Robot vacuums with several maps can now assign and calibrate each map against
|
||||
its own floor, route the live position and trail there while leaving the dock
|
||||
in place, and explain unassigned or ambiguous active maps instead of guessing
|
||||
where to draw them
|
||||
([#162](https://github.com/Matysh/houseplan-card/issues/162)).
|
||||
- General settings can hide the room information window without disabling its
|
||||
highlight, furniture snaps to the visible physical wall face, and the HACS
|
||||
catalog now names the integration simply **House Plan**
|
||||
([#426](https://github.com/Matysh/houseplan-card/issues/426),
|
||||
[#445](https://github.com/Matysh/houseplan-card/issues/445),
|
||||
[#444](https://github.com/Matysh/houseplan-card/issues/444)).
|
||||
- Confirmation and **Help & feedback** flows now fail safely across missing
|
||||
spaces, delayed previews, edited retries, mixed compatible versions and
|
||||
malformed nested data; Home Assistant Area tracking and rejected General,
|
||||
device or calibration saves also preserve the last confirmed plan
|
||||
([#417](https://github.com/Matysh/houseplan-card/issues/417),
|
||||
[#418](https://github.com/Matysh/houseplan-card/issues/418),
|
||||
[#419](https://github.com/Matysh/houseplan-card/issues/419),
|
||||
[#420](https://github.com/Matysh/houseplan-card/issues/420),
|
||||
[#423](https://github.com/Matysh/houseplan-card/issues/423),
|
||||
[#439](https://github.com/Matysh/houseplan-card/issues/439),
|
||||
[#442](https://github.com/Matysh/houseplan-card/issues/442)).
|
||||
|
||||
## v1.71.0-beta.4 — 2026-09-04
|
||||
|
||||
- Furniture now snaps its back edge to the visible physical wall surface rather
|
||||
|
||||
+70
-1
@@ -8,6 +8,75 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- На больших планах тяжёлая сцена комнат, стен, света, подложки и устройств
|
||||
теперь остаётся неизменной во время hover, pan, pinch и перетаскиваний в
|
||||
редакторах, а посторонние обновления Home Assistant больше не перестраивают
|
||||
карточку целиком; внешний вид и результат действий не меняются, но отклик
|
||||
становится заметно плавнее
|
||||
([#451](https://github.com/Matysh/houseplan-card/issues/451)).
|
||||
- Двойной клик или двойной тап по свободному фону плана в Просмотре и киоске
|
||||
теперь выполняет то же действие **«Вписать всё»**, что кнопка в панели;
|
||||
комнаты, устройства, проёмы и редакторы сохраняют собственные жесты
|
||||
([#449](https://github.com/Matysh/houseplan-card/issues/449)).
|
||||
- Обновление топологии Zigbee2MQTT теперь понимает реальные camelCase-поля
|
||||
raw-карты сети, сохраняя совместимость со snake_case: корректная карта
|
||||
заполняет устройства и связи вместо ошибки неподдерживаемых данных
|
||||
([#450](https://github.com/Matysh/houseplan-card/issues/450)).
|
||||
|
||||
## v1.72.0-beta.1 — 2026-09-04
|
||||
|
||||
- Скрытые эксперименты теперь включаются одним бессрочным локальным ключом
|
||||
браузера: `hp_alpha=1` активирует весь alpha-набор и возвращает существующий
|
||||
3-D Просмотр, а `hp_alpha=0` выключает его. Прежнее состояние
|
||||
`hp-labs=iso` не переносится, поэтому тестировщикам нужно включить alpha
|
||||
заново ([#448](https://github.com/Matysh/houseplan-card/issues/448)).
|
||||
- Администратор теперь может явно загрузить снимок топологии ZHA или
|
||||
Zigbee2MQTT и при наведении реальной мыши видеть только наблюдаемых прямых
|
||||
соседей выбранного устройства. Диагностика по умолчанию выключена, никогда не
|
||||
запускает сканирование от наведения, считает вместо отрисовки связи с другими
|
||||
пространствами и не появляется на touch, в киоске, редакторах и статической
|
||||
карточке ([#54](https://github.com/Matysh/houseplan-card/issues/54)).
|
||||
- В Просмотре и киоске клик или тап по комнате теперь центрирует и вписывает
|
||||
её видимый пол и ограничивающие стены с полями 10%; видимая подпись комнаты
|
||||
даёт то же действие с клавиатуры
|
||||
([#152](https://github.com/Matysh/houseplan-card/issues/152)).
|
||||
- Мебель теперь может примагничиваться к обеим физическим граням наружной стены
|
||||
и не проваливается сквозь кладку, а стрелки сдвигают любой выбранный объект
|
||||
подложки ровно на одну текущую клетку без повторного магнита стен или декора
|
||||
([#447](https://github.com/Matysh/houseplan-card/issues/447)).
|
||||
|
||||
## v1.71.0 — 2026-09-04
|
||||
|
||||
- В редакторе подложки теперь можно загружать и повторно использовать закрытые
|
||||
PNG, JPEG, WebP и безопасные SVG, а затем размещать, менять размер, отражать,
|
||||
вращать, заменять и раскладывать их по слоям как мебель, но без магнита к
|
||||
стенам; для пропавшего файла сохраняется восстанавливаемая рамка, а переносной
|
||||
экспорт хранит данные целостности без встраивания байтов
|
||||
([#51](https://github.com/Matysh/houseplan-card/issues/51)).
|
||||
- Роботу с несколькими картами теперь можно сопоставить и откалибровать каждую
|
||||
карту на своём этаже: живая позиция и след рисуются там, а док остаётся на
|
||||
месте; для несопоставленной или неоднозначной активной карты план показывает
|
||||
точную причину вместо попытки угадать этаж
|
||||
([#162](https://github.com/Matysh/houseplan-card/issues/162)).
|
||||
- В общих настройках можно скрыть информационное окно комнаты, не отключая её
|
||||
подсветку; мебель примагничивается к видимой физической грани стены, а в
|
||||
каталоге HACS интеграция теперь называется просто **House Plan**
|
||||
([#426](https://github.com/Matysh/houseplan-card/issues/426),
|
||||
[#445](https://github.com/Matysh/houseplan-card/issues/445),
|
||||
[#444](https://github.com/Matysh/houseplan-card/issues/444)).
|
||||
- Подтверждения и **«Помощь и обратная связь»** теперь безопасно завершаются при
|
||||
пропавшем пространстве, позднем превью, изменённом повторе, совместимых
|
||||
смешанных версиях и некорректных вложенных данных; отслеживание HA-зон и
|
||||
отклонённые сохранения общих настроек, устройства или калибровки также
|
||||
сохраняют последний подтверждённый план
|
||||
([#417](https://github.com/Matysh/houseplan-card/issues/417),
|
||||
[#418](https://github.com/Matysh/houseplan-card/issues/418),
|
||||
[#419](https://github.com/Matysh/houseplan-card/issues/419),
|
||||
[#420](https://github.com/Matysh/houseplan-card/issues/420),
|
||||
[#423](https://github.com/Matysh/houseplan-card/issues/423),
|
||||
[#439](https://github.com/Matysh/houseplan-card/issues/439),
|
||||
[#442](https://github.com/Matysh/houseplan-card/issues/442)).
|
||||
|
||||
## v1.71.0-beta.4 — 2026-09-04
|
||||
|
||||
- Мебель теперь примагничивается задней гранью к видимой физической поверхности
|
||||
@@ -214,7 +283,7 @@
|
||||
|
||||
## v1.70.0-beta.1 — 2026-08-30
|
||||
|
||||
- Колесо, кнопки масштаба, «Показать всё», стрелка возврата и двойной тап в
|
||||
- Колесо, кнопки масштаба, «Вписать всё», стрелка возврата и двойной тап в
|
||||
киоске теперь плавно перемещают камеру за короткую анимацию. Щипок и
|
||||
панорамирование остаются непосредственными, а при системном уменьшении
|
||||
движения масштаб меняется мгновенно
|
||||
|
||||
@@ -77,6 +77,21 @@ new frontend restores the disabled behavior after upgrade. Full backup/import
|
||||
preserves the setting and the privacy-safe support projection includes only a
|
||||
validated boolean.
|
||||
|
||||
## Contextual Zigbee topology (#54)
|
||||
|
||||
`settings.zigbee_topology` is an optional global object. Only exact
|
||||
`enabled: true` activates the admin-only full-card View layer; absence,
|
||||
malformed values and older configs are off. `z2m_base_topics` is a bounded list
|
||||
of exact MQTT base topics: empty entries, wildcards, duplicates and control
|
||||
characters are dropped. Disabling the feature retains valid topics; resetting
|
||||
General settings removes the object.
|
||||
|
||||
Provider snapshots, IEEE addresses, links, errors and timestamps are runtime
|
||||
memory only. They are not config fields, do not enter backup/export or support
|
||||
diagnostics, and disappear with the HA connection/page. Older frontends ignore
|
||||
the settings object; the backend's unknown-settings policy preserves it. No
|
||||
model or store version migration is required.
|
||||
|
||||
## Vacuum map routes (#162)
|
||||
|
||||
`marker.vacuum.map_routes` is an optional array of
|
||||
|
||||
@@ -16,7 +16,7 @@ light routing, device state or Home Assistant actions.
|
||||
| Physical values | Stroke and text sizes are stored in centimetres; the UI shows cm/in for small values and m/ft for object dimensions. |
|
||||
| Undo | Decor and backdrop use the same named 50-command stack as plan geometry. |
|
||||
| Cancel | `Esc` restores the state at the start of an active drag/draw/resize/rotate gesture. A completed gesture is reverted with Undo. |
|
||||
| Selection | One click selects; drag moves; the selected object has one common transform frame; double click opens all editable properties. |
|
||||
| Selection | One click selects; drag moves; Arrow keys move the selected decor by one visible grid cell without re-running magnets; the selected object has one common transform frame; double click opens all editable properties. |
|
||||
| Scale | Corner drag preserves aspect ratio. Hold `Shift` for independent axes. Furniture and custom images also have four one-axis middle handles and may cross the fixed edge to mirror. |
|
||||
| Rotation | Ordinary decor uses 5° steps by default and `Shift` for free rotation. Furniture and custom images are free by default and `Shift` snaps to 45°. Lines use endpoint handles instead of a rotation handle. |
|
||||
| Magnet targets | Only other decor objects and room contours: corners, edge centres, centres and edges. The image, devices and openings are excluded. |
|
||||
@@ -114,6 +114,13 @@ shared history. `Ctrl+Shift+Z` and `Ctrl+Y` obey the same transaction boundary:
|
||||
the first invocation cancels a live gesture, the next redoes. Native text-field
|
||||
history wins while focus is inside an input.
|
||||
|
||||
With **Select** active, each Arrow key moves any selected line, shape, text,
|
||||
furniture or custom image by one current grid cell along the canvas axes.
|
||||
`Shift` does not accelerate this command. The move preserves any existing
|
||||
off-grid remainder and deliberately bypasses decor and wall magnets, so it can
|
||||
fine-tune furniture away from a wall. Each keydown is one named Undo step;
|
||||
controls, dialogs and live pointer gestures keep their own Arrow behaviour.
|
||||
|
||||
## Code ownership
|
||||
|
||||
| Concern | File |
|
||||
|
||||
+13
-11
@@ -119,18 +119,20 @@ git config core.untrackedCache true
|
||||
|
||||
Labs is an internal, presentation-only runtime in `src/labs.ts`; it must never
|
||||
gate config/schema migrations, persistence writes, HA services or network
|
||||
requests. Enable the current experiment with `?hp-labs=iso` (or
|
||||
`#hp-labs=iso&space=<id>`), remove it with `-iso`, and clear all flags with
|
||||
`hp-labs=off`. Known URL operations persist to
|
||||
`houseplan_card_labs_v1` without rewriting the URL. Active flags are exposed for
|
||||
diagnostics as the frozen sorted `window.__hpLabs` array.
|
||||
requests. `?hp_alpha=1` or `#hp_alpha=1&space=<id>` enables every experimental
|
||||
capability in the current build and persists `1` in
|
||||
`houseplan_card_alpha_v1`; `hp_alpha=0` disables them and persists `0`. Query is
|
||||
applied before hash and the last exact `1`/`0` wins. The URL is not rewritten,
|
||||
unknown values fail closed for the current resolution, and the legacy
|
||||
`hp-labs`/`houseplan_card_labs_v1` inputs are not read or migrated. Diagnostics
|
||||
expose the boolean `window.__hpAlpha` together with the frozen sorted
|
||||
`window.__hpLabs` capability array.
|
||||
|
||||
To add a flag, add one unique lowercase id plus issue, `since`, `expires` and a
|
||||
non-empty summary to `LABS_FLAGS`, then cover URL/storage ordering, runtime
|
||||
subscription and expiry. Versions compare the numeric `major.minor.patch` core:
|
||||
at `expires` (including its first beta) the flag is dead and every activation
|
||||
path fails closed. Remove or graduate a flag before its expiry cycle; do not
|
||||
extend an expiry silently. See `docs/ISOMETRIC.md` for the current Stage 1 use.
|
||||
To add a capability, add one unique lowercase id plus issue and a non-empty
|
||||
summary to `LABS_FLAGS`, then cover registry validation and the alpha-on active
|
||||
set. Capabilities have no individual public key or version lifetime: the one
|
||||
persisted alpha switch is deliberately indefinite until the owner changes the
|
||||
contract. See `docs/ISOMETRIC.md` for the current use.
|
||||
|
||||
These commands are read-only diagnostics, not release gates:
|
||||
|
||||
|
||||
+9
-6
@@ -114,12 +114,15 @@ Consequently:
|
||||
nothing instead of breaking the plan.
|
||||
|
||||
The top edge of every drawing is BACK (`y = 0`). Placement and dragging put
|
||||
BACK on the room-facing **physical surface** of a wall, including the local
|
||||
atomic half-thickness; the invisible centreline is not the contact surface.
|
||||
On a shared wall the raw pointer side selects the room, while an exact-axis
|
||||
drag keeps the piece's current side. The magnet reach is measured from the
|
||||
physical surface. `Shift` keeps free placement and bypasses the wall magnet.
|
||||
Preview, commit and drag share the same surface resolver. Furniture is decor:
|
||||
BACK on a **physical surface** of a wall, including the local atomic
|
||||
half-thickness; the invisible centreline is not the contact surface. An outer
|
||||
wall exposes both its room-facing and exterior surfaces, so raw pointer intent
|
||||
keeps furniture inside or outside instead of pulling it through the masonry.
|
||||
On a shared wall the raw pointer side selects the room. A new placement exactly
|
||||
on an outer-wall axis defaults inside, while an exact-axis drag keeps the
|
||||
piece's current side. The magnet reach is measured from the selected physical
|
||||
surface. `Shift` keeps free placement and bypasses the wall magnet. Preview,
|
||||
commit and drag share the same surface resolver. Furniture is decor:
|
||||
it has no entity, state, room aggregation, collision model or automatic
|
||||
binding to later wall edits, and already saved coordinates are never migrated.
|
||||
|
||||
|
||||
+23
-13
@@ -5,23 +5,26 @@ hidden, presentation-only volumetric View experiment. The normative contract is
|
||||
`docs/specs/089-isometric-view-stage1.md`; the fixed rendering decisions are in
|
||||
`docs/adr/089-isometric-stage1-renderer.md`.
|
||||
|
||||
## Activation and lifetime
|
||||
## Activation
|
||||
|
||||
The feature exists only while the `iso` entry in `src/labs.ts` is live. For the
|
||||
v1.62 cycle it can be enabled with either `?hp-labs=iso` or
|
||||
`#hp-labs=iso&space=<id>`. Query operations are applied first and hash operations
|
||||
second. `-iso` removes this flag and `off` clears every Labs flag. A known URL
|
||||
operation is persisted in `houseplan_card_labs_v1`; the URL itself is not
|
||||
rewritten.
|
||||
The feature belongs to the single hidden alpha set in `src/labs.ts`. Enable all
|
||||
experiments in the current build with either `?hp_alpha=1` or
|
||||
`#hp_alpha=1&space=<id>`; disable them with `hp_alpha=0`. Query operations are
|
||||
applied first, hash operations second, and the last exact `1`/`0` wins. A known
|
||||
operation is persisted as that exact string in `houseplan_card_alpha_v1`; the
|
||||
URL itself is not rewritten. Unknown values fail closed for the current
|
||||
resolution and do not overwrite storage.
|
||||
|
||||
The selected presentation is stored per space in `houseplan_card_view_v1`. Flat
|
||||
is always the initial default. Kiosk has no toggle but reads the saved preference.
|
||||
Editors and `houseplan-space-card` are always flat.
|
||||
|
||||
The current registry entry is live from numeric core `1.62.0` and expires at
|
||||
`1.65.0`. Thus `1.65.0-beta.1` is already expired. Malformed versions, malformed
|
||||
entries and duplicate ids fail closed. Labs never gate schemas, migrations,
|
||||
stores, service calls or network requests.
|
||||
The switch has no version expiry and enables the complete capability set known
|
||||
to the installed build. The legacy `hp-labs` URL and
|
||||
`houseplan_card_labs_v1` storage are not read or migrated, so former testers
|
||||
must enable `hp_alpha` once. Malformed registry entries and duplicate ids fail
|
||||
closed. Alpha never gates schemas, migrations, plan stores, service calls or
|
||||
network requests.
|
||||
|
||||
## Coordinate systems
|
||||
|
||||
@@ -45,6 +48,12 @@ preserves scalar zoom and converts the view centre through logical floor space.
|
||||
`projectedFrame()` includes both floor corners and wall-top corners so fit/home
|
||||
cannot clip the volume.
|
||||
|
||||
Room fit (#152) projects every final floor vertex at floor and floor-edge depth
|
||||
and every selected-room boundary-wall vertex at floor and wall-top height before
|
||||
building the AABB. It never projects a plan-space bounding rectangle, so a
|
||||
concave room does not gain fictitious corners. The resulting camera still uses
|
||||
the shared View controller; changing projection cancels the room-focus intent.
|
||||
|
||||
## Geometry and composition
|
||||
|
||||
`src/iso-projection.ts` owns pure projection math. `src/iso-walls.ts` consumes
|
||||
@@ -101,8 +110,9 @@ the exact Linux CI SHA.
|
||||
Stage 2 evolves the same hidden `iso` experiment; it does not add a flag,
|
||||
setting or public activation path. The accepted implementation contract is
|
||||
`docs/specs/122-isometric-stage2.md` and the fixed composition decisions are in
|
||||
`docs/adr/122-isometric-stage2-composition.md`. The Labs `since: 1.62.0` and
|
||||
exclusive `expires: 1.65.0` boundary are unchanged.
|
||||
`docs/adr/122-isometric-stage2-composition.md`. Its historical per-feature
|
||||
lifetime was superseded by the single indefinite `hp_alpha` gate in #448; the
|
||||
Stage 2 rendering contract itself is unchanged.
|
||||
|
||||
### One structural scene, live opening leaves
|
||||
|
||||
|
||||
+23
-9
@@ -1,18 +1,32 @@
|
||||
<!-- release: v1.71.0-beta.4 -->
|
||||
<!-- release: v1.72.0-beta.1 -->
|
||||
|
||||
## Основное
|
||||
|
||||
- Вторую и последующие карты робота теперь можно безопасно добавить после выбора этажа; пустой список маршрутов остаётся пустым, а удалённые пространства и лишний обход устройств обрабатываются корректно ([#441](https://github.com/Matysh/houseplan-card/issues/441), [#443](https://github.com/Matysh/houseplan-card/issues/443)).
|
||||
- Если Home Assistant отклоняет сохранение устройства или калибровки робота, план атомарно возвращается к подтверждённому состоянию, сохраняя открытый редактор и введённый черновик для повтора ([#442](https://github.com/Matysh/houseplan-card/issues/442)).
|
||||
- Мебель теперь примагничивается к видимой физической поверхности стены с учётом локальной толщины и стороны общей стены, а не проваливается в кладку из-за привязки к оси ([#445](https://github.com/Matysh/houseplan-card/issues/445)).
|
||||
- Скрытый 3-D Просмотр снова доступен тестировщикам через единый бессрочный
|
||||
ключ `hp_alpha=1`; старое включение `hp-labs=iso` намеренно не переносится
|
||||
([#448](https://github.com/Matysh/houseplan-card/issues/448)).
|
||||
- В Просмотре и киоске клик или тап по комнате теперь центрирует её и вписывает
|
||||
видимый пол и стены с полями 10%, а подпись даёт то же действие с клавиатуры;
|
||||
мебель примагничивается к обеим физическим граням наружной стены, а стрелки
|
||||
сдвигают выбранный объект подложки ровно на одну клетку
|
||||
([#152](https://github.com/Matysh/houseplan-card/issues/152),
|
||||
[#447](https://github.com/Matysh/houseplan-card/issues/447)).
|
||||
- Администратор может включить диагностику Zigbee-сети и при наведении видеть только прямые связи выбранного устройства из снимка ZHA или Zigbee2MQTT, не закрывая план полной сеткой ([#54](https://github.com/Matysh/houseplan-card/issues/54)).
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## Highlights
|
||||
|
||||
- A robot vacuum's second and later maps can now be added safely after choosing a floor; an empty route list stays empty, while deleted spaces and unnecessary device scans are handled correctly ([#441](https://github.com/Matysh/houseplan-card/issues/441), [#443](https://github.com/Matysh/houseplan-card/issues/443)).
|
||||
- If Home Assistant rejects a device or robot-calibration save, the plan atomically restores its confirmed state while keeping the editor and entered draft ready for another attempt ([#442](https://github.com/Matysh/houseplan-card/issues/442)).
|
||||
- Furniture now snaps to the visible physical wall surface with the local thickness and shared-wall side taken into account, instead of entering the masonry through centreline snapping ([#445](https://github.com/Matysh/houseplan-card/issues/445)).
|
||||
- Hidden 3-D View is available to testers again through the single indefinite
|
||||
`hp_alpha=1` switch; the former `hp-labs=iso` state is intentionally not
|
||||
migrated ([#448](https://github.com/Matysh/houseplan-card/issues/448)).
|
||||
- In View and kiosk, clicking or tapping a room now centres it and fits its
|
||||
visible floor and walls with 10% margins, while the label offers the same
|
||||
keyboard action; furniture snaps to either physical face of an exterior wall,
|
||||
and Arrow keys move a selected Background object by exactly one grid cell
|
||||
([#152](https://github.com/Matysh/houseplan-card/issues/152),
|
||||
[#447](https://github.com/Matysh/houseplan-card/issues/447)).
|
||||
- Administrators can enable Zigbee diagnostics and hover a device to see only its direct links from a ZHA or Zigbee2MQTT snapshot, without covering the plan with the full mesh ([#54](https://github.com/Matysh/houseplan-card/issues/54)).
|
||||
- Small fixes and improvements.
|
||||
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.71.0-beta.4/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.71.0-beta.4/docs/CHANGELOG.md)
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.72.0-beta.1/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.72.0-beta.1/docs/CHANGELOG.md)
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ deliberate degradation is allowed under `TOUCH-SUPPORT.md`.
|
||||
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, filtered auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
|
||||
| J5 | "Room climate at a glance" — per-room temperature/humidity, comfort-range fills, room-card metrics | **Closed** |
|
||||
| J6 | "Keep the plan true as the home evolves" — new-device flag, two editors, drag/resize, merge/split, multi-client live sync, optimistic locking | **Closed** |
|
||||
| J7 | "Is my Zigbee mesh healthy *here*?" — LQI badges, per-room average, LQI fill | **Closed** (kept deliberately: cheap, spatial by nature, no in-plan competitor) |
|
||||
| J7 | "Is my Zigbee mesh healthy *here*?" — LQI badges, per-room average/fill and opt-in direct-neighbour links for one hovered device | **Closed** (spatial diagnostics; no persistent full-mesh graph) |
|
||||
|
||||
## Partially covered — improvement backlog stays inside these
|
||||
|
||||
|
||||
+2
-2
@@ -21,8 +21,8 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.71.0-beta.4** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| Current local cycle | v1.71.0-beta.4 fixes adding and removing multi-floor robot routes (#441, #443), atomically rolls rejected device and robot-calibration writes back to server-confirmed state (#442), snaps furniture to the physical wall face (#445), and improves the HACS storefront copy (#444). Publish only after exact-SHA Validate is green. |
|
||||
| Version | **v1.72.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| Current local cycle | v1.72.0-beta.1 adds contextual per-device Zigbee topology diagnostics (#54), click/tap room fitting with 10% margins (#152), exterior wall-face furniture snapping and exact one-cell keyboard nudging for Background objects (#447). Publish from `dev` only after exact-SHA Validate is green; `main` remains at v1.71.0. |
|
||||
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
|
||||
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first); every bullet links its GitHub issue (#NN) so the #328 rules stay machine-checkable. A STABLE body aggregates the changelog since the PREVIOUS STABLE release (never since the last beta): features/fixes described across the line's beta changelogs must appear, while bugs that were introduced and fixed strictly inside the beta line (never shipped in any stable) are excluded — draft with `npm run release:notes -- <tag>`, curate by hand, then `npm run release:notes -- <tag> --verify` must pass. `Мелкие исправления и улучшения` / `Small fixes and improvements` is allowed only when the range really contains user-visible work not itemised in the body; a single-issue hotfix ships without it (the verifier enforces this). Every body ends with separate links to the Russian and English changelogs. Open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
|
||||
@@ -25,6 +25,11 @@ URLs, paths, filenames, plan/backdrop/manual bytes, vacuum calibration and
|
||||
trails, backup history, message and contact. Unknown fields are dropped rather
|
||||
than copied and redacted later.
|
||||
|
||||
The Zigbee topology snapshot is never offered to this package: raw provider
|
||||
payloads, IEEE addresses, neighbor links, base topics, timestamps and provider
|
||||
errors stay out of support preview/download/submission and out of browser
|
||||
storage and logs.
|
||||
|
||||
## Preview and authorization
|
||||
|
||||
Only a Home Assistant user allowed to write House Plan can build or send a
|
||||
|
||||
@@ -3706,6 +3706,31 @@ require hands on real hardware — they remain for the human pass.
|
||||
Целочисленность обрезки — единственная часть съёмки, которую можно проверить без
|
||||
браузера: `node --test test/capture-clip.test.mjs`.
|
||||
|
||||
## Вписывание выбранной комнаты (#152)
|
||||
|
||||
- [ ] В View чистый mouse click и одиночный touch tap по полу простой,
|
||||
вогнутой и вложенной комнаты центрируют именно browser-target и помещают
|
||||
её видимый пол вместе с ограничивающими стенами в центральные 80% stage.
|
||||
- [ ] В Flat и Iso ограничивающая ось даёт поля `10% ± 1 CSS px`; устройства,
|
||||
badges, room label, Glow, солнце, подложка и декор bounds не расширяют.
|
||||
- [ ] Device/capsule, opening/lock/action, vacuum и HA Area link выполняют только
|
||||
своё действие; pan выше click threshold, pinch и long press не запускают
|
||||
room fit, а движение ниже threshold остаётся tap.
|
||||
- [ ] В kiosk два tap по комнате не запускают Fit all и не меняют background
|
||||
double-tap sequence; два tap по свободному фону сохраняют прежний reset.
|
||||
- [ ] Быстрые клики разных комнат retarget-ят один camera controller; повторный
|
||||
fit уже вписанной комнаты не создаёт RAF и не пишет `LS_ZOOM`.
|
||||
- [ ] Stable resize атомарно пересчитывает активную комнату. Wheel, zoom buttons,
|
||||
Fit all/home, pan/pinch, mode/space/projection, hidden/disconnect и новая
|
||||
structural snapshot снимают intent.
|
||||
- [ ] Видимая подпись имеет один `role=button`/tab stop, локализованное имя и
|
||||
`:focus-visible`; Enter/Space вызывают тот же fit и не двигают focus.
|
||||
Скрытая/безымянная подпись не создаёт невидимый tab stop.
|
||||
- [ ] Pure proofs: `node --test test/room-fit.test.mjs`; полный cycle:
|
||||
`npm run typecheck`, `npm test`, `npm run build`. Browser matrix и
|
||||
мутации room ownership и session-only zoom persistence выполняются перед
|
||||
бетой.
|
||||
|
||||
## Ядра фронтенда растут только осознанно (#425, заменяет #34)
|
||||
|
||||
- [ ] `node --test test/core-file-budget.test.mjs` — потолки на
|
||||
@@ -3719,3 +3744,12 @@ require hands on real hardware — they remain for the human pass.
|
||||
Вторая половина правила (уменьшение ниже потолка на 250 строк тоже краснеет)
|
||||
нужна затем, что без неё вынос двух тысяч строк ничего не меняет: потолок
|
||||
остаётся прежним, и ядро дорастает до него обратно «в рамках бюджета».
|
||||
|
||||
## Реальная raw-карта Zigbee2MQTT (#450)
|
||||
|
||||
- [ ] **Обновить карту** на Zigbee2MQTT 1.x/2.x принимает camelCase-поля
|
||||
`ieeeAddr` / `networkAddress` и показывает связи вместо
|
||||
`error_invalid_payload`; сохранена совместимость со snake_case.
|
||||
- [ ] Плоские `sourceIeeeAddr` / `targetIeeeAddr` имеют приоритет над
|
||||
вложенными концами связи, а массив `failed` не превращается в ложную
|
||||
недоступность устройства [unit + production-shaped fixture + mutation].
|
||||
|
||||
@@ -28,6 +28,8 @@ On phones, tablets, wall panels and HA Companion apps, the ordinary View must:
|
||||
|
||||
- render the plan and current states correctly;
|
||||
- support convenient pan, pinch zoom and space switching;
|
||||
- fit the whole plan after two clean taps on free scene background, while one
|
||||
tap on a room keeps the immediate room-fit action;
|
||||
- provide a touch path for essential information that desktop exposes through
|
||||
hover;
|
||||
- open and close View dialogs without clipping their essential content or
|
||||
|
||||
+19
-3
@@ -201,6 +201,20 @@ but hide the floating room summary, turn off **General settings → Show the roo
|
||||
information window on hover**. The option is on by default and does not affect
|
||||
device tooltips.
|
||||
|
||||
For an occasional Zigbee placement check, an administrator can enable
|
||||
**General settings → Show Zigbee links when hovering over a device**. The option
|
||||
is off by default. Load the provider snapshot there: **Read ZHA data** reads
|
||||
ZHA's existing cache, while **Update map** starts an explicit Zigbee2MQTT raw
|
||||
network-map scan for each entered base topic (default `zigbee2mqtt`). The latter
|
||||
may take 10 seconds to 2 minutes and can temporarily slow the Zigbee network.
|
||||
|
||||
After data is loaded, moving a real mouse over a mapped Zigbee marker shows
|
||||
only its observed direct neighbours. Links to markers on the current space are
|
||||
lines; drawable neighbours on other spaces are summarized as a temporary
|
||||
count. This is a snapshot, not the route currently used by every packet. The
|
||||
layer does not appear on touch/pen, in kiosk, in editors or in the static card,
|
||||
and hovering never starts a scan.
|
||||
|
||||
Each editor has a stable primary toolbar. Tool parameters and selected-object
|
||||
actions appear in a context tray over the top of the canvas. On a narrow screen
|
||||
the tray scrolls horizontally instead of shrinking the plan.
|
||||
@@ -228,7 +242,8 @@ objects; each space keeps its local View viewport.
|
||||
|
||||
| Scenario | Mouse | Touch View | Touch editors | Keyboard |
|
||||
|---|---|---|---|---|
|
||||
| Zoom and pan | Wheel; drag empty space; `−`/`+` | Pinch; drag; double-tap resets kiosk | Available but precision is not guaranteed | — |
|
||||
| Zoom and pan | Wheel; drag empty space; `−`/`+`; double-click free background to Fit all | Pinch; drag; double-tap free background to Fit all | Available but precision is not guaranteed; no Fit-all double-tap | — |
|
||||
| Room | A clean click fits the room with 10% margins | One tap fits the room; repeated room taps never become Fit all | — | `Enter`/`Space` on a visible room label |
|
||||
| Change space | Click a tab | Tap; kiosk swipe at 1:1 | Tap a tab | — |
|
||||
| Device | Click/double-click per mode | Tap; safe actions equal desktop | Drag/properties are best effort | `Esc` closes the top surface |
|
||||
| Walls drawing or precise drag | Full contract | Not applicable | Best effort; use desktop for Resize and exact nodes | `Shift` changes magnet/angle; `Esc` finishes a Walls chain or cancels the current precise drag |
|
||||
@@ -238,8 +253,9 @@ objects; each space keeps its local View viewport.
|
||||
At zoom above 1:1 a horizontal kiosk gesture pans instead of changing space.
|
||||
Any manual kiosk operation pauses auto-cycle for 60 seconds.
|
||||
|
||||
Wheel, `−`/`+`, **Fit all**, the return arrow and kiosk double-tap use a short
|
||||
smooth camera transition. Rapid wheel input changes the current destination
|
||||
Wheel, `−`/`+`, **Fit all**, the return arrow and a free-background
|
||||
double-click/tap in View or kiosk use a short smooth camera transition. Rapid
|
||||
wheel input changes the current destination
|
||||
instead of building a queue. Pinch and pan stay directly under the fingers.
|
||||
With the operating system's reduced-motion preference enabled, every zoom
|
||||
command is immediate.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user