Compare commits

..
Author SHA1 Message Date
claude[bot] cc17109249 docs: review document for #123
Issue: #123
User-Visible: no
2026-08-13 18:55:48 +00:00
Sergey Matyunin e79f8f5aa1 Fix corner split smoke geometry input
Issue: #123
User-Visible: no
2026-08-13 21:48:28 +03:00
claude[bot] 024a1accd8 docs: code review document for #123
Issue: #123
User-Visible: no
2026-08-13 18:45:40 +00:00
Sergey Matyunin 47c6f10a9d Fix corner split exterior walls
Issue: #123
User-Visible: yes
2026-08-13 21:29:34 +03:00
Sergey Matyunin 52ec0fb54f Merge dev into issue/123 branch
Issue: #123
User-Visible: no
2026-08-13 20:57:00 +03:00
claude[bot] bcd280afb9 docs: review document for #123
Issue: #123
User-Visible: no
2026-08-13 17:55:48 +00:00
Sergey Matyunin ba56d4f768 Specify corner split wall geometry
Issue: #123
User-Visible: no
2026-08-13 20:18:35 +03:00
103 changed files with 1153 additions and 8056 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ while read -r local_ref local_sha remote_ref remote_sha; do
echo "process-gate: $local_ref, диапазон ${base}..${local_sha}" >&2
# shellcheck disable=SC2086
if ! node "$gate" --range "${base}..${local_sha}" --target-ref "$remote_ref" $issues_flag >&2; then
if ! node "$gate" --range "${base}..${local_sha}" $issues_flag >&2; then
status=1
fi
done
-61
View File
@@ -1,61 +0,0 @@
name: Mutation gate
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
# защитой, и это хуже его отсутствия.
#
# Прогон дорогой: пересборка бандла на каждого мутанта. Поэтому он не входит в
# Validate и не идёт на каждый push. Его место — перед стабильным релизом
# (PROCESS.md §8) и раз в неделю по расписанию, чтобы дрейф тестов не копился
# до релиза. Дешёвая половина — «якоря патчей живы, guard-файлы существуют» —
# идёт с обычными юнитами: test/mutation-gate.test.mjs.
on:
workflow_dispatch:
schedule:
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
- cron: '20 5 * * 1'
permissions:
contents: read
concurrency:
group: mutation-gate
cancel-in-progress: true
jobs:
mutants:
runs-on: ubuntu-latest
# Шесть мутантов × (сборка + браузерный смок) — это десятки минут, и это
# нормально: гейт предрелизный. Час — потолок против зависшего Chromium.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
ref: dev
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Реестр применим к текущему коду
run: node scripts/mutation-gate.mjs --check
- name: Каждый тест ловит свою поломку
run: node scripts/mutation-gate.mjs
+12 -88
View File
@@ -48,7 +48,6 @@ jobs:
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
SMALL: ${{ contains(github.event.issue.labels.*.name, 'small') }}
TRIVIAL: ${{ contains(github.event.issue.labels.*.name, 'trivial') }}
NUM: ${{ github.event.issue.number }}
run: |
# Этап определяется первым: от него зависит, какие вердикты считать.
@@ -59,9 +58,8 @@ jobs:
*) echo "метка $LABEL конвейер не запускает" ;;
esac
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
limit=4
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
# Лимит циклов: 4 обычный, 2 на лёгком треке (PROCESS.md §4).
limit=4; [ "$SMALL" = "true" ] && limit=2
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
@@ -136,15 +134,8 @@ jobs:
fetch-depth: 0
ref: dev
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
# тяжёлых job, здесь его не было.
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
with: { node-version: 22 }
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
@@ -165,24 +156,6 @@ jobs:
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
fi
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
- name: Установить зависимости
run: npm ci
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
# но когда нужен — качать его заново дороже, чем держать в кэше.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Review
id: review
uses: anthropics/claude-code-action@v1
@@ -232,38 +205,10 @@ jobs:
По каждому AC: либо он доказан автотестом и ты убедился, что тест
умеет падать, либо разобран по коду с явной записью «проверено
чтением, не исполнением». «Verified» без названной команды и её
результата доказательством не является. Зависимости уже установлены
workflow, Chromium тоже — `npm ci` выполнять не нужно. Проверь
трейлеры Issue и User-Visible, при User-Visible: yes — правки в оба
changelog в том же коммите.
**Объём гейтов соразмерен задаче.** Прогонять весь набор на каждой
правке — не тщательность, а потеря времени: полные наборы это
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
Всегда, они дешёвые:
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
копий бандла.
По необходимости, и «необходимость» определяется diff'ом и AC:
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс
относящиеся к тронутым поверхностям. Их 127; прогон всех уместен
только когда задача действительно задевает всё;
- `npm run golden:verify` — если diff может изменить видимый
результат: рендер, геометрия, стили, слои;
- `python -m pytest tests_backend -q` — если тронут
`custom_components/**/*.py`;
- performance-профили — если названы в AC либо тронуты
чувствительные к перфу пути.
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
тем тестам, которые ты прогонял.
**В комментарии обязателен перечень: какие гейты прогнал, какие нет и
почему.** Это условие честности такого сужения: непрогнанный гейт
становится видимым решением, а не молчаливым пропуском. Раздел «чего
не проверял» в документе ревью — не формальность, а главный его
раздел на коротких задачах.
результата доказательством не является. Зависимостей в рабочей
копии нет: перед гейтами выполни `npm ci`. Проверь трейлеры Issue и
User-Visible, при User-Visible: yes — правки в оба changelog в том же
коммите.
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
@@ -304,21 +249,14 @@ jobs:
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
run: |
# Ветки задачи может не быть: у задач, размеченных до появления
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
# комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что
# ничего не сделал. Документ ложится туда же, где лежит само ТЗ.
target="${BRANCH:-dev}"
if [ -z "$BRANCH" ]; then
echo "::warning::ветки задачи нет — документ ревью ляжет в dev"
echo "ветки задачи нет — документ некуда класть"; exit 0
fi
git checkout -- . 2>/dev/null || true
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
git add docs/reviews 2>/dev/null || true
if git diff --cached --quiet; then
echo "::warning::документ ревью не создан"
exit 0
echo "документ ревью не создан"; exit 0
fi
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
@@ -328,23 +266,9 @@ jobs:
Issue: #$NUM
User-Visible: no
EOF
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
# либо dev, поэтому push нужен с явным перебазированием при гонке:
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"; then
git fetch -q origin "$target"
if ! git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
rebase "origin/$target"; then
git rebase --abort || true
echo "::error::документ ревью не удалось опубликовать в $target: конфликт"
exit 0
fi
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"
fi
echo "документ опубликован в $target"
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$BRANCH"
echo "документ опубликован в $BRANCH"
- name: Решение по вердикту
id: decide
-1
View File
@@ -51,7 +51,6 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TARGET_REF: ${{ github.ref }}
# Публичный репозиторий: штатного токена хватает на чтение issue.
GH_TOKEN: ${{ github.token }}
run: |
-9
View File
@@ -41,15 +41,6 @@ of a status and `rejected` on a closed issue. Exactly one `S*` label per open
issue. [GitHub Projects (v2)](https://github.com/users/Matysh/projects/1) is a
human-facing view synchronised from the labels, not the source of truth.
Two shortcuts exist for small work. `small` — the light track: the spec lives in
the issue body and its review is a comment. `trivial` — the short track: no spec
stage at all, `S2-analysis` straight to `S5-ready`, with the AC written into the
issue body first. `trivial` requires a bug confined to one surface with no new UX
contract, no migration, no i18n, no perf or touch impact, at most three checkable
AC, **and expected behaviour already on record** — nothing left to decide. Code
review is never skipped on either track; it is what stands in for testing.
`PROCESS.md` §5 and §5.1 hold the criteria.
An issue filed by an outsider is worked exactly like one of the owner's own, once
the owner has decided to take it. The check sits **at the entrance**, not on every
step: while an issue carries no status label it is outside the process and the
+4 -59
View File
@@ -70,8 +70,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
→ S6-in-progress → S7-code-review ⟲ → S8-merged → закрыт при выпуске беты
служебные: blocked (поверх статуса) rejected (закрыт)
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком треке 2
```
Переходы `S4-spec-review` и `S7-code-review` выполняются **автоматически**: метка
@@ -295,42 +294,6 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
модуль) — метка `small` снимается, issue возвращается в `S3-spec` и получает
нормальный файл ТЗ. Это не провал, это ранняя диагностика.
### 5.1 Короткий трек (метка `trivial`)
Решение владельца 2026-08-13, issue #128. Лёгкий трек делает ТЗ дешёвым; короткий
обходится без него совсем.
**Маршрут:** `S1-new` → `S2-analysis` → `S5-ready` → `S6-in-progress` →
`S7-code-review` → `S8-merged`. Стадии `S3-spec` и `S4-spec-review` пропускаются.
`S2-analysis` остаётся: это комментарий, а не прогон CI, и именно там владелец
решает приоритет и ценность. AC пишет автор в теле issue при переводе в
`S5-ready` — до перехода, иначе ревьюеру нечего будет сверять.
**Критерии, все обязательны:**
- тип `bug`;
- правка ограничена одной поверхностью, нового UX-контракта нет;
- нет миграции конфига, новых ключей i18n, влияния на перф и touch;
- AC выражаются тремя проверяемыми утверждениями или меньше;
- **ожидаемое поведение уже зафиксировано** — в `docs/USER-GUIDE.ru.md`, в
каноническом документе подсистемы либо однозначно в самом отчёте. Решать нечего.
Если есть что решать, это `S3-spec`, и никакая экономия этого не отменяет.
Метка ставится в `S2-analysis` вместе с остальными оценками, одним комментарием,
где владелец утверждает и приоритет.
**Что не упрощается:** issue, оценка, статусы, трейлеры, changelog и **код-ревью**.
Лимит циклов код-ревью — 2, как на лёгком треке.
Если по ходу выясняется, что критерий нарушен, метка снимается и issue уходит в
`S3-spec` за нормальным ТЗ. Как и на лёгком треке, это не провал, а ранняя
диагностика.
**Чем этот трек опасен.** Он убирает единственное место, где решение проверялось
до написания кода. Признак «решать нечего» держит всю конструкцию, и его нельзя
подтверждать ощущением — только ссылкой на уже зафиксированное поведение.
---
## 6. Роли
@@ -468,17 +431,6 @@ npm run golden:verify # если менялся визуал
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
```
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла. По
необходимости, определяемой diff'ом и AC: браузерные смоки (их 127 — прогон всех
уместен только когда задача задевает всё), `golden:verify` при изменении видимого
результата, `pytest tests_backend` при правках в Python, performance-профили при
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
пропуском.
**Гейт беты** (условие закрытия issue): CI Validate зелёный на точном SHA тега.
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
@@ -512,8 +464,8 @@ Project v2 остаётся человеческим представление
| `blocked` | Ждём внешнего или владельца, **поверх** статусной метки |
| `rejected` | Отклонено, issue закрыт |
Модификаторы: `small` (лёгкий трек, сложность ≤3), `trivial` (короткий трек,
§5.1), `hotfix`, `process`, `review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
Модификаторы: `small` (лёгкий трек, сложность ≤3), `hotfix`, `process`,
`review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
ортогональны процессу.
@@ -608,7 +560,7 @@ Project v2 остаётся человеческим представление
{`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`}; закрытый,
недоступный или помеченный `blocked` — отказ (**fail closed**).
Три оговорки к проверке 8 выяснились при реализации.
Две оговорки к проверке 8, обе выяснились при реализации.
**`S8-merged` входит в множество**, хотя по смыслу задача уже принята. Причина
механическая: конвейер (§10.4) сливает ветку в `dev` **раньше**, чем ставит метку,
@@ -621,13 +573,6 @@ Validate стартует от этого push и успевает прочит
документ ревью: он ложится в ветку задачи, пока та в `S4-spec-review` или
`S7-code-review`, то есть заведомо вне рабочего множества.
**При продвижении в `main` не перепроверяются коммиты, уже достижимые из
prerelease-тега.** После выпуска беты их issue по §2.8 должны быть закрыты, а
stable fast-forward снова включает эти коммиты в диапазон `old-main..candidate`.
Pre-push передаёт целевую remote ref через `--target-ref`, а Validate — через
`TARGET_REF`; оба исключают только уже опубликованную prerelease-историю. Любой
post-beta коммит остаётся в проверке и по закрытому issue отклоняется fail-closed.
Не реализовано и остаётся долгом:
9. `npm run release:prerelease -- --issues=…` не проверяет, есть ли у issue
-11
View File
@@ -312,17 +312,6 @@ Always source is deliberately constant-on. With several own `light.*`/`switch.*`
entities, Always also offers a leading-entity selector; a missing saved choice
is warned about and retained while a deterministic fallback is used.
To make that virtual lamp manually switchable without creating a Home
Assistant helper, also choose **Tap action → Toggle state** on the lamp itself.
This exact combination — virtual binding, **Light source → Always**, and
**Toggle state** — stores a shared on/off state in the House Plan integration.
It survives page reloads and Home Assistant restarts and updates Glow, room
fill/statistics, full cards and `houseplan-space-card` together. Any signed-in
dashboard viewer may toggle it. While this manual mode is active, saved
**Controls other light sources** remain intact but are not called; changing the
role, binding or tap action restores their normal behaviour. This operational
state is deliberately not part of plan exports or Home Assistant entities.
The same dialog controls how the device looks on the plan. **Display** switches between the
icon badge, an animated **presence ripple** (pulsing rings while the entity is active, a faint
dot when idle — great for motion sensors) or both, with a per-device ring colour and size. The
+6 -18
View File
@@ -24,12 +24,7 @@ from .const import (
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
from .plans import collect_attachments, collect_plans, sweep_upload_temps
from .repairs import async_check_plan_files
from .store import (
HouseplanConfigEntry,
async_save_config_state,
async_save_layout_state,
create_data,
)
from .store import HouseplanConfigEntry, async_save_layout_state, create_data
_LOGGER = logging.getLogger(__name__)
@@ -65,10 +60,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
await data.config_store.async_load()
except Exception as err: # noqa: BLE001 — corrupt/unreadable .storage
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
try:
await data.virtual_light_store.async_load()
except Exception: # noqa: BLE001 — operational state fails safe to default on
_LOGGER.exception("House Plan: virtual-light storage is not readable; using default on")
entry.runtime_data = data
# server-side vacuum trails: the integration records the path itself
@@ -161,7 +152,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
rev = int(stored.get("rev", 0))
if cfg and migrate_config(cfg): # 2. the config half
rev += 1
await async_save_config_state(data, cfg, rev, previous_rev=rev - 1)
await data.config_store.async_save({"config": cfg, "rev": rev})
migrate_layout(layout, merged) # 3. the layout half + intent cleared
await async_save_layout_state(
data, lay_stored, layout, lay_rev + 1, remove=("geom_pending",)
@@ -195,14 +186,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
"layout_rev", layout_rev + (lay_stored.get("layout", {}) != target_layout)
))
if stored.get("config") != target_config or config_rev < target_config_rev:
previous_config_rev = config_rev
config_rev = max(config_rev, target_config_rev)
await async_save_config_state(
data,
target_config,
config_rev,
previous_rev=previous_config_rev,
)
await data.config_store.async_save({
"config": target_config,
"rev": config_rev,
})
if lay_stored.get("layout", {}) != target_layout or layout_rev < target_layout_rev:
layout_rev = max(layout_rev, target_layout_rev)
exact_metadata = pending.get("final_metadata")
+1 -2
View File
@@ -3,7 +3,6 @@
DOMAIN = "houseplan"
STORAGE_KEY = f"{DOMAIN}.layout"
STORAGE_CONFIG_KEY = f"{DOMAIN}.config"
STORAGE_VIRTUAL_LIGHTS_KEY = f"{DOMAIN}.virtual_lights"
STORAGE_VERSION = 1
STORAGE_MINOR_VERSION = 1
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
@@ -46,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.63.0"
VERSION = "1.63.0-beta.1"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.63.0"
"version": "1.63.0-beta.1"
}
+1 -59
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
@@ -11,17 +10,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from .const import (
DOMAIN,
STORAGE_CONFIG_KEY,
STORAGE_KEY,
STORAGE_MINOR_VERSION,
STORAGE_VERSION,
STORAGE_VIRTUAL_LIGHTS_KEY,
)
_LOGGER = logging.getLogger(__name__)
from .const import DOMAIN, STORAGE_CONFIG_KEY, STORAGE_KEY, STORAGE_MINOR_VERSION, STORAGE_VERSION
class HouseplanStore(Store):
@@ -51,7 +40,6 @@ class HouseplanData:
store: HouseplanStore
config_store: HouseplanStore
virtual_light_store: HouseplanStore
# One lock for every load→modify→save cycle of both stores: prevents
# lost updates from concurrent WS calls and makes the rev check atomic.
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
@@ -83,12 +71,6 @@ def create_data(hass: HomeAssistant) -> HouseplanData:
config_store=HouseplanStore(
hass, STORAGE_VERSION, STORAGE_CONFIG_KEY, minor_version=STORAGE_MINOR_VERSION
),
virtual_light_store=HouseplanStore(
hass,
STORAGE_VERSION,
STORAGE_VIRTUAL_LIGHTS_KEY,
minor_version=STORAGE_MINOR_VERSION,
),
)
@@ -157,43 +139,3 @@ async def async_save_layout_state(
)
await runtime.store.async_save(payload)
return payload
async def async_save_config_state(
runtime: HouseplanData,
config: dict[str, Any],
rev: int,
*,
previous_rev: int | None = None,
) -> dict[str, Any]:
"""Persist configuration and reconcile dependent operational state.
Callers already hold ``runtime.write_lock``. Reading the previous
revision here keeps less common writers (import recovery and undo) on the
same path as ordinary editor saves without duplicating lifecycle rules.
"""
if previous_rev is None:
previous = await runtime.config_store.async_load() or {}
try:
previous_rev = int(previous.get("rev", 0))
except (TypeError, ValueError):
previous_rev = 0
payload = {"config": config, "rev": rev}
await runtime.config_store.async_save(payload)
# The config is already durable at this point. Reconciliation remains a
# separate Store write; an interrupted pair is detected from config_rev on
# the next read and fails safe to the compatibility default (all on).
from .virtual_lights import async_reconcile_virtual_lights
try:
await async_reconcile_virtual_lights(
runtime.virtual_light_store,
config,
rev,
previous_config_rev=previous_rev,
)
except Exception: # noqa: BLE001 - config commit already stands
_LOGGER.exception("House Plan: virtual-light state reconciliation failed")
return payload
@@ -1,125 +0,0 @@
"""Persistent operational state for manual virtual lights."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .store import HouseplanStore
EVENT_VIRTUAL_LIGHT_UPDATED = "houseplan_virtual_light_updated"
def is_manual_virtual_light(marker: Any) -> bool:
"""Return whether a marker uses the exact persistent manual-light mode."""
return (
isinstance(marker, dict)
and isinstance(marker.get("id"), str)
and bool(marker["id"])
and marker.get("binding") == "virtual"
and marker.get("is_light") is True
and marker.get("tap_action") == "toggle"
and marker.get("removed") is not True
)
def eligible_virtual_light_ids(config: Any) -> set[str]:
"""Collect live marker ids eligible for persistent manual state."""
if not isinstance(config, dict):
return set()
markers = config.get("markers")
if not isinstance(markers, list):
return set()
return {marker["id"] for marker in markers if is_manual_virtual_light(marker)}
def _integer(value: Any, default: int = 0) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
return default
return max(0, parsed)
def _read_state(stored: Any) -> tuple[int, int, set[str]]:
if not isinstance(stored, dict):
return 0, 0, set()
raw_off = stored.get("off")
off = (
{item for item in raw_off if isinstance(item, str) and item}
if isinstance(raw_off, list)
else set()
)
return _integer(stored.get("rev")), _integer(stored.get("config_rev")), off
def _wire(rev: int, config_rev: int, off: set[str]) -> dict[str, Any]:
return {"rev": rev, "config_rev": config_rev, "off": sorted(off)}
async def async_virtual_light_snapshot(
store: HouseplanStore,
config: dict[str, Any],
config_rev: int,
) -> dict[str, Any]:
"""Return a coherent snapshot, repairing stale or interrupted state.
A revision gap means an older writer may have changed eligibility without
knowing about this Store. Clearing every manual-off bit is conservative:
it restores the pre-feature/default-on behaviour and cannot resurrect an
old off state for a marker whose role changed in the meantime.
"""
stored = await store.async_load() or {}
rev, state_config_rev, stored_off = _read_state(stored)
eligible = eligible_virtual_light_ids(config)
off = stored_off & eligible if state_config_rev == config_rev else set()
if off != stored_off:
rev += 1
payload = _wire(rev, config_rev, off)
if payload != stored:
await store.async_save(payload)
return payload
async def async_reconcile_virtual_lights(
store: HouseplanStore,
config: dict[str, Any],
config_rev: int,
*,
previous_config_rev: int,
) -> dict[str, Any]:
"""Carry eligible state across one known configuration transition."""
stored = await store.async_load() or {}
rev, state_config_rev, stored_off = _read_state(stored)
eligible = eligible_virtual_light_ids(config)
off = stored_off & eligible if state_config_rev == previous_config_rev else set()
if off != stored_off:
rev += 1
payload = _wire(rev, config_rev, off)
if payload != stored:
await store.async_save(payload)
return payload
async def async_toggle_virtual_light(
store: HouseplanStore,
config: dict[str, Any],
config_rev: int,
marker_id: str,
) -> dict[str, Any] | None:
"""Atomically invert one eligible marker and persist before returning."""
if marker_id not in eligible_virtual_light_ids(config):
return None
snapshot = await async_virtual_light_snapshot(store, config, config_rev)
off = set(snapshot["off"])
if marker_id in off:
off.remove(marker_id)
else:
off.add(marker_id)
payload = _wire(_integer(snapshot["rev"]) + 1, config_rev, off)
await store.async_save(payload)
return {
"marker_id": marker_id,
"on": marker_id not in off,
"rev": payload["rev"],
}
+11 -84
View File
@@ -44,16 +44,10 @@ from .store import (
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
HouseplanData,
async_save_config_state,
async_save_layout_state,
get_data,
get_entry,
)
from .virtual_lights import (
EVENT_VIRTUAL_LIGHT_UPDATED,
async_toggle_virtual_light,
async_virtual_light_snapshot,
)
from .registry_snapshot import import_registry_snapshot
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
@@ -132,7 +126,6 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_layout_update)
websocket_api.async_register_command(hass, ws_layout_delete)
websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_virtual_light_toggle)
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_optimize)
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
@@ -201,11 +194,10 @@ async def _persist_pair_intent(
async def _converge_pair(rt: HouseplanData, pending: dict[str, Any]) -> None:
"""Write both target halves and remove the durable intent last."""
await async_save_config_state(
rt,
pending["config"],
int(pending["config_rev"]),
)
await rt.config_store.async_save({
"config": pending["config"],
"rev": int(pending["config_rev"]),
})
stored = await rt.store.async_load() or {}
await async_save_layout_state(
rt,
@@ -1066,28 +1058,14 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
async with rt.write_lock:
data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
config_rev = int(data.get("rev", 0))
try:
virtual_lights = await async_virtual_light_snapshot(
rt.virtual_light_store,
config,
config_rev,
)
except Exception: # noqa: BLE001 - config remains independently readable
_LOGGER.exception("House Plan: reading virtual-light state failed")
# Never expose a stale off bit after an unreadable/revision-gap
# operational store. Compatibility/default on is the safe frame.
virtual_lights = {"rev": 0, "config_rev": config_rev, "off": []}
data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
connection.send_result(
msg["id"],
{
"config": config,
"rev": config_rev,
"virtual_lights": virtual_lights,
"rev": data.get("rev", 0),
"can_write": may_write(hass, getattr(connection, "user", None)),
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
"undo_kind": _undo_kind(data, layout_data),
@@ -1095,42 +1073,6 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/virtual_light/toggle",
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=500)),
}
)
@websocket_api.async_response
async def ws_virtual_light_toggle(
hass: HomeAssistant, connection, msg: dict[str, Any]
) -> None:
"""Atomically toggle one eligible virtual light for any signed-in user."""
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
async with rt.write_lock:
data = await rt.config_store.async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
result = await async_toggle_virtual_light(
rt.virtual_light_store,
config,
int(data.get("rev", 0)),
msg["marker_id"],
)
if result is None:
connection.send_error(
msg["id"],
"not_toggleable",
"Marker is not an active virtual light with tap_action=toggle",
)
return
# Both the reply and event follow the durable Store write. There is no
# optimistic client state, so all cards converge on this revision.
connection.send_result(msg["id"], result)
hass.bus.async_fire(EVENT_VIRTUAL_LIGHT_UPDATED, result)
def _internal_plan_names(config: dict[str, Any]) -> set[str]:
"""Plan file names a configuration names through OUR urls.
@@ -1273,12 +1215,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
return
new_rev = current_rev + 1
await async_save_config_state(
rt,
msg["config"],
new_rev,
previous_rev=int(current_rev),
)
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
try:
await _discard_optimizer_snapshot(rt)
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
@@ -1403,12 +1340,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
await async_save_config_state(
rt,
msg["config"],
new_config_rev,
previous_rev=config_rev,
)
await rt.config_store.async_save({"config": msg["config"], "rev": new_config_rev})
await async_save_layout_state(
rt, layout_data, msg["layout"], new_layout_rev,
metadata={_OPTIMIZE_BACKUP: backup},
@@ -1478,12 +1410,7 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
await async_save_config_state(
rt,
restored_config,
new_config_rev,
previous_rev=config_rev,
)
await rt.config_store.async_save({"config": restored_config, "rev": new_config_rev})
await async_save_layout_state(
rt, layout_data, restored_layout, new_layout_rev,
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup"),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

+22 -25
View File
@@ -1,32 +1,29 @@
{
"schema": 1,
"matrixVersion": 18,
"acceptedAt": "2026-08-13T19:26:19.387Z",
"sourceFingerprint": "27fda3d75e4cda95b9d85a9481f15ccd44b96d8c9d3311e0476718d36e2588c5",
"matrixVersion": 17,
"acceptedAt": "2026-08-13T14:30:05.929Z",
"sourceFingerprint": "66f31850eafc963848acdc4e56e363a364bbb9b189bf4ade73d3c111ae2fc375",
"chromium": "151.0.7922.34",
"scenarios": {
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
"isometric-geometry-view-dark": "73939fa61bebe2254591a788fad455a026a5e3b7394eeff27d4fe9a905de7ad2",
"isometric-geometry-view-light": "fc62f6ee8935d2e0c57b4918bf5fe554056a5956747b53a752ae265349ce662e",
"isometric-geometry-view-dark": "6db601f322fe55e33a1a89fe62f5d3a7d53f12bac4a68c7911b12f6382ef4fca",
"isometric-geometry-view-light": "c4c005fa55f64280abf9fccccdf7dc8efc3497bd626572bc11f45cc3cddaa159",
"isometric-live-layers-dark": "869c62bf9cd762c36d342d1a3bbd992969425b46b132f3243439735e2c75c14f",
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
"isometric-touch-kiosk-dark": "5eba7794e563e1ef5b9387184693c819976454d0efd222bd12b38aad8ea03e20",
"isometric-touch-kiosk-dark": "36c83bbe39809f346ebb5a0f4ed633c938e2fed028defaa48097c281db8e23a3",
"isometric-large-warm-remount-dark": "798d312671dffebf59034a39f2865a65ece27a84b150e77bc59038bb2567d07c",
"geometry-view-dark-fit": "3df272f6c3c3d20e9e375ea037f3dbb885657b94b0b29d0067505f4a73741237",
"geometry-view-light-fit": "a7f2c9667d9872dd84a37d5318fd238c9eabcc0f413017eb19e02204587b4e1a",
"geometry-plan-editor-dark": "19b5c84943b70074ba1aae51d1e54f9a59c44dde34fff89e32f0babb1e90a2ca",
"opening-placement-door-thick-wall-dark": "395c03bbf5d968e83664fd6621f0ac25902e718022f2e92ffbcddb8ce629cf9c",
"geometry-devices-editor-dark": "a9e4846ce5453400b87e6ad3d575882bc23a07b59dc3eecb612ed872b0c871ec",
"geometry-decor-editor-dark": "435b36096bbb2996d56ff0af262ddebff4a727edd841b0fad9b8d4f507b987ac",
"tray-wide-selection-en": "024ac666ac4dac01a36f7d1fdbf3bb41f76a627377f7c02dc373f429dfe96d43",
"tray-wide-tool-ru": "669fc1cf04433b936c0e9d05799ad19eda8bc003d5ab71937d0512c8f49ba921",
"tray-medium-group-en": "50cd3980b21f554bec2e76f4d2e8032f4bd30c0679477eb2f5cbe22f68935b7c",
"tray-medium-selection-ru": "4e5f235be8ed6296e136641d172e727a6a6b7a9d061f0928a96ccc1103c19f1f",
"tray-narrow-palette-en": "88b9846e4b451ed95b7ae7d2c3183a2ea191d7668768992a1364c6a7a53eb0c6",
"tray-narrow-tool-ru": "c4130715b3cb31c68619dfc706a3aa308e86edf272bfae6833b20666764ece2b",
"geometry-diagonal-45-opening-dark": "01206d25631c8fd09fa077932fbb5c1ee115b65ba76b38e6f7b09315dbbf6002",
"geometry-view-dark-fit": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
"geometry-view-light-fit": "0c57dee930f30a1f9c16e4e704675156f57a7623a4edf839c14a4fecbf12881c",
"geometry-plan-editor-dark": "6b06213324c5ff50c7176451307a33d8ac31ee636c96698f8efe61e8db763bae",
"opening-placement-door-thick-wall-dark": "24f472818f2246eeaa6568648ca4a6c42d9d84723fbcdd879435253399e2995d",
"geometry-devices-editor-dark": "c850e83f1af747f063b895109fe26d6a5804356fab505b0976073c28ad146104",
"geometry-decor-editor-dark": "44a95fd0b397c2729fea65c750fedcb11df10042ae198f3690151ead077c84e1",
"tray-wide-selection-en": "468ac6acfd7fcdcbfa947e032843ff117a32ae01b4ec30937a6cddecff534504",
"tray-wide-tool-ru": "388e03d7bf7a2391d0581e8446d0692048b9792e80bf3b9dd9bca86222fe9418",
"tray-medium-group-en": "190d5c356461ad2aa8b63b132416e8958a285047f8dea92301213678c7ac5a92",
"tray-medium-selection-ru": "ab91e88583b2863305434ce2775e31328638414671ff0a4f8ee82c8274c04405",
"tray-narrow-palette-en": "fb63483e7101d457d7fb1c83ae50435410ed797771adcbfde14dc2798a000ce1",
"tray-narrow-tool-ru": "60ce3c75de88b72fdb5d3fe7a16790184a8265c74f0f97e5be4fd83dbe0259fd",
"geometry-diagonal-45-opening-dark": "3736a75163d47a71f60c45cd554d604f27cfbc81119b5b55e8134da61a2a0f0a",
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
@@ -45,15 +42,15 @@
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
"hover-nested-room-dark": "6c09526ad885c4555063def5b43287b41124a81d90972c425084dba6e622d055",
"hover-nested-room-dark": "b70e385829a4fe45a77f5e1c3f8af4e18efec5282fb9594b26fab08c1f113d52",
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
"device-dialog-desktop-en": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
"decor-color-popover-mobile-ru": "46d4c2e4dd20c3a38e90efe3db59b3e878bdbcf273fbc1aa23de4b230723fa6e",
"backup-full-preview-desktop-en": "1957c1c797c7793fb8dcf592ca74c9f2e3eccfcf4bac1d0187482c055bebb93b",
"decor-color-popover-mobile-ru": "16d2859d1ed3715c1c4d3d451a8428c37e91ba00da17272c59cf83420a7b6c31",
"backup-full-preview-desktop-en": "7f12943d1027c89f6fe46978fa1f4e1bcdbf85a1216281d850d467e68f52cbda",
"backup-space-preview-mobile-ru": "998c6b52c1cc95109feb440a9966cade738154ceeae387246d40f8c56f9e8a3a"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 321 KiB

+1 -2
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 19;
export const GOLDEN_MATRIX_VERSION = 18;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -15,7 +15,6 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'split-corner-wall-thick-dark', fixture: 'visual', space: 'golden-corner-split',
cornerSplitWall: 'thick', 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', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
labs: ['iso'], projection: 'iso', theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
+3 -9
View File
@@ -1,4 +1,4 @@
// #122 Stage 2: Labs lifecycle, structural composition and flat editor boundary.
// #89 Stage 1: Labs lifecycle, toggle, projection parity and flat editor boundary.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 850 });
@@ -44,12 +44,7 @@ const out = await page.evaluate(async () => {
const device = root().querySelector('[data-hp="device"]');
const roomLabel = root().querySelector('[data-hp="room-label"]');
result.isoRendered = isoToggle?.getAttribute('aria-pressed') === 'true'
&& !!root().querySelector('[data-hp="iso-underlay"] .iso-floor-side')
&& !!root().querySelector('[data-hp="iso-walls"] .iso-wall-top');
result.sharedProjectionSnapshot = ['.iso-underlay-svg', '.plan-svg', '.iso-shadows-svg', '.iso-walls-svg']
.map((selector) => root().querySelector(selector)?.getAttribute('viewBox'))
.every((value, _index, values) => !!value && value === values[0]);
result.stage2DefinitionsBounded = root().querySelectorAll('[id^="hp-iso-"]').length <= 5;
result.preferenceStored = JSON.parse(localStorage.getItem('houseplan_card_view_v1') || '{}').f1 === 'iso';
result.anchorsFinite = [device, roomLabel].every((node) => node
&& center(node).every((value) => Number.isFinite(value)));
@@ -84,7 +79,7 @@ const out = await page.evaluate(async () => {
build: () => {
attempts++;
if (shouldFail) throw new Error('injected isometric failure');
return { walls: [], floor: [], openings: [] };
return [];
},
});
card.requestUpdate();
@@ -113,8 +108,7 @@ const out = await page.evaluate(async () => {
await frame();
result.removalIsImmediateFlat = JSON.stringify(window.__hpLabs) === '[]'
&& !root().querySelector('[data-hp="projection-toggle"]')
&& !root().querySelector('[data-hp="iso-walls"]')
&& !root().querySelector('[id^="hp-iso-"]');
&& !root().querySelector('[data-hp="iso-walls"]');
return result;
});
+4 -57
View File
@@ -1,4 +1,4 @@
// #122 Stage 2: live-floor preservation, vertical openings, touch and remount paths.
// #89 Stage 1: Q6 live-floor preservation plus touch, kiosk and remount paths.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(
@@ -26,8 +26,6 @@ const out = await page.evaluate(async () => {
contact: 'binary_sensor.window' },
{ id: 'iso-door', type: 'door', x: 0.55, y: 0.36, angle: 90, length: 0.12,
contact: 'binary_sensor.window', lock: 'lock.front_door' },
{ id: 'iso-gate', type: 'gate', x: 0.72, y: 0.14, angle: 0, length: 0.16,
contact: 'binary_sensor.gate', lock: 'lock.front_door' },
];
configSpace.partitions = [{
id: 'iso-live-wall', a: [0.15, 0.12], b: [0.85, 0.12], cm: 15,
@@ -88,7 +86,6 @@ const out = await page.evaluate(async () => {
glowSources: count('.glowlayer [data-glow-source]'),
sun: count('.sunlayer'),
openings: count('[data-hp="opening"]'),
verticalOpenings: count('[data-hp="iso-openings"] .iso-opening-panel'),
devices: count('[data-hp="device"]'),
hover: count('.room-hover-fill'),
vacuumPucks: count('.vacpuck'),
@@ -107,8 +104,6 @@ const out = await page.evaluate(async () => {
const wallFingerprint = root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint;
const cachedGeometry = original._isoGeometryCache.get(wallFingerprint)?.geometry;
const light = original.hass.states['light.ceiling'];
const openingPanelBefore = root(original)
.querySelector('[data-hp="iso-openings"] [data-id="iso-door"]')?.getAttribute('d');
original.hass = {
...original.hass,
states: {
@@ -116,9 +111,6 @@ const out = await page.evaluate(async () => {
'light.ceiling': {
...light, attributes: { ...light.attributes, rgb_color: [255, 64, 32] },
},
'binary_sensor.window': {
...original.hass.states['binary_sensor.window'], state: 'off',
},
},
};
await original.updateComplete;
@@ -126,26 +118,22 @@ const out = await page.evaluate(async () => {
const afterSpillParts = [...root(original).querySelectorAll('.glow-pool')]
.map((node) => node.getAttribute('data-lit-parts'));
const ordered = [
root(original).querySelector('.iso-underlay-svg'),
root(original).querySelector('.hp-backdrop'),
root(original).querySelector('.decorlayer'),
root(original).querySelector('[data-hp="room"]'),
root(original).querySelector('.glow-base-layer'),
root(original).querySelector('.glowlayer'),
root(original).querySelector('.sunlayer'),
root(original).querySelector('.iso-shadows-svg'),
root(original).querySelector('.iso-walls-svg'),
root(original).querySelector('.vacpuck'),
];
const result = {
isoOnTouch: !!root(original).querySelector('[data-hp="iso-walls"]'),
flatIsoLayerParity: JSON.stringify({ ...flat, openings: 0, verticalOpenings: before.verticalOpenings })
=== JSON.stringify(before)
flatIsoLayerParity: JSON.stringify(flat) === JSON.stringify(before)
&& JSON.stringify(flatSpillParts) === JSON.stringify(beforeSpillParts),
liveLayersPresent: before.rooms > 0 && before.roomFills > 0 && before.decor >= 2
&& before.furniture > 0 && before.backdrop > 0 && before.glowBase > 0
&& before.glowSources >= 2 && before.sun > 0 && before.openings === 0
&& before.verticalOpenings === 5
&& before.glowSources >= 2 && before.sun > 0 && before.openings >= 2
&& before.devices > 0 && before.hover > 0 && before.vacuumPucks > 0
&& before.vacuumTrails >= 2,
liveLayersStable: JSON.stringify(after) === JSON.stringify(before),
@@ -159,50 +147,9 @@ const out = await page.evaluate(async () => {
.some((stop) => stop.getAttribute('stop-color') === '#ff4020'),
sameWallFingerprint: root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint === wallFingerprint,
haUpdateReusesGeometry: original._isoGeometryCache.get(wallFingerprint)?.geometry === cachedGeometry,
contactUpdateMovesOnlyLivePanel: !!openingPanelBefore
&& root(original).querySelector('[data-hp="iso-openings"] [data-id="iso-door"]')?.getAttribute('d')
!== openingPanelBefore
&& original._isoGeometryCache.size === 1,
oneLightModel: !root(original).querySelector('.window-light,.iso-window-light,.iso-glow,.iso-sun'),
oldFloorSymbolsNotDuplicated: before.openings === 0,
noStage1VerticalOpenings: !root(original).querySelector('.iso-window,.window-light,.vertical-door'),
};
configSpace.settings.hide_openings = true;
original._cfgEpoch++;
original.requestUpdate();
await original.updateComplete;
result.hideOpeningsKeepsStructure = !!root(original).querySelector('[data-hp="iso-walls"]')
&& !root(original).querySelector('[data-hp="iso-openings"]')
&& !root(original).querySelector('[data-hp="opening"]')
&& root(original).querySelectorAll('.glowlayer [data-glow-source]').length >= 2;
configSpace.settings.hide_openings = false;
configSpace.settings.show_borders = false;
original._cfgEpoch++;
original.requestUpdate();
await original.updateComplete;
result.noBordersUsesFloorSymbols = !root(original).querySelector('[data-hp="iso-walls"]')
&& !root(original).querySelector('[data-hp="iso-underlay"]')
&& root(original).querySelectorAll('[data-hp="opening"]').length === 3;
configSpace.settings.show_borders = true;
original._cfgEpoch++;
original.requestUpdate();
await original.updateComplete;
result.visibleBordersRestoreStage2 = !!root(original).querySelector('[data-hp="iso-openings"]')
&& original._isoGeometryCache.size === 1;
const supports = CSS.supports;
CSS.supports = () => false;
original.requestUpdate();
await original.updateComplete;
result.unsupportedDecorationKeepsIsoStructure = !!root(original).querySelector('[data-hp="iso-walls"]')
&& !!root(original).querySelector('[data-hp="iso-openings"]')
&& !root(original).querySelector('[data-hp="iso-shadows"]');
CSS.supports = supports;
original.requestUpdate();
await original.updateComplete;
const calls = [];
original.hass = {
...original.hass,
-187
View File
@@ -1,187 +0,0 @@
// #131: a read-only HA session may read the House Plan snapshot while HA
// rejects event subscriptions. The initial frame must still select one exact
// space and render every raw-space layer before any user click.
import { launch, checkAll, finish } from './serve.mjs';
import { makeVisualMatrixFixture } from './fixtures/visual-matrix.mjs';
const fixture = makeVisualMatrixFixture();
const { page, browser } = await launch({ width: 820, height: 760 });
const result = await page.evaluate(async (rawFixture) => {
const out = {};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const HP = customElements.get('houseplan-card');
const host = document.getElementById('host');
window.__card.remove();
HP._warmBootReset?.();
for (const key of Object.keys(localStorage)) {
if (key.startsWith('houseplan_card_')) localStorage.removeItem(key);
}
history.replaceState(null, '', '/demo.html');
const fixture = structuredClone(rawFixture);
const lighting = fixture.config.spaces.find((space) => space.id === 'golden-lighting');
const geometry = fixture.config.spaces.find((space) => space.id === 'golden-geometry');
lighting.id = 'home';
lighting.title = 'Home';
lighting.settings = {
...lighting.settings,
fill_mode: 'custom',
custom_fill: { c: '#cdbb96', a: 0.42 },
glow_enabled: true,
show_borders: true,
};
lighting.decor = [{
id: 'readonly-furniture', kind: 'rect', x: 0.16, y: 0.18, w: 0.12, h: 0.08,
color: '#8d6e63', opacity: 1, width_cm: 2, fill: true,
fill_color: '#bcaaa4', fill_opacity: 1,
}];
geometry.id = 'upstairs';
geometry.title = 'Upstairs';
fixture.config.spaces = [lighting, geometry];
fixture.config.markers.push({
id: 'golden-light-one', binding: 'device:golden-light-one', display: 'value',
});
fixture.layout = Object.fromEntries(Object.entries(fixture.layout).map(([id, pos]) => [
id,
{ ...pos, s: pos.s === 'golden-lighting' ? 'home' : pos.s === 'golden-geometry' ? 'upstairs' : pos.s },
]));
const base = window.__mkHass();
const makeHass = (subscriptionMode = 'partial') => {
const calls = { configGets: 0, events: {}, unsubscribed: [] };
const connection = {
subscribeEvents: async (callback, event) => {
calls.events[event] = (calls.events[event] || 0) + 1;
if (String(event).startsWith('houseplan_')
&& (subscriptionMode === 'reject-all'
|| event === 'houseplan_config_updated' || event === 'houseplan_trail_updated')) {
throw new Error('unauthorized');
}
return () => { calls.unsubscribed.push(event); };
},
subscribeMessage: async () => () => {},
};
const hass = {
...base,
user: { id: 'readonly', name: 'Readonly', is_admin: false },
devices: fixture.devices,
entities: fixture.entities,
areas: fixture.areas,
states: fixture.states,
connection,
callWS: async (message) => {
if (message.type === 'houseplan/config/get') {
calls.configGets += 1;
return { config: structuredClone(fixture.config), rev: 131, can_write: false };
}
if (message.type === 'houseplan/layout/get') {
return { layout: structuredClone(fixture.layout), rev: 131 };
}
if (message.type === 'houseplan/trail/get') return { trails: {} };
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
if (message.type === 'config_entries/get') {
return [{ entry_id: 'golden_entry', domain: 'houseplan_golden', title: 'Golden' }];
}
if (message.type === 'manifest/list') {
return [{ domain: 'houseplan_golden', name: 'Golden' }];
}
return { ok: true };
},
};
return { hass, calls };
};
const layerSnapshot = (card) => {
const root = card.shadowRoot || card.renderRoot;
return {
exactSpace: card._curSpaceCfg?.id || null,
activeSpace: root.querySelector('[data-hp="space-tab"].active')?.getAttribute('data-id') || null,
rooms: root.querySelectorAll('[data-hp="room"]').length,
decor: root.querySelectorAll('[data-hp="decor"]').length,
walls: root.querySelectorAll('.wallbodies .wallbody-fill').length,
glow: root.querySelectorAll('.glow-spot').length,
devices: root.querySelectorAll('[data-hp="device"]').length,
values: root.querySelectorAll('[data-hp="device"] .valtext').length,
};
};
const complete = (snapshot) => snapshot.exactSpace === 'home'
&& snapshot.activeSpace === 'home' && snapshot.rooms > 0 && snapshot.decor > 0
&& snapshot.walls > 0 && snapshot.glow > 0 && snapshot.devices > 0 && snapshot.values > 0;
const waitForCard = async (card, calls, kiosk = false) => {
const until = performance.now() + 9000;
while (performance.now() < until) {
const snapshot = layerSnapshot(card);
const allSubscriptionsTried = ['houseplan_config_updated', 'houseplan_trail_updated', 'houseplan_layout_updated']
.every((event) => calls.events[event] === 1);
if (card._loadOk && !card._loading && card._booting === false
&& allSubscriptionsTried && complete({
...snapshot,
activeSpace: kiosk ? 'home' : snapshot.activeSpace,
})) return snapshot;
await sleep(25);
}
throw new Error(`readonly card did not settle: ${JSON.stringify({
state: layerSnapshot(card), calls, space: card._space, loadOk: card._loadOk,
})}`);
};
const mount = async (title, options = {}) => {
const runtime = makeHass(options.subscriptionMode);
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', title, kiosk: !!options.kiosk });
host.appendChild(card);
card.hass = runtime.hass;
const snapshot = await waitForCard(card, runtime.calls, !!options.kiosk);
return { card, snapshot, ...runtime };
};
const cold = await mount('Readonly cold start');
out.coldSelectsExactSpace = cold.card._space === 'home'
&& cold.snapshot.exactSpace === 'home' && cold.snapshot.activeSpace === 'home';
out.coldRendersAllSpatialLayers = complete(cold.snapshot);
out.readOnlyStaysReadOnly = cold.card._canEdit === false
&& !(cold.card.shadowRoot || cold.card.renderRoot).querySelector('.modetab');
out.allSubscriptionsAttempted = ['houseplan_config_updated', 'houseplan_trail_updated', 'houseplan_layout_updated']
.every((event) => cold.calls.events[event] === 1);
out.allowedSubscriptionAdopted = typeof cold.card._unsubLayout === 'function'
&& !cold.card._unsubCfg && !cold.card._unsubTrail;
out.cacheWritten = !!localStorage.getItem('houseplan_card_cfg_v1');
const beforeClick = layerSnapshot(cold.card);
cold.card._pickSpace('home');
await cold.card.updateComplete;
out.activeTabClickIsNoop = JSON.stringify(layerSnapshot(cold.card)) === JSON.stringify(beforeClick);
await sleep(700);
out.optionalFailureNoFullRetry = cold.calls.configGets === 1;
cold.card.remove();
await sleep(20);
out.successfulSubscriptionCleanedUp = cold.calls.unsubscribed.includes('houseplan_layout_updated');
const warm = await mount('Readonly cold start');
out.warmRemountKeepsCompleteSpace = complete(warm.snapshot) && warm.card._space === 'home';
warm.card.remove();
await sleep(20);
HP._warmBootReset?.();
const reload = await mount('Readonly simulated reload');
out.cachedReloadKeepsCompleteSpace = complete(reload.snapshot) && reload.card._space === 'home';
reload.card.remove();
await sleep(20);
HP._warmBootReset?.();
localStorage.removeItem('houseplan_card_cfg_v1');
localStorage.removeItem('houseplan_card_nav_v1');
const kiosk = await mount('Readonly cold kiosk', { kiosk: true, subscriptionMode: 'reject-all' });
const kioskRoot = kiosk.card.shadowRoot || kiosk.card.renderRoot;
out.coldKioskCompleteWithoutHeaderAction = kiosk.card._space === 'home'
&& kiosk.snapshot.exactSpace === 'home' && kiosk.snapshot.rooms > 0
&& kiosk.snapshot.decor > 0 && kiosk.snapshot.walls > 0 && kiosk.snapshot.glow > 0
&& kiosk.snapshot.devices > 0 && getComputedStyle(kioskRoot.querySelector('.hdr')).display === 'none';
out.kioskCacheWritten = !!localStorage.getItem('houseplan_card_cfg_v1');
kiosk.card.remove();
return out;
}, fixture);
checkAll(result);
await finish(browser, result);
-171
View File
@@ -1,171 +0,0 @@
// #107: persistent manual virtual light, shared by two full cards and one static card.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1200, height: 900 });
const result = await page.evaluate(async () => {
const first = window.__card;
const markerId = 'smoke_manual_virtual_light';
const space = first._model[0];
const room = space.rooms.find((candidate) => candidate.id && candidate.area);
const serverConfig = structuredClone(first._serverCfg);
serverConfig.markers = [...(serverConfig.markers || []), {
id: markerId,
name: 'Manual virtual lamp',
binding: 'virtual',
is_light: true,
tap_action: 'toggle',
tap_confirm: false,
controls: ['light.ceiling'],
space: space.id,
area: room.area,
room_id: room.id,
}];
const serverLayout = {
...first._layout,
[markerId]: { s: space.id, x: 0.32, y: 0.32 },
};
const virtual = { rev: 0, config_rev: first._cfgRev, off: [] };
const subscriptions = new Map();
const serviceCalls = [];
let toggleCalls = 0;
const emit = (event, data) => {
for (const listener of subscriptions.get(event) || []) listener({ data });
};
const baseHass = first.hass;
const baseCallWS = baseHass.callWS.bind(baseHass);
const connection = {
...baseHass.connection,
addEventListener: baseHass.connection?.addEventListener?.bind(baseHass.connection),
removeEventListener: baseHass.connection?.removeEventListener?.bind(baseHass.connection),
subscribeMessage: baseHass.connection?.subscribeMessage?.bind(baseHass.connection),
subscribeEvents: async (callback, event) => {
const listeners = subscriptions.get(event) || new Set();
listeners.add(callback);
subscriptions.set(event, listeners);
return () => listeners.delete(callback);
},
};
const hass = {
...baseHass,
connection,
callService: async (domain, service, data) => {
serviceCalls.push({ domain, service, data });
},
callWS: async (message) => {
if (message.type === 'houseplan/config/get') return {
config: serverConfig,
rev: virtual.config_rev,
can_write: true,
virtual_lights: structuredClone(virtual),
};
if (message.type === 'houseplan/layout/get') return {
layout: serverLayout,
rev: first._layoutRev,
};
if (message.type === 'houseplan/virtual_light/toggle') {
toggleCalls++;
const off = new Set(virtual.off);
if (off.has(message.marker_id)) off.delete(message.marker_id);
else off.add(message.marker_id);
virtual.off = [...off].sort();
virtual.rev++;
const reply = {
marker_id: message.marker_id,
on: !off.has(message.marker_id),
rev: virtual.rev,
};
queueMicrotask(() => emit('houseplan_virtual_light_updated', reply));
return reply;
}
return baseCallWS(message);
},
};
// Reconnect the already mounted card to the fake server authority.
first._unsubVirtual?.();
first._unsubVirtual = null;
first.hass = hass;
await first._reloadConfigOnly(true);
first._layout = serverLayout;
first._regSignature = '';
first._maybeRebuildDevices();
first._ensureLiveSyncSubscriptions();
await first.updateComplete;
const second = document.createElement('houseplan-card');
second.setConfig({ type: 'custom:houseplan-card', icon_size: 3.4 });
document.body.append(second);
second.hass = hass;
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: space.id });
document.body.append(staticCard);
staticCard.hass = hass;
const waitFor = async (predicate, timeout = 6000) => {
const started = Date.now();
while (!predicate() && Date.now() - started < timeout) {
await new Promise((resolve) => setTimeout(resolve, 40));
}
return !!predicate();
};
await waitFor(() => second._devices?.some((device) => device.id === markerId));
await waitFor(() => staticCard.renderRoot?.querySelector(`[data-id="${markerId}"]`));
await second.updateComplete;
await staticCard.updateComplete;
const deviceOf = (card) => card._devices.find((device) => device.id === markerId);
const on = (card) => card._stateClass(deviceOf(card)).includes('on');
const staticOn = () => staticCard.renderRoot
.querySelector(`[data-id="${markerId}"]`)?.classList.contains('on');
const initialEverywhereOn = on(first) && on(second) && staticOn();
const markerNode = first.renderRoot.querySelector(`.dev[data-id="${markerId}"]`);
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await waitFor(() => virtual.rev === 1 && !on(second) && staticOn() === false);
await first.updateComplete;
const clickEverywhereOff = !on(first) && !on(second) && staticOn() === false;
// A second real click returns to on; the saved outgoing control never calls HA.
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await waitFor(() => virtual.rev === 2 && on(second) && staticOn() === true);
const secondClickEverywhereOn = on(first) && on(second) && staticOn();
const beforeTouch = toggleCalls;
markerNode.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, pointerType: 'touch', pointerId: 41,
clientX: 100, clientY: 100,
}));
markerNode.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, composed: true, pointerType: 'touch', pointerId: 41,
clientX: 100, clientY: 100,
}));
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await waitFor(() => virtual.rev === 3 && !on(second) && staticOn() === false);
const touchSingleToggle = toggleCalls === beforeTouch + 1;
// A fresh card must paint the server's off snapshot, not wait for an event.
const reloaded = document.createElement('houseplan-card');
reloaded.setConfig({ type: 'custom:houseplan-card', icon_size: 3.4 });
document.body.append(reloaded);
reloaded.hass = hass;
await waitFor(() => reloaded._devices?.some((device) => device.id === markerId));
await reloaded.updateComplete;
const reloadFirstStateOff = !on(reloaded);
second.remove();
staticCard.remove();
reloaded.remove();
return {
initialEverywhereOn,
clickEverywhereOff,
secondClickEverywhereOn,
touchSingleToggle,
reloadFirstStateOff,
oneServerTogglePerGesture: toggleCalls === 3,
noHaServiceCalls: serviceCalls.length === 0,
};
});
checkAll(result);
await finish(browser, result);
File diff suppressed because one or more lines are too long
+255 -374
View File
File diff suppressed because one or more lines are too long
+1 -62
View File
@@ -340,31 +340,6 @@ cache guarded by a structural geometry fingerprint. This is computed render
state only: it never rewrites rooms or wall entries, and an HA state tick does
not rebuild topology.
### Hidden Isometric Stage 2 composition (#122)
The hidden `iso` View reuses that masonry but has one bounded structural scene,
not a second house model. `_isoGeometryCache` remains an eight-entry LRU keyed
by room/wall/opening geometry (including opening flips), scale/camera, fixed
wall/floor-edge heights and an algorithm revision. Each value holds wall faces,
the room/exterior slab edge, immutable opening jamb bases and the projected
frame. HA state, theme, hover and filter support are presentation inputs and
never enter this key.
`floorFootprintGeometry()` derives only the union of room floors and exterior
masonry; unlike wall volume, it has no independent partition/column input.
`buildIsoFloorGeometry()` emits visible low faces for outer component rings,
not internal edges or holes. `src/iso-openings.ts` stores jamb/axis topology and
applies `openingAmount()` only during live projection, keeping contact updates
out of the boolean geometry path.
Composition is shared-viewBox SVG: ambient shadow/floor edge → the existing
affine-projected floor/live scene → contact/leaf shadows → wall material and
vertical panels → existing screen-facing HTML overlays. A constant set of
gradients/filters serves every face. Unsupported decoration or forced colours
remove nuance/shadows without changing projection; only structural failure
uses the Stage 1 latched Flat fallback. Details and fixed ratios are recorded in
`docs/adr/122-isometric-stage2-composition.md`.
## Markup editor (v1.4.0+)
State inside the card: `_markup` (mode), `_tool` (draw/partition/column/merge/split/resize/opening/
@@ -489,8 +464,7 @@ spans are clipped per atomic body.
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}, rev}` |
| `houseplan/layout/set` | `layout`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_layout_updated` |
| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` |
| `houseplan/config/get` | — | `{config, rev, virtual_lights:{rev,config_rev,off[]}}` (`virtual_lights` optional for rolling compatibility) |
| `houseplan/virtual_light/toggle` | `marker_id` | `{marker_id,on,rev}` / err `not_toggleable`; event `houseplan_virtual_light_updated` |
| `houseplan/config/get` | — | `{config, rev}` |
| `houseplan/trail/get` | — | `{trails: {marker: {current, previous}}}` — vacuum runs, raw robot coords |
| `houseplan/trail/delete` | `marker_id` | `{ok, removed}` — erase current/previous runs after marker deletion |
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
@@ -511,23 +485,6 @@ spans are clipped per atomic body.
Manual attachments upload over HTTP (streaming, transactional staging), not WS —
the old `houseplan/file/set` was removed in v1.10.0.
Manual virtual-light state is operational data, not plan configuration. The
integration owns a separate versioned `houseplan.virtual_lights` Store whose
bounded payload contains only `{rev, config_rev, off[]}`. The existing shared
write lock serializes config reconciliation and atomic toggles. Eligibility is
always recalculated from server config; the toggle command accepts no desired
state, entity id or service. It is intentionally available to every
authenticated View user, while config writers remain governed by `may_write`.
The durable save precedes both reply and event. A config-revision gap from an
older writer clears manual off bits to the compatibility default `on`.
The first `config/get` frame carries the coherent operational snapshot. Full
cards subscribe directly to the update event; all `houseplan-space-card`
instances share the module-level config cache and one subscription. Local
storage may retain the last snapshot for continuity, but never authorizes an
optimistic toggle. The data is excluded from marker/layout schemas, portable
export/import and the HA entity registry.
Portable import preview uses authenticated
`POST /api/houseplan/import/preview`. The endpoint streams at most 8 MiB,
strictly rejects duplicate/prototype keys, non-finite numbers and future model
@@ -694,24 +651,6 @@ localized opaque recovery overlay, after a 150 ms delay. The overlay never
steals initial focus; while visible it alone is interactive and the scene is
`inert`.
**The initial snapshot does not depend on live-sync subscriptions** (#131).
`houseplan-card` first accepts config and layout, builds the model, chooses one
exact space, caches the accepted snapshot and restores its viewport. Only then
is the mandatory load complete. Config, trail and layout event subscriptions
start together as independent best-effort enrichments: one rejected channel
does not prevent the others from subscribing, does not erase the usable
snapshot and does not schedule a full-load retry solely for that rejection.
Missing channels get another attempt on the next normal load or reconnect.
`src/initial-load.ts` is the shared authority for the exact space used by a
cached snapshot, a live snapshot and its protected-backdrop candidate. A cold
load considers only valid ids in this order: URL hash, saved navigation,
`default_floor`, first live space. Once an initial URL hash has been consumed,
a valid same-route current selection is preserved instead of repeatedly
snapping back to that hash. The legacy field initializer is never a cold-start
choice by itself. A plan with no spaces keeps `null` authority and does not
invent an id.
**Room climate is one pass per hass snapshot** (review R2-3). `areaClimateMap()`
classifies the whole registry once and returns `Map<area, {temp, hum}>`; the
card memoizes it on `hass` identity, which Home Assistant replaces on every
-27
View File
@@ -2,38 +2,11 @@
## Unreleased
- Read-only View and kiosk cards now paint a complete first frame even when
Home Assistant refuses live-sync event subscriptions. The selected space,
room fills, decor, walls, Glow and device values no longer require clicking
the already active space tab ([#131](https://github.com/Matysh/houseplan-card/issues/131)).
- Virtual markers configured as **Light source → Always** with **Toggle state**
can now be switched directly without an HA helper. Their shared state
survives reloads/restarts and stays consistent across Glow, room light
presentation, full cards and static space cards, including markers that retain
saved outgoing controls
([#107](https://github.com/Matysh/houseplan-card/issues/107)).
## v1.63.0 — 2026-08-13
- Preserved explicit door, window and gate bindings when their standalone
sensor or lock marker is removed, and fixed the supported empty state after
deleting the last space
([#104](https://github.com/Matysh/houseplan-card/issues/104),
[#111](https://github.com/Matysh/houseplan-card/issues/111)).
- Splitting a room from an existing corner no longer deforms the exterior
facade, including with thick dividers. Flat, static and hidden isometric
rendering use the same preserved wall geometry
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
- Small fixes and improvements.
## v1.63.0-beta.2 — 2026-08-13
- Splitting a room from an existing corner no longer deforms the exterior wall
or pulls a thick internal divider through the facade. Plan, View, kiosk,
static cards, hidden isometric rendering and light obstacles now use the same
preserved exterior geometry, including already saved plans
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
- Small fixes and improvements.
## v1.63.0-beta.1 — 2026-08-13
-28
View File
@@ -8,39 +8,11 @@
## Unreleased
- Read-only карточки в режимах View и киоска теперь сразу показывают полный
первый кадр, даже если Home Assistant запрещает подписки на события live-sync.
Выбранное пространство, заливки комнат, декор, стены, Glow и значения
устройств больше не требуют нажатия на уже активную вкладку пространства
([#131](https://github.com/Matysh/houseplan-card/issues/131)).
- Виртуальные маркеры с сочетанием **Источник света → Всегда** и
**Переключить состояние** теперь можно включать и выключать без helper/entity
Home Assistant. Общее состояние переживает перезагрузку страницы и HA и
одинаково отображается в Glow, свете комнаты, полной и статической карточках,
в том числе при сохранённых исходящих связях управления
([#107](https://github.com/Matysh/houseplan-card/issues/107)).
## v1.63.0 — 2026-08-13
- Сохранены явные привязки дверей, окон и ворот после удаления отдельного
маркера датчика или замка; исправлено предусмотренное пустое состояние после
удаления последнего пространства
([#104](https://github.com/Matysh/houseplan-card/issues/104),
[#111](https://github.com/Matysh/houseplan-card/issues/111)).
- Split из существующего угла комнаты больше не деформирует наружный фасад,
в том числе с толстым разделителем. Плоский, статичный и скрытый
изометрический рендер используют одну сохранённую геометрию стен
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
- Мелкие исправления и улучшения.
## v1.63.0-beta.2 — 2026-08-13
- Split из существующего угла комнаты больше не деформирует наружную стену и
не вытягивает толстый внутренний разделитель сквозь фасад. Редактор плана,
View, киоск, статичная карточка, скрытая изометрия и световые препятствия
используют одну сохранённую наружную геометрию, в том числе для уже
сохранённых планов ([#123](https://github.com/Matysh/houseplan-card/issues/123)).
- Мелкие исправления и улучшения.
## v1.63.0-beta.1 — 2026-08-13
-26
View File
@@ -142,29 +142,3 @@ while an untouched old broken source remains readable and round-trippable.
space transfer remaps an internal target and disables/counts a link whose
target is outside the transfer. Older clients ignore the field and may erase
it if they reconstruct the same marker after a downgrade.
## Persistent manual virtual-light state
The exact `virtual` + `is_light:true` + `tap_action:toggle` combination has a
shared operational on/off state. It is not a Marker/ServerConfig field: the
integration stores `{rev, config_rev, off[]}` under a separate versioned Store
key and exposes an optional `virtual_lights` snapshot in `houseplan/config/get`.
It is excluded from layout, full/space export, import and HA entities. Missing
Store data or a missing wire field projects to `on`.
Compatibility matrix:
| Frontend | Backend | Behaviour |
|---|---|---|
| old | new | Extra snapshot/event are ignored; the marker keeps historical #84/#94 behaviour and config remains intact |
| new | old | Missing snapshot starts `on`; an unsupported toggle command reports an error and creates no optimistic local state |
| new | new | Server snapshot is authoritative; revisioned events synchronize full and static cards |
Every current config writer reconciles the Store. Rename, move, hide and
unrelated edits preserve off bits for still-eligible stable marker ids;
binding/role/action changes, tombstones and deletion prune them. Re-entering the
triple therefore starts on. If `config_rev` skips the revision recorded by the
operational Store — for example after downgrade, an old writer or an interrupted
pair — all off bits are cleared rather than resurrected against unknown marker
lifecycle history. Older integrations safely ignore the separate Store on
downgrade.
+1 -77
View File
@@ -1,4 +1,4 @@
# Hidden Isometric View internals
# Isometric Stage 1 internals
Issue [#89](https://github.com/Matysh/houseplan-card/issues/89) implements a
hidden, presentation-only volumetric View experiment. The normative contract is
@@ -90,79 +90,3 @@ the rollback path and does not depend on the iso cache.
Golden references are accepted only from the complete reviewed Linux artifact.
The full `large-house-isometric-v1` performance comparison is also canonical on
the exact Linux CI SHA.
## Stage 2 composition (#122)
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.
### One structural scene, live opening leaves
The per-card LRU remains capped at eight entries. Its Stage 2 value contains:
- canonical wall top/sides and the physical-wall contact path;
- a room/exterior floor footprint and its low visible outer faces;
- immutable opening jamb/axis bases, including type, flips and selected wall
face;
- the shared projected frame, including wall/opening tops and the low floor
edge.
The key includes rooms, masonry/opening geometry, flips, scale/camera, wall and
edge heights and algorithm revision. It excludes HA state, theme, hover,
day/night and filter capability. `openingAmount()` is applied only after an LRU
hit by `projectIsoOpening()`, so a contact update projects O(O) leaves without
repeating a wall or floor boolean operation.
`floorFootprintGeometry()` deliberately accepts no independent physical-body
input. The slab is the union of room floors and derived exterior masonry:
internal room boundaries and nested holes make no decorative step, detached
room components keep separate outside edges, while partitions and columns do
not enlarge it.
### Layer order and materials
All geometry roots use one scene `viewBox`. The existing floor/live nodes are
grouped under the Stage 1 affine matrix; HTML anchors still use
`projectPlanPoint()`.
```text
stage background
→ shared ambient shadow + low exterior floor edge
→ existing floor SVG (paper/image, room fills/hover, decor, Glow, sun)
→ shared contact and live leaf shadows
→ canonical wall sides/top + inert vertical opening panels
→ existing HTML devices, labels/cards, locks and vacuum overlays
```
Wall top and side use two shared matte gradients. Ambient, contact and leaf
shadows use three shared filters; definition count is constant per card, never
per face or opening. Forced colours use solid `Canvas`/`CanvasText` faces and
omit decoration. A runtime without the required filter paint keeps solid
structure, floor edge and vertical panels but emits no Stage 2 shadows; this
does not enter the structural fallback latch.
### Vertical openings and display settings
`src/iso-openings.ts` mirrors the existing opening-symbol transform algebra:
door has one jamb-hinged leaf, gate has two leaves with the established
0–10° exterior-face turn, and window has two light neutral casements. Heights
are fixed presentation ratios of `ISO_WALL_HEIGHT`; there is no schema field.
Panels/shadows are pointer- and ARIA-inert. Existing lock badges/cards and HA
actions remain the only interactive opening surface.
- borders visible: vertical panels replace the floor-plane symbols;
- `hide_openings: true`: panels and leaf shadows disappear, while masonry
cuts, Glow/sun and contact/lock meaning remain;
- `show_borders: false`: Stage 2 roots are absent and the established floor
symbols and Stage 1 projected frame return (subject to `hide_openings`),
avoiding floating panels or an invisible Stage 2 bound that reframes them;
- Flat, editors and `houseplan-space-card` retain their old symbols and DOM.
Stage 2 adds no window beam, Glow source, sun renderer, material config,
network request or HA service path. Structural topology/projection exceptions
still use the Stage 1 latched Flat fallback. The known independent exact-SHA
view-toggle performance debt remains tracked in #124; #122 neither weakens its
budget nor treats fallback as benchmark success.
-12
View File
@@ -134,18 +134,6 @@ source even when the marker has no controllable HA entity. That last case is a
- its position, room, colour, brightness and radius belong to the target lamp,
not to the switch which drives it.
There is one explicit manual-authority exception. An active marker with a
`virtual` binding, `is_light: true` (Always) and `tap_action: toggle` reads its
on/off value from the integration's revisioned operational store. Absence is
`on`. While the triple remains active, that value overrides incoming controller
OR for the marker source and therefore reaches every consumer of
`resolvedLightSources()` — Glow, room fill/counts, device presentation, preview
and both card types. Saved controls remain lossless but a tap on this exact
marker performs the operational toggle, never an HA service call. Leaving the
triple restores the normal controller rules and clears any stored off bit;
hiding alone does neither. The operational revision is part of the resolver
cache key, so an event changes the projection without an HA state tick.
The controller picker can therefore link a smart relay to a virtual marker for
a dumb physical lamp. Multiple controllers use OR. A direct entity reference
and a marker reference resolving to the same stateful source are deduplicated.
+7 -9
View File
@@ -1,18 +1,16 @@
<!-- release: v1.63.0 -->
<!-- release: v1.63.0-beta.1 -->
## Основное
- Удаление отдельного маркера больше не разрывает явную привязку двери, окна или ворот.
- После удаления последнего пространства интеграция остаётся рабочей и позволяет создать новое.
- Разделение комнаты из существующего угла больше не искажает наружный фасад даже при толстом разделителе.
- Удаление маркера датчика или замка больше не разрывает его привязку к двери, окну или воротам.
- Исправлено падение после удаления последнего пространства; из пустого состояния снова можно добавить пространство.
- Мелкие исправления и улучшения.
## Highlights
- Removing a standalone marker no longer breaks an explicit door, window or gate binding.
- The integration remains usable after deleting the last space and allows a new one to be created.
- Splitting a room from an existing corner no longer deforms the exterior facade, even with a thick divider.
- Deleting a sensor or lock marker no longer breaks its door, window or gate binding.
- Fixed the crash after deleting the last space; a space can again be added from the empty state.
- Small fixes and improvements.
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.63.0/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.63.0/docs/CHANGELOG.md)
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.63.0-beta.1/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.63.0-beta.1/docs/CHANGELOG.md)
+4 -4
View File
@@ -17,13 +17,13 @@ change must pass through a published beta/RC before stable. Stable release
commits are promotion-only (versions, generated bundles and release/changelog
metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
## Snapshot (2026-08-14)
## Snapshot (2026-08-13)
| Item | State |
|---|---|
| Version | **v1.63.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable promotion candidate after published v1.63.0-beta.2 |
| Current local cycle | v1.63.0 is the stable base. #122 Stage 2 is being implemented on `issue/122-isometric-stage2` under an explicit owner arbitration to start while the independent #124 exact-SHA view-toggle performance debt remains open; no Stage 2 release has been published. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 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 is explicitly a separate task. |
| Version | **v1.63.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate |
| Current local cycle | v1.63.0-beta.1 fixes the empty-plan crash (#111) and preserves explicit opening sensor/lock references after standalone marker deletion (#104). Development after that beta preserves the exterior facade when Split starts or ends at a room corner (#123), using one wall geometry for flat/static/isometric rendering and light. The line also carries the reviewed process automation work #105 and #118–#121. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1 as a hidden, expiring `iso` Labs experiment: a fixed near-top orthographic volumetric View. Flat remains default; editors and `houseplan-space-card` remain flat; all existing floor live effects and HA actions are preserved. This is internal, not a public feature. |
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. 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): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; 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 and the linked [Project v2](https://github.com/users/Matysh/projects/1) is the canonical priority/status view; both must stay current. `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) |
| CI | Prerelease publication requires a green exact-SHA Validate: frontend/backend, smoke (including the #73 rAF frame sampler), golden, HACS/Hassfest and a short absolute-ceiling performance smoke. Obsolete same-ref Validate runs are cancelled. Full seven-sample base/candidate performance moved to `performance.yml` (`main` push, weekly, manual); stable release assets fail closed unless Validate and Full Performance are green for the exact tagged SHA and the stable-only CDP compositor screencast finds no empty/black presented frame. |
-33
View File
@@ -1,38 +1,5 @@
# Manual testing checklist
## Правила для новых тестов (issue #85) — обязательны
Зелёный тест в этом проекте несколько раз означал «ничего не проверено»:
смок непрерывности не заметил удаления механизма, который защищает; golden-сцена,
заведённая под #71, была пустой; смок теней был зелёным, пока тени физически не
рисовались. Общее у всех случаев — **тест ни разу не проверяли на способность
падать**. Отсюда правила.
1. **Наличие атрибута, класса или узла — не проверка поведения.** Такой ассерт
допустим только рядом с пиксельным либо поведенческим: атрибут доказывает,
что код выполнился, а не что механизм сработал.
2. **Golden-сцена, заведённая под конкретную задачу, несёт семантический
ассерт** (`warmPixelRegion` и родственные в `demo/golden/run.mjs`): сцена
обязана падать, если перестала показывать то, ради чего заведена. Пиксельный
дифф с эталоном этого не заменяет — пустая сцена совпадает со своим пустым
эталоном идеально.
3. **Фикстура содержит слои, которые тест защищает.** Смок непрерывности без
подложки, Glow и декора проверяет пустую страницу; солнце с азимутом, при
котором луч не достигает единственного окна (#89), — та же ошибка в
геометрии.
4. **Тест, охраняющий механизм, сопровождается мутантом** в
`scripts/mutation-gate.mjs`: 2–5 строк патча, воспроизводящего поломку,
против которой тест заведён, и тест обязан на ней краснеть. Чистым функциям
с обычными юнитами мутант не нужен.
5. **Тавтологический ассерт — читающий то же свойство, которое код только что
выставил, — не пишется вовсе.** Он может упасть только при удалении строки,
но не при её неработоспособности.
Проверка: `node scripts/mutation-gate.mjs --check` — якоря патчей живы;
полный прогон — workflow `mutation-gate.yml`, перед стабильным релизом и по
понедельникам. Дешёвая половина идёт с юнитами: `test/mutation-gate.test.mjs`.
## Device value badge (#90)
- [ ] An untouched legacy thermometer/humidity marker remains pixel-identical;
-16
View File
@@ -495,22 +495,6 @@ House Plan читает реестры устройств, сущностей и
`marker:*` является только внутренней ссылкой плана и никогда не отправляется
в Home Assistant как entity ID.
Если у самой виртуальной лампы одновременно выбраны **Является источником
света → Всегда** и **Действие по нажатию → Переключить состояние**, нажатие
переключает её собственное ручное состояние. Новая лампа начинается
включённой; состояние общее для всех полных и статических карточек, сохраняется
после перезагрузки страницы и Home Assistant и одинаково меняет Glow, заливку
«Свет» и статистику комнаты. Переключать может любой вошедший пользователь
дашборда — права редактора плана для этого не требуются.
Это точное исключение из обычной схемы `controls`. Пока три условия активны,
сохранённые внешние `controls` не вызывают сервисы HA и не переопределяют
ручное состояние, но остаются в настройках без потерь. Смена роли, привязки
или действия возвращает обычное управление; при повторном включении ручного
режима лампа снова начинается включённой. Скрытие сохраняет состояние, удаление
маркера очищает его. Ручное состояние не является entity/helper HA и не входит
в экспорт или импорт плана.
Удаление и скрытие — разные операции. Скрытый маркер можно показать через
**Скрытые и деактивированные**, и он продолжает участвовать в предусмотренных для скрытых
устройств данных. Удалённый маркер там не показывается, не участвует в LQI,
@@ -1,93 +0,0 @@
# ADR #122 — Stage 2 isometric composition
- Issue: https://github.com/Matysh/houseplan-card/issues/122
- Status: accepted for implementation
- Date: 2026-08-14
- Normative spec: `docs/specs/122-isometric-stage2.md`, revision 1
- Predecessor: `docs/adr/089-isometric-stage1-renderer.md`
## Context
Stage 1 established one fixed affine floor projection, canonical wall volume,
screen-facing HTML anchors and a bounded structural LRU. Stage 2 must add matte
depth, an exterior floor edge, grounding shadows and vertical openings without
creating another plan/light model or making HA state rebuild topology.
The owner explicitly allowed implementation to begin with #124's known
view-toggle performance debt still open. That arbitration removes the DoR
blocker only: the unchanged exact-SHA performance budget remains a gate and
fallback may not be forced to make it green.
## Decision
### Structural/live boundary
The existing eight-entry Iso LRU becomes a structural scene cache. Each entry
contains wall faces, floor footprint/edge, immutable opening bases and one
projected frame. The fingerprint includes opening geometry and flips plus the
fixed wall/edge heights and algorithm revision. It excludes HA state and every
decorative capability.
An opening basis stores its resolved wall face, jamb hinge, closed vector,
quarter-turn vector and fixed height bounds. Rendering applies the existing
`openingAmount()` after the cache lookup. Door/window/gate live changes are
therefore O(O) vector projection and cannot invoke polygon boolean operations.
### Floor footprint
The slab source is the canonical union of rooms plus derived exterior masonry.
Independent partitions, drafts and columns remain canonical wall volume and
light occluders but are not accepted as floor-footprint input. Stage 2 projects
only outer rings down by ten plan units; holes and internal/shared boundaries
cannot become steps. Disconnected outer polygons remain disconnected.
### Composition
Four absolute SVG roots share the effective scene `viewBox`: underlay, the
existing floor scene, shadows and wall/opening volume. The old floor nodes are
not copied; they live under the same affine matrix Stage 1 used. HTML overlays
stay above every SVG root.
The stable order is ambient shadow, floor edge, current floor/live layers,
contact/leaf shadows, wall sides/top, vertical panels, then HTML overlays.
All new geometry is pointer/focus/ARIA inert.
### Material and degradation
Two shared SVG gradients distinguish matte wall top and side. Three shared
filters provide ambient/contact/leaf softness. Definitions are O(1) per card;
there are no per-face filters, raster textures, data URLs or runtime
dependencies.
Filter-paint failure removes shadows only. Forced colours also remove gradient
nuance and use solid system colours. Structural wall/opening/edge geometry and
all existing live layers remain Iso. Only a structural topology/projection
exception enters the established `space|fingerprint` Flat fallback latch.
### Opening presentation and settings
- door: one full jamb-hinged leaf, 92% of wall height;
- gate: two half leaves, 88%, preserving the existing 0–10° face-aware turn;
- window: two light inserts between 27% and 78% of wall height.
These are presentation constants, not persisted fields. With visible borders,
vertical panels replace the old floor symbols. `hide_openings` hides panels and
leaf shadows. With borders disabled, every Stage 2 root is absent and the
existing floor symbols return, so there are no floating vertical leaves. The
no-borders branch also keeps the accepted Stage 1 projected frame (floor plus
the fixed wall-height allowance): absent Stage 2 floor/opening geometry cannot
contribute invisible bounds and reframe the live floor. With borders visible,
fit includes every Stage 2 structural bound; blur capability never changes it.
## Consequences
- Flat, editors, static card, schema, storage keys, i18n, backend and HA actions
are unchanged.
- Glow/spill and sunlight remain the only room/window light models.
- Theme, hover, filter fallback and HA-only updates do not grow the structural
LRU.
- Fit includes structural opening/wall tops and the low floor edge, never blur.
- Golden and browser evidence must review new Iso pixels; Flat and the existing
no-borders baseline remain unchanged.
- Stage 2 stays hidden and expires with the existing `iso` Labs entry unless a
separate owner-reviewed rollout/graduation issue changes that contract.
-307
View File
@@ -1,307 +0,0 @@
# Код-ревью issue #107 — переключение виртуального источника света «Всегда» (r1)
- **Issue:** https://github.com/Matysh/houseplan-card/issues/107
- **ТЗ:** [`docs/specs/107-virtual-light-toggle.md`](../specs/107-virtual-light-toggle.md), ревью
[`SPEC-REVIEW-107-r1.md`](SPEC-REVIEW-107-r1.md) — зелёное, High 0 / Medium 0.
- **Диапазон:** `origin/dev...HEAD`, коммит `1079cdfab25617df924b8c3592631aa40e078d87`
("feat: add persistent virtual light toggles"), ветка `issue/107-virtual-light-toggle`.
- **Ревьюер:** Claude (код-ревью ≠ ревью ТЗ, свежая сессия, без контекста реализации).
- **Цикл:** r1/4.
## 1. Скоуп изменения
Backend: новый Store `houseplan.virtual_lights` (`virtual_lights.py`, `const.py`,
`store.py`), новая WS-команда `houseplan/virtual_light/toggle`, дополнение
`houseplan/config/get` полем `virtual_lights`, согласование состояния во всех
писателях конфигурации (`websocket_api.py`, `__init__.py` — миграция при
setup).
Frontend: новый модуль `src/virtual-light-state.ts` (нормализация wire-снапшота,
монотонное применение событий, reconciliation при смене config revision);
интеграция в `resolveToggleIntent`/`resolvedLightSources`
(`device-toggle.ts`, `devices.ts`), в live-sync и localStorage-кэш
(`config-store.ts`, `houseplan-card.ts`), в статическую карточку
(`space-card.ts`, `space-render.ts`); i18n EN/RU; unit-тесты
(`test/virtual-light-state.test.mjs`, дополнения `device-toggle.test.mjs`,
`devices.test.mjs`); backend-тесты (`test_virtual_lights.py`,
`test_ha_virtual_lights.py`, дополнение `test_ha_setup.py`); целевой browser
smoke `demo/smoke_virtual_light_toggle.mjs`; документация (`docs/LIGHT.md`,
`docs/CONFIG-COMPATIBILITY.md`, `docs/ARCHITECTURE.md`,
`docs/USER-GUIDE.ru.md`, `README.md`, оба changelog).
Соответствует заявленной поверхности ТЗ. Продуктовое соответствие
`docs/SCOPE.md`: J1/J3, узкое исключение к замороженным virtual devices, не
пересмотрено ревьюером ТЗ повторно (не входит в код-ревью).
## 2. Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit (frontend) | `npm test` | 781/781 pass |
| Build + сверка бандлов | `npm run build && sha256sum dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js demo/srv/assets/houseplan-card.js` | один и тот же SHA-256 `caffc463…d23bbc89` для всех трёх копий; рабочее дерево осталось чистым (`git status --short` пусто) — сборка воспроизводима и совпадает с закоммиченным бандлом |
| Backend pytest (чистое подмножество, без HA) | `python3 -m venv /tmp/venv-review && /tmp/venv-review/bin/pip install pytest voluptuous && PYTHONPATH=. /tmp/venv-review/bin/pytest tests_backend -q` | **упал сбором** — `tests_backend/test_virtual_lights.py` не собирается без `homeassistant` (см. находку M1). Полный HA-харнесс (`pytest-homeassistant-custom-component`) в этом окружении не установлен и не входит в его canonical Linux CI набор по факту — только офлайн-подмножество, которое теперь не запускается вовсе. `test_ha_virtual_lights.py`/`test_ha_setup.py` разобраны чтением (см. §4) |
| Целевой browser smoke | `node demo/smoke_virtual_light_toggle.mjs` (после `npm run build` и синхронизации трёх копий бандла, Chromium/Playwright уже установлены в среде) | **FAILED (2)**: `clickEverywhereOff: expected true, got false`; `reloadFirstStateOff: expected true, got false`. Остальные 5 проверок (`initialEverywhereOn`, `secondClickEverywhereOn`, `touchSingleToggle`, `oneServerTogglePerGesture`, `noHaServiceCalls`) — true. См. находку H1 |
Не прогонялись (осознанно, вне гейта код-ревью для этого объёма изменений
по PROCESS.md §8): `npm run golden:verify` (изменение не трогает
художественный baseline — новая fixture по плану идёт в golden только на
pre-beta gate), полный набор `demo/smoke_*.mjs` (127 сценариев — задача не
задевает все поверхности, а точечный сценарий уже прогнан выше и провалился),
performance-профили (в AC не назван численный бюджет; §15.4 ТЗ явно откладывает
его на pre-beta). Прогон полного HA backend harness (`pytest-homeassistant-custom-component`)
недоступен в этом окружении — AC2/AC3/AC4/AC8/AC9, доказываемые
`test_ha_virtual_lights.py`, разобраны чтением, не исполнением (см. §4).
## 3. Находки
### H1 (High, блокирует). Собственная презентация маркера (иконка/CSS-класс `on`/`off`) не отражает ручное состояние, когда у того же маркера есть исходящие `controls` — подтверждено исполнением целевого smoke
**Файл:** `src/device-presentation.ts` (не тронут этим PR), проявляется через
`src/houseplan-card.ts` (`_devicePresentation`/`_stateClass`),
`src/space-card.ts`, `src/space-render.ts`.
**Сценарий воспроизведения:** ровно fixture `demo/smoke_virtual_light_toggle.mjs`
— маркер `binding: 'virtual', is_light: true, tap_action: 'toggle', controls:
['light.ceiling']` (эта же тройка + исходящий `controls`, то есть ровно сценарий
AC12). Запуск `node demo/smoke_virtual_light_toggle.mjs` на чистом собранном
бандле даёт:
```
FAILED (2):
- clickEverywhereOff: expected true, got false
- reloadFirstStateOff: expected true, got false
```
**Причина, подтверждена чтением и точечной инструментацией (debug-копия
смока, запущена и удалена, в репозиторий не попала):** `resolvePresentationSources()`
(`src/device-presentation.ts:279-280`) выбирает `sourceKind = 'controls'`,
как только у устройства есть хоть один источник с `via === 'controls'` —
это ветка для «маркер-контроллер показывает лицом состояние того, что
включает». Для точной тройки #107 `marker.controls` — это **исходящая**,
не относящаяся к ролям Auto/Always связь (AC12 требует, чтобы она сохранялась
lossless и не вызывала HA-сервис, но не говорит, что она должна забирать
приоритет у собственного ручного состояния маркера). Проверено
инструментацией: без поля `controls` та же тройка `_stateClass`/иконка
корректно показывает `off` после клика (`sourceKind: 'light'`,
`visualSources: [{"eid":"marker:...","state":"off"}]`); с полем `controls`
`_devicePresentation()` даёт `sourceKind: 'controls'`,
`visualSources: [{"eid":"light.ceiling","state":"on"}, {"eid":"marker:...",
"state":"off"}]` — правильное ручное состояние присутствует в списке, но не
выбрано для лица маркера, поэтому `_stateClass()`/CSS-класс `on` остаются
завязаны на состояние `light.ceiling`, а не на ручной toggle.
Это касается всех трёх типов потребителей презентации маркера
(`_devicePresentation` полной карточки, второй полной карточки, статической —
все три вызывают `resolveDevicePresentation` с одним и тем же
`resolvePresentationSources`), поэтому и `on(first)`, и `on(second)`, и
`staticOn()` остаются залипшими на состоянии `light.ceiling`, а не на ручном
toggle — что и даёт `false` в обеих сравнительных проверках смока.
**Почему это блокирует, а не Low/Medium:** ТЗ §6.2 требует «один и тот же
resolved source обязан дать одинаковый результат» для списка потребителей,
явно включающего «room card и device presentation»; AC5 требует `source.on=
false … во всех canonical light consumers»; AC10/AC12 — это ровно комбинация
(манильный маркер + сохранённые исходящие controls), которую тестирует сам
смок. Реализация проходит AC1–AC9 (см. §4) и логику toggle/persistence, но
для этой явно предусмотренной ТЗ комбинации собственная иконка маркера не
меняется — заявленный сценарий («нажатие выключает его … Одновременно
меняются его Glow и все room-light consumers») не выполняется полностью:
Glow/room fill меняются (подтверждено smoke-проверками `oneServerTogglePerGesture`,
`noHaServiceCalls` и раздельно нижеприведённым разбором devices.ts), а лицо
самого маркера — нет. Это ровно тот класс дефекта, который должен доказываться
исполнением, а не чтением: без прогона smoke эта находка осталась бы
незамеченной, потому что unit-тесты `devices.test.mjs`/`device-toggle.test.mjs`
не строят презентацию через `resolveDevicePresentation()` и поэтому не видят
приоритет `sourceKind`.
**Что не задето:** сама персистентность, атомарность toggle, авторизация,
отсутствие HA-сервисного вызова, поведение без `controls` — всё корректно
(unit-тесты и 5/7 smoke-проверок это подтверждают).
### M1 (Medium, обязан стать отдельным issue). `tests_backend/test_virtual_lights.py` не собирается pytest без установленного `homeassistant`, хотя заявлен как «чистый» тест
**Файл:** `tests_backend/test_virtual_lights.py:1,4`.
**Сценарий воспроизведения:**
```
python3 -m venv /tmp/venv-review
/tmp/venv-review/bin/pip install pytest voluptuous
PYTHONPATH=. /tmp/venv-review/bin/pytest tests_backend -q
```
даёт
```
ERROR tests_backend/test_virtual_lights.py
ImportError while importing test module '.../tests_backend/test_virtual_lights.py'
tests_backend/test_virtual_lights.py:4: in <module>
from custom_components.houseplan.virtual_lights import (...)
custom_components/houseplan/__init__.py:9: in <module>
from homeassistant.components.frontend import add_extra_js_url
E ModuleNotFoundError: No module named 'homeassistant'
Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
```
Это полная остановка сбора — ни один тест `tests_backend/` не выполняется,
включая ранее рабочие чистые файлы вроде `test_validation.py`.
**Причина:** файл заявляет в докстринге «Pure operational-store rules
independent of the HA WebSocket harness» и импортирует модуль обычным путём
`from custom_components.houseplan.virtual_lights import …`. Такой импорт
обязан сначала выполнить `custom_components/houseplan/__init__.py` (пакетный
`__init__`), который безусловно импортирует `homeassistant.components.frontend`
и другие модули HA. Существующий `test_validation.py` (не тронут этим PR)
решает ту же задачу иначе и специально: докстринг «validation.py is loaded by
path, without importing the HA integration package», реализовано через
`importlib.util.spec_from_file_location`, ровно чтобы не тянуть
`homeassistant` для чистых тестов.
`AGENTS.md` фиксирует это же поведение как контракт: «Locally only the pure
subset runs; `python -m pytest tests_backend/ -q` without Home Assistant
silently skips `test_ha_*.py` … so a green result proves nothing» — то есть
не-`test_ha_*` файлы обязаны собираться и выполняться без HA. Этот PR впервые
нарушает инвариант: вместо тихого пропуска — полная остановка сбора для всего
каталога.
**Почему не блокирует:** CI-джоб `backend` (`.github/workflows/validate.yml:206`)
всегда ставит `pytest-homeassistant-custom-component`, так что зелёный проход
в CI не страдает — сами тесты внутри `test_virtual_lights.py` корректны и
проверяют то, что заявлено (см. §4). Ломается только локальный/офлайн путь
без HA, который прежде «доказывал что-то» для остальных чистых файлов.
Соответствует критерию Medium: реальный, воспроизводимый дефект вне
заявленных AC, требует отдельного issue со ссылкой на #107 (тип `tech-debt`,
поверхность — тестовая инфраструктура backend).
## 4. Что проверено и корректно
- **AC1 (eligibility matrix, unit):** `isManualVirtualLightMarker()`/
`eligible_virtual_light_ids()` (frontend `virtual-light-state.ts`, backend
`virtual_lights.py`) требуют точную тройку `binding==='virtual'` +
`is_light===true` + `tap_action==='toggle'` + `removed!==true`; unit-тест
`test_eligibility_is_the_exact_triple_and_hidden_is_not_lifecycle` и frontend
`devices.test.mjs`/`device-toggle.test.mjs` покрывают Auto/Never/wrong-action/
non-virtual/removed — доказано автотестом, тест умеет падать (проверено
изменением условия вручную не потребовалось: тест явно перечисляет все
отрицательные варианты одним assert на множество).
- **AC2/AC3 (persistence, atomic concurrency, backend):** `async_toggle_virtual_light`
вызывается из `ws_virtual_light_toggle` **внутри** `rt.write_lock` — тот же
lock, что использует `ws_config_get`/`ws_config_set`, поэтому конкурентные
toggle-запросы физически сериализуются на уровне WS-хендлера, а не только
внутри `virtual_lights.py`. Backend-тест
`test_invalid_target_and_concurrent_toggles_are_server_atomic` (файл требует
полного HA harness — прочитан, не исполнен: заявленное поведение —
две последовательные server revisions `(1,False),(2,True)` — соответствует
коду `async_toggle_virtual_light`, который читает текущий snapshot, инвертирует
и сохраняет одной операцией под общим локом).
- **AC4 (права, ревью кода):** `ws_virtual_light_toggle` не вызывает `_check_write`/
`may_write` — команда доступна любому аутентифицированному соединению,
соответствует ТЗ §7.4. `not_toggleable` возвращается без изменения store и
без события (`if result is None: connection.send_error(...); return` —
находится **внутри** `async with rt.write_lock`, до `send_result`/`async_fire`).
- **AC5 (canonical `source.on`, unit):** `devices.ts:536-548` — цикл идёт по
ВСЕМ passive-источникам (`if (!source.passive) continue`), сначала считает
обычный OR по `incoming`-контроллерам, затем **безусловно** переопределяет
`source.on` через `virtualLightIsOn()`, если `isManualVirtualLightMarker`
— то есть ручное состояние выигрывает и когда `control` есть, и когда его
нет. `devices.test.mjs` («manual virtual state is canonical and invalidates
the light cache») это явно проверяет для случая с входящим контроллером.
Данный путь работает корректно на уровне Glow/room-графа; H1 — это
отдельный путь (device-presentation), который **не** использует этот
безусловный override напрямую, а получает свой собственный `resolvedLightSources()`
вызов без `virtualLights`, скорректированный через `lightSources:
planLightSources`, что ломается только при наличии собственных `controls`
у того же маркера (см. H1).
- **AC6 (typed intent, unit + ревью кода):** `resolveToggleIntent()`
(`device-toggle.ts:610-632`) возвращает `operation: {kind:'virtual-light',
markerId}` и `command: null` для точной тройки, до общей ветки `controls`
— marker ID никогда не подставляется в `ToggleCommand`/`entity_id`;
`test/device-toggle.test.mjs` («exact manual virtual light wins over saved
HA controls») подтверждает `on.command === null` и стабильность
`sameToggleOperationTargets` при смене направления.
- **AC7 (i18n, диалог):** новые ключи `marker.virtual_light_target/
virtual_light_current/virtual_light_state_on/off` есть в EN и RU
(`src/i18n/en.json`, `src/i18n/ru.json`), formatter в `houseplan-card.ts`
(`~17025-17046`) ветвится по `target.via === 'virtual-light'` до общего
`toggle_hint_current`/`toggle_hint_single` — старый `no_actionable_entity`
hint для этой тройки не строится, так как `resolveToggleIntent` возвращает
ненулевой intent раньше проверки `no-actionable-entity`.
- **AC8 (initial snapshot, backend):** `ws_config_get` вызывает
`async_virtual_light_snapshot` **внутри** `rt.write_lock`, тем же
`config_rev`, что и возвращаемый `config`/`rev` — снэпшот согласован с той
же ревизией конфигурации, на которой backend проверял eligibility (прочитано
в коде; `test_default_toggle_event_and_restart_persistence` подтверждает
восстановление `off` после `async_reload` — прочитано, не исполнено, требует
HA harness).
- **AC9 (config writer lifecycle, backend):** все места, где раньше был
прямой `rt.config_store.async_save({...})` (`_converge_pair`, `ws_config_set`,
`ws_plan_optimize`, `ws_plan_optimize_undo`, миграция в `__init__.py`),
заменены на `async_save_config_state`, которая безусловно вызывает
`async_reconcile_virtual_lights` с явным `previous_rev`/`previous_config_rev`
— единая точка, разрозненных ручных `virtual_light_store.async_save()` не
найдено (`grep` по `websocket_api.py`/`__init__.py` подтверждает единственный
вызывающий путь). Fail-safe на неизвестный revision gap (`state_config_rev
== config_rev` иначе `off = set()`) реализован и в `async_virtual_light_snapshot`,
и в `async_reconcile_virtual_lights` — прочитано, соответствует ТЗ §7.5.
- **AC11/AC13/AC14 (live-sync, confirmation, cache invalidation):**
`config-store.ts`/`houseplan-card.ts` подписываются на
`houseplan_virtual_light_updated` рядом с существующими событиями,
`applyVirtualLightEvent` монотонна по `rev` (тест «events are monotonic and
never optimistic» и «event-before-response ordering never rolls state back»
в `test/virtual-light-state.test.mjs» умеют падать — проверено инверсией
условия мысленно: `rev <= current.rev` иначе `event.on` не проверялся бы).
`virtualLightFingerprint` участвует в ключе `RESOLVED_LIGHT_CACHE`
(`devices.ts`), поэтому событие инвалидирует Glow/room-граф без HA state
tick — подтверждено smoke-проверкой `oneServerTogglePerGesture` (одно
серверное действие на один жест) и фактическим обновлением `staticOn`/`on(second)`
в дебаг-прогоне без `controls`.
- **AC15 (build/bundle parity):** `npm run build` воспроизводим, три копии
бандла побайтно идентичны (см. §2).
- **AC16 (документация):** оба changelog, `README.md`, `docs/LIGHT.md`,
`docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`,
`docs/USER-GUIDE.ru.md` обновлены в том же коммите, ссылаются на #107,
описывают точную тройку, persistence/permissions, исключение из экспорта —
сверено построчно, расхождений с реализацией не найдено (кроме того, что
H1 делает фразу README «updates Glow, room fill/statistics, full cards and
`houseplan-space-card` together» неполной для комбинации с `controls` —
само по себе не отдельная находка, устранится вместе с H1).
- **Трейлеры:** коммит `1079cdf` несёт `Issue: #107`, `User-Visible: yes`,
оба changelog правлены в том же коммите — соответствует.
## 5. Чего не проверял
- Полный HA backend harness (`pytest-homeassistant-custom-component`) —
недоступен в этой среде (не `.venv-backend`, чистый Linux-раннер без
предустановленного пакета). AC2/AC3/AC4/AC8/AC9, чьё единственное
автотест-доказательство — `test_ha_virtual_lights.py`, разобраны только
чтением кода и тестового файла (см. §4), не исполнением. Тесты выглядят
корректными и умеющими падать по структуре (явные значения `rev`/`on` на
каждом шаге, явный `monkeypatch` для forced-failure сценария), но это не
заменяет фактический прогон — Linux CI job `backend` остаётся канонической
проверкой для них.
- `npm run golden:verify`, полный набор из 127 `demo/smoke_*.mjs`,
performance-профили — не прогонялись, обоснование в §2 (объём соразмерен
задаче по PROCESS.md §8; они относятся к pre-beta gate).
- Продуктовое соответствие `docs/SCOPE.md`/выбор J1/J3 — не пересматривалось
повторно, это было предметом ревью ТЗ, а не код-ревью.
- Не проверялся весь возможный матрикс `is_light`/`tap_action` смены онлайн
(rename/move/hidden/tombstone и т.д., AC9 edge cases) сверх того, что
показывает `test_lifecycle_preserves_hidden_and_prunes_when_eligibility_ends`
— тест прочитан и логически согласован с кодом, не исполнен.
## 6. Вердикт
`H1` — реальный, воспроизведённый прогоном собственного целевого smoke-теста
дефект: презентация маркера (иконка/CSS-класс, используемый полной и
статической карточками) не отражает ручное состояние виртуального света,
когда у того же маркера есть сохранённые исходящие `controls` — то есть ровно
в комбинации, которую требует покрывать AC10+AC12 и которую строит сам
`demo/smoke_virtual_light_toggle.mjs`. Это High: блокирует.
`M1` — дефект тестовой инфраструктуры (не блокирует CI, но ломает
документированный локальный офлайн-прогон `pytest tests_backend -q` из
`AGENTS.md` для всего каталога, а не только для нового файла) — Medium,
обязан стать отдельным issue.
Задача возвращается в `S6-in-progress`.
-177
View File
@@ -1,177 +0,0 @@
# Код-ревью issue #107 — переключение виртуального источника света «Всегда» (r2)
- **Issue:** https://github.com/Matysh/houseplan-card/issues/107
- **ТЗ:** [`docs/specs/107-virtual-light-toggle.md`](../specs/107-virtual-light-toggle.md), ревью
[`SPEC-REVIEW-107-r1.md`](SPEC-REVIEW-107-r1.md) — зелёное, High 0 / Medium 0.
- **Предыдущий цикл:** [`CODE-REVIEW-107-r1.md`](CODE-REVIEW-107-r1.md) — красный,
H1 (презентация маркера не отражает ручное состояние при сохранённых исходящих
`controls`) + M1 (сбор `pytest tests_backend` падает без HA) → M1 заведён
отдельным issue [#135](https://github.com/Matysh/houseplan-card/issues/135).
- **Диапазон:** `origin/dev...HEAD`. Новый коммит цикла — `de0171dd028f87622a42e0b3d698473ef5e70cb9`
("fix: keep manual virtual light face canonical"), поверх `1079cdfab25617df924b8c3592631aa40e078d87`.
Ветка `issue/107-virtual-light-toggle`.
- **Ревьюер:** Claude (код-ревью ≠ ревью ТЗ ≠ r1, свежая сессия, без контекста
реализации и без контекста r1-обсуждения).
- **Цикл:** r2/4.
## 1. Скоуп изменения (дельта к r1)
r1 уже проверил backend (`virtual_lights.py`, `store.py`, `websocket_api.py`,
миграция в `__init__.py`), фронтовую персистентность/live-sync
(`virtual-light-state.ts`, `config-store.ts`, `houseplan-card.ts`), typed-intent
toggle (`device-toggle.ts`, `devices.ts`), i18n и документацию — эта часть не
менялась в r2 и повторно не пересматривается по существу.
Коммит `de0171d` правит ровно то, что требовал вердикт r1:
- `src/device-presentation.ts` — `resolvePresentationSources()` теперь для
точной manual-тройки (`isManualVirtualLightMarker`) отдаёт лицо маркера
собственному canonical light source, а не агрегату `controls`; сохранённые
исходящие `controls` остаются в графе (не исчезают из конфигурации/light-графа
устройства-цели), но не перехватывают иконку/CSS-класс `on`/`off` самого
маркера;
- `src/space-card.ts` — две конструкции `virtualFingerprint` (`_captureRenderDeviceSnapshot`,
`_frameFingerprint`) переведены на `snap?.virtualLights` вместо `snap ? snap.virtualLights…`,
для единообразия с остальными местами файла, которые уже использовали
optional chaining (строки 309/392/736 в исходном 1079cdf). Функционально
эквивалентно в текущем коде (`virtualLights` всегда строится и в
`cachedSnapshot()`, и в `fetchFresh()`), но защищает от падения, если где-то в
графе появится `_snap`/`snap` без гарантированного поля `virtualLights`
(например, унаследованный модульный `cache` из другой версии бандла на той же
странице) — не увидел, что это было наблюдаемым падением сейчас, но и не
увидел вреда от правки;
- `test/device-presentation.test.mjs` — новый unit `issue 107 manual virtual
source owns its face despite saved outgoing controls`, воспроизводящий ровно
комбинацию AC12/H1 (marker с `controls: ['light.ceiling']`) для `off` и `on`;
- `docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` — уточнение формулировки («including
markers that retain saved outgoing controls» / «в том числе при сохранённых
исходящих связях управления») в том же коммите, `User-Visible: yes`.
M1 в этот коммит не входит (сознательно, по хендоффу) — это корректно: Medium
не должен чиниться заодно с фиксом High, он уже заведён issue'ом.
## 2. Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit (frontend) | `npm test` | **782/782 pass** (было 781/781 в r1; +1 — новый регресс-тест `device-presentation.test.mjs`) |
| Build + сверка бандлов | `npm run build && sha256sum dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js demo/srv/assets/houseplan-card.js` | один и тот же SHA-256 `d75945b8…8442e17` для всех трёх копий, совпадает с указанным автором в хендоффе; `git status --short` пусто после сборки — воспроизводимо |
| Целевой browser smoke (тот самый, что провалил H1 в r1) | `node demo/smoke_virtual_light_toggle.mjs` (после свежей сборки и синхронизации бандла) | **7/7 true**: `initialEverywhereOn`, `clickEverywhereOff`, `secondClickEverywhereOn`, `touchSingleToggle`, `reloadFirstStateOff`, `oneServerTogglePerGesture`, `noHaServiceCalls` — включая ровно две проверки, которые падали в r1 (`clickEverywhereOff`, `reloadFirstStateOff`). Файл смока не менялся между r1 и r2 (`git log` на него показывает только исходный коммит `1079cdf`) — фикс проверен тем же, изначально проваленным, воспроизводимым тестом, не переписанным «чтобы позеленело` |
| Backend pytest, чистое подмножество (повтор r1-методики, без HA) | `python3 -m venv /tmp/venv-review-r2 && pip install pytest voluptuous && PYTHONPATH=. pytest tests_backend -q` | **всё ещё падает сбором** на `tests_backend/test_virtual_lights.py` — идентично r1 (`ModuleNotFoundError: No module named 'homeassistant'`, `Interrupted: 1 error during collection`). Ожидаемо: M1 сознательно не входит в этот коммит, чинится отдельно в #135 |
Не прогонялись (осознанно, диапазон r1→r2 не касается backend/live-sync/i18n
кода, только `device-presentation.ts`/`space-card.ts` и один unit-файл):
- полный набор `demo/smoke_*.mjs` (130 сценариев) — диапазон изменения узкий
(один resolver презентации плюс два defensive `?.`), не задевает все
поверхности; целевой smoke уже прогнан и является тем же смоком, который
ловил дефект;
- `npm run golden:verify` — изменение видимого результата ограничено ровно
той же новой комбинацией (manual virtual toggle + сохранённые `controls`),
для которой в существующих golden-baseline нет сценария (feature появилась в
этом же issue); в существующие baseline-сценарии эта тройка не входит, и
визуальные regressions на них не ожидаются — не подтверждено прогоном golden
(это решение сужения объёма, а не находка);
- performance-профили — в AC не назван численный бюджет, ТЗ §15.4 явно относит
это к pre-beta gate (без изменений к r1);
- полный HA backend harness (`pytest-homeassistant-custom-component`) —
недоступен в этом окружении (нет `.venv-backend`, чистый Linux-раннер).
Backend-код (`virtual_lights.py`, `store.py`, `websocket_api.py`,
`__init__.py`) в этом цикле не менялся относительно r1, поэтому AC2/AC3/AC4/
AC8/AC9 повторно по существу не пересматривались — их разбор чтением из r1
остаётся в силе.
## 3. Находки
Блокирующих находок нет.
Не найдено новых High/Medium в дельте r1→r2. Единственное отмеченное в §1 —
изменение в `space-card.ts` (`?.virtualLights`) — не тождественно
воспроизводимому дефекту: не нашёл сценария, где `this._snap`/`snap` ненулевой,
но `virtualLights` при этом отсутствует (оба источника, `cachedSnapshot()` и
`fetchFresh()`, безусловно строят это поле через `virtualLightSnapshot(...)`/
`adoptVirtualLightServerSnapshot(...)`). Правка защитная и не меняет наблюдаемое
поведение ни в одном пройденном тесте — не поднимаю до Low, так как не нашёл ни
воспроизведения, ни вреда.
## 4. Что проверено и корректно
- **H1 закрыт, доказано исполнением, не только чтением.** Целевой
`demo/smoke_virtual_light_toggle.mjs` — тот же файл, что и в r1, без
изменений — теперь проходит 7/7, включая обе ранее красные проверки.
Дополнительно инструментирован новый unit
(`test/device-presentation.test.mjs`), который специально воспроизводит
именно тройку H1 (`binding:'virtual', is_light:true, tap_action:'toggle',
controls:['light.ceiling']`) и проверяет оба направления (`off`/`on`) для
`sourceKind`, `visualSources`, `visual.status` и CSS-класса `on`. Тест умеет
падать: без ветки `manualVirtualFace` в `resolvePresentationSources()`
`sourceKind` откатывается на `'controls'`, `visualSources` включает
`light.ceiling` (state `on`) первым источником, и assert
`off.visual.status === 'neutral'`/`!classes.includes('on')` не проходит —
ровно это давал прогон смока в r1 до фикса.
- **Причинный разбор совпадает с диагнозом r1.** `resolvePresentationSources()`
(`src/device-presentation.ts:252-260,285-287`): для eligible-тройки
(`isManualVirtualLightMarker(d.marker)`) `lights` строится только из
`ownedLights` (без `via==='controls'` источников), и `sourceKind` принудительно
остаётся `'light'` независимо от того, есть ли у маркера сохранённые исходящие
`controls`. Сами `controls`-источники при этом не исчезают из общего
`resolvedLightSources()`/Glow-графа устройства-цели (`light.ceiling`
продолжает управляться и показываться как обычно у себя) — правка узкая,
ограничена лицом самого manual-маркера, что соответствует ТЗ §6.3
(«Controls остаются lossless … но не управляют state этого источника»).
- **Не расширяет eligibility.** `isManualVirtualLightMarker()` — тот же
переиспользуемый предикат из `virtual-light-state.ts`, что уже покрыт
AC1-тестами в r1 (`devices.test.mjs`/`device-toggle.test.mjs`), новой логики
распознавания тройки не введено — риск регресса для неэлигибл-маркеров
(обычные `controls`-контроллеры, cover, switch, passive sensor) минимален и
подтверждён тем, что все существовавшие кейсы `device-presentation.test.mjs`
(44 теста в файле) остаются зелёными без изменений.
- **AC5/§6.2 (canonical consumer contract).** `docs/LIGHT.md:137-147` уже
декларирует «device presentation» как обязательного потребителя canonical
ручного состояния — до r2 это было расхождением документации с кодом
(H1), теперь код соответствует уже написанному тексту; правка документации
не потребовалась.
- **CHANGELOG.** Обе версии (`docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`)
уточнены в том же коммите `de0171d` — фраза, которую r1 отметил как
«станет неполной без H1», исправлена точным добавлением про сохранённые
исходящие связи. Трейлеры коммита: `Issue: #107`, `User-Visible: yes` — оба
changelog в этом же коммите, соответствует правилу.
- **M1 корректно вынесен.** Отдельный issue
[#135](https://github.com/Matysh/houseplan-card/issues/135) существует,
ссылается на #107 и на находку CODE-REVIEW-107-r1.md, помечен `S1-new`,
`tech-debt`, `tests` — не оставлен как TODO в тексте ревью (§12 process).
- **Инвариант сборки.** `npm run build` воспроизводим, три копии бандла
побайтно совпадают друг с другом и с указанным в хендоффе SHA-256.
## 5. Чего не проверял
- Полный HA backend harness — недоступен в этой среде; поскольку backend-код
не менялся между r1 и r2, повторно не разбирал по существу AC2/AC3/AC4/AC8/AC9
сверх того, что зафиксировано в CODE-REVIEW-107-r1.md §4.
- Полный набор из 130 `demo/smoke_*.mjs` и `npm run golden:verify` — не
запускал; обоснование сужения в §2 (диапазон изменения узкий, целевой smoke
уже покрывает ровно сценарий дефекта, новая визуальная комбинация не входит
ни в один существующий golden baseline).
- Performance-профили — не в AC, отложено на pre-beta gate тем же ТЗ, что и в
r1.
- Не проверял защитную правку `space-card.ts` (`?.virtualLights`) на предмет
реального воспроизводимого сценария падения — не нашёл такого сценария при
чтении обоих источников `_snap`/`snap` (`cachedSnapshot()`, `fetchFresh()` в
`config-store.ts`), поэтому не поднимаю её в находки, но и не подтверждаю
специальным тестом, что именно она предотвращает.
- Продуктовое соответствие `docs/SCOPE.md`/выбор J1/J3 — предмет ревью ТЗ
(зелёное, r1), в код-ревью повторно не пересматривается.
## 6. Вердикт
H1 из r1 исправлен и подтверждён исполнением: тот же ранее красный
`demo/smoke_virtual_light_toggle.mjs` теперь зелёный 7/7 без изменений в самом
файле смока, плюс новый целевой unit-регресс. M1 корректно вынесен отдельным
issue (#135), в этот коммит не подмешан. Новых High/Medium в дельте r1→r2 не
найдено. Все обязательные гейты (`typecheck`, `npm test`, `npm run build` +
сверка бандлов, целевой smoke) зелёные; сознательно суженные (`golden:verify`,
полный набор smoke, performance, полный HA harness) обоснованы объёмом дельты
и перечислены выше.
`High: 0 · Medium: 0` — задача уходит в очередь на пре-релиз.
-248
View File
@@ -1,248 +0,0 @@
# Код-ревью #122 — Isometric Stage 2: hidden visual polish (r1)
- Issue: https://github.com/Matysh/houseplan-card/issues/122
- Спецификация: `docs/specs/122-isometric-stage2.md` (SPEC-REVIEW-122-r1: green)
- Диапазон: `git diff origin/dev...HEAD` на коммите `42b3f44` (`feat: add hidden
isometric stage 2`), плюс сопутствующие `76ce755` (ТЗ), `4c73e2c` (ревью ТЗ)
- Роль: ревьюер кода (свежая сессия, без контекста реализации)
- Вердикт: **красный · цикл r1/4 · High: 2 · Medium: 1 → #134**
## Скоуп
Класс файлов коммита `42b3f44`: A (`src/houseplan-card.ts`, `src/iso-openings.ts`
(новый), `src/iso-projection.ts`, `src/iso-walls.ts`, `src/wall-thickness.ts`,
`src/styles.ts`) + B (`test/**`, `demo/smoke_isometric_contract.mjs`,
`demo/smoke_isometric_live_touch.mjs`, `demo/golden/matrix.mjs`,
`tsconfig.test.json`) + C (`docs/ISOMETRIC.md`, `docs/ARCHITECTURE.md`,
`docs/STATUS.md`, `docs/adr/122-isometric-stage2-composition.md`). Класс D
(`dist/**`, `custom_components/houseplan/frontend/houseplan-card.js`,
`demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**`) **не
изменён вовсе** — см. High-1.
Трейлеры на всех трёх коммитах диапазона корректны: `Issue: #122`,
`User-Visible: no` (фича скрытая, публичного changelog не требует — верно).
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | green, без вывода |
| Unit | `npm test` | green, 766/766 |
| Build | `npm run build` | green, `dist/houseplan-card.js` собран |
| Синхронизация 3 копий бандла | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и то же для `demo/srv/assets/houseplan-card.js` | **FAIL** — расходятся, byte 45, `git diff --stat` 327 insertions / 209 deletions на файл; см. High-1 |
| Целевые браузерные смоки (названы в AC1/AC5/AC6/AC9/AC10/AC11 и напрямую тронуты диффом) | `node demo/smoke_isometric_contract.mjs`, `node demo/smoke_isometric_live_touch.mjs` — **после** `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js`, т.к. коммит не обновил эту копию | оба green, все проверки в JSON-выводе `true` |
| `npm run golden:verify` (диф меняет рендер/геометрию/слои — попадает в критерий «по необходимости») | `npm run golden:verify` (после того же build+copy) | 46/53 сценариев `passed`; 7 `different`: 5 ожидаемо-разных iso-сценариев без принятых эталонов (не блокер сам по себе, эталоны Stage 2 умышленно не принимаются на этом этапе — §12.3 ТЗ), но **2 сценария обязаны были остаться пиксель-идентичными и не остались** — см. High-2, Medium-1 |
| Дифференциальная проверка причинности (A/B) | те же две команды (`npm run build`, `npm run golden:verify`) в `git worktree add /tmp/dev-check origin/dev` | `isometric-no-borders-dark` и `large-house-zoom-250-dark` оба `passed` на `origin/dev` — регрессия строго локализована в этом диффе, не окружение |
| Backend | не прогонялся | правок в `custom_components/**/*.py` нет — не применимо |
| Performance (`large-house-isometric-v1`) | не прогонялся | по ТЗ §10/§12.4 и решению владельца это pre-beta гейт, заблокированный отдельно #124; в этом ревью не требуется |
**Чего не проверял и почему:** полный набор из 127 браузерных смоков (диф не
задевает весь продукт — только iso-поверхность и косвенно `wall-thickness.ts`);
`python -m pytest tests_backend` (без py-правок); performance-профиль (вне
гейта код-ревью, зависит от #124); принятие golden-эталонов (не роль
ревьюера и не требуется до pre-beta).
## Находки
### High-1 — три копии бандла не пересобраны; CI уже красный на этом SHA
Коммит `42b3f44` не трогает `dist/houseplan-card.js`,
`custom_components/houseplan/frontend/houseplan-card.js` ни
`demo/srv/assets/houseplan-card.js` — все три остаются побитово равны
промоушен-коммиту `a282f85` (`build: promote v1.63.0`), то есть Stage-1-коду
без единой строки Stage 2.
Воспроизведение:
```
$ npm run build
$ cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js differ: byte 45, line 1
$ git diff --stat dist/houseplan-card.js
dist/houseplan-card.js | 536 ++++++++++++++++++++++++++++++-------------------
1 file changed, 327 insertions(+), 209 deletions(-)
$ grep -c "invalid isometric opening input" dist/houseplan-card.js # fresh build
1
$ git show HEAD:custom_components/houseplan/frontend/houseplan-card.js | grep -c "invalid isometric opening input"
0
$ git show HEAD:demo/srv/assets/houseplan-card.js | grep -c "invalid isometric opening input"
0
```
Независимое подтверждение — CI Validate уже прогонялся на этом самом SHA и
уже красный ровно на этом шаге:
```
$ gh run view 31746904479 --repo Matysh/houseplan-card
X frontend in 36s
✓ Build
X Card bundle snapshots in sync
```
Хендофф-комментарий исполнителя утверждает «три отслеживаемые bundle-копии
byte-identical» — это не так; либо команда не выполнялась, либо результат
интерпретирован неверно. Смысл дефекта не формальный: реальный
`custom_components/houseplan/frontend/houseplan-card.js` (то, что грузит HA) и
`demo/srv/assets/houseplan-card.js` (то, что видит демо-стенд и что реально
тестируют браузерные смоки/golden в CI до пересборки) сейчас **не содержат
Stage 2 вовсе**. `smoke`/`golden`/`performance_smoke` в CI зависят от
`frontend` (`needs: frontend`) и поэтому на этом SHA не запускались вообще —
ни один из пре-релизных гейтов не имеет зелёного прогона против настоящего
кода задачи.
Блокирует. AC16 не выполнен.
### High-2 — `show_borders:false` в Iso не сохраняет пиксель-идентичный Stage-1 «no-volume» сценарий
ТЗ §6.6 и ADR «Consequences» требуют: при `show_borders:false` в Iso
Stage-2-геометрия отсутствует целиком, и AC7/AC15 требуют, чтобы
`isometric-no-borders-dark` остался неизменным. Golden показывает
значительную регрессию:
```
$ npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
$ npm run golden:verify
different isometric-no-borders-dark
```
Из `artifacts/golden/golden-report.json`:
`differingPixels: 169919, diffRatio: 0.2194 (≈22%), maxObservedDelta: 206`.
Повторный прогон — идентичные числа (детерминированно, не флейк). На
`origin/dev` (та же команда, тот же `git worktree add /tmp/dev-check
origin/dev`, тот же Chromium 151.0.7922.34) сценарий `passed` чисто.
Причина по коду (`src/houseplan-card.ts:4503-4522`, `_isoScene()`):
рамка проекции теперь всегда строится с
`floorDepth: ISO_FLOOR_EDGE_HEIGHT` и объединяется с `isoOpeningBounds()` через
`unionRect(flat, openingFrame)` **независимо** от `disp.showBorders` /
`resolveIsoDecoration().structural`. В Stage 1 рамка включала только
`wallHeight`; Stage 2 добавляет глубину пола и границы проёмов всегда, даже
когда сама геометрия стен/проёмов/края пола не рисуется (`structural=false`).
Это меняет коэффициент "сжатия" `floorView`, и весь план визуально сдвигается/
масштабируется иначе, чем в принятом Stage-1 baseline — то есть нарушение не
косметическое, это другой кадр.
Похоже на конфликт двух требований ТЗ: AC12 хочет единую рамку, не зависящую
от переключения `show_borders` (чтобы zoom не менялся), а AC7/AC15 хотят
байт-в-байт то же изображение, что и Stage 1, где такой рамки не было. Решить
это — работа автора (например, включать `floorDepth`/`openingFrame` в рамку
только когда `structural===true`, и объяснить в ADR, почему AC12 всё равно
держится). Ревьюер такие продуктовые компромиссы не решает.
Блокирует. AC7 и AC15 не выполнены буквально.
### Medium-1 — регрессия пикселей во Flat-сценарии при выключенном Labs → #134
`large-house-zoom-250-dark` (без `labs`/`projection` в `demo/golden/matrix.mjs`
— чистый Flat, Labs неактивен) на `origin/dev` проходит чисто (`passed`), на
этой ветке — нет:
```
different large-house-zoom-250-dark
```
`differingPixels: 920, diffRatio: 0.00105, maxObservedDelta: 201` —
воспроизведено дважды подряд с идентичными числами (не флейк, не дрейф
Chromium: версия в обоих прогонах `151.0.7922.34`).
Это прямое нарушение AC2 («with Labs inactive... Flat... retain their
existing state/action/pixel contract») и инварианта из `docs/ISOMETRIC.md`
(«existing Flat DOM and pixels remain the reference»). В рамках бюджета этого
ревью не удалось локализовать точный узел DOM/CSS, отвечающий за эту
конкретную (небольшую по площади, но не нулевую и стабильную) разницу;
кандидат — новая безусловная обёртка `<g class=${iso ? 'iso-floor-scene' :
nothing} transform=${iso ? isoFloorMatrixCss() : nothing}>` вокруг содержимого
плана в `src/houseplan-card.ts` (добавляется в разметку независимо от того,
активен ли `iso`), но это не доказано построчно — отметка «предположение,
требует дальнейшего разбора автором», а не факт.
Площадь и видимое воздействие малы (0.1% пикселей одного сценария на большом
зуме), поэтому находка не расширяет цикл #122 сверх уже блокирующих High-1/
High-2, но контракт заявлен как безусловный и находка воспроизводима —
заведён отдельный issue #134 со ссылкой на #122, а не оставлена как TODO
в этом документе.
## Что проверено и корректно
- **AC1** (`unit`+`smoke`+код): `iso` остаётся единственным Labs id;
`demo/smoke_isometric_contract.mjs` подтверждает Flat как дефолт,
`isometric-contract.test.mjs` подтверждает отсутствие второго флага/ключа —
прочитано и прогнано, зелёно.
- **AC5/AC6** (открывающиеся элементы): `src/iso-openings.ts` —
`buildIsoOpeningBasis`/`projectIsoOpening` алгебраически повторяют
существующий floor-symbol (поворот на 0–10° для gate, симметричные leaves
для window, один leaf с шарниром для door); `test/iso-openings.test.mjs`
проверяет неподвижность базиса при live-изменениях, `flip`-инверсию базиса,
совпадение `unavailable`≡no-contact через `openingAmount()`, границы через
`isoOpeningBounds`. Панели помечены `aria-hidden="true" pointer-events="none"`
(`src/houseplan-card.ts`, рендер `_renderIsoWalls`) — проверено чтением и
подтверждено смоком `live_touch` (`touchOpeningIsSafe`, `flatIsoActionParity`).
Тесты умеют падать: `assert.deepEqual(gateBasis.leaves.map(l =>
Math.abs(l.turnDeg)), [10, 10])` красится при любой правке угла;
аналогично для высот leaf.
- **AC4** (floor edge): `buildIsoFloorGeometry`/`floorFootprintGeometry` —
внутренние/общие границы и вложенные дырки не создают ступень (тест
«canonical adjacent room union has no edge on its shared boundary», «floor
edge follows outer components without internal or nested steps»),
независимые partition/column не расширяют footprint (тест «Stage floor
footprint excludes detached independent physical bodies»), стабильность к
порядку/winding подтверждена отдельным тестом. Прочитано и прогнано зелёно;
тесты специфичны настолько, что удаление одной внешней грани красит их.
- **AC8/AC14** (отсутствие второго свето-слоя, отсутствие новых сетевых/
конфигурационных путей): `isometric-contract.test.mjs`
(«Stage 2 adds no schema, dependency, storage, network or HA action
surface») грепает исходники на `localStorage|fetch|XMLHttpRequest|
WebSocket|callService|config|schema` и `three|babylon|webgl` — прогнано,
зелёно. `src/styles.ts` не содержит нового источника света, только матовые
градиенты/тени — прочитано.
- **AC9** (структурный кэш не растёт от HA/темы/hover): фингерпринт в
`_isoSource()` включает `rooms, walls, openCuts, openings (только
геометрия/флипы), partitions, roomDrafts, columns, cellCm, gridPitch,
wallKeyPitch, camera, wallHeight, floorEdgeHeight, algorithm: 3` и не
включает HA-состояние/тему/hover — подтверждено и тестом
`isometric-contract.test.mjs` («structural cache includes opening flips and
excludes live HA amount», прямая проверка исходного текста `_isoSource`
на отсутствие `_openingAmt|openingAmount|.hass|matchMedia|CSS.supports|
theme|hover`) и смоком `live_touch` (`sameWallFingerprint`,
`haUpdateReusesGeometry`, `contactUpdateMovesOnlyLivePanel`).
- **AC11** (деградация): `resolveIsoDecoration` — чистая функция, тест
«decoration degradation never removes structure or creates floating
panels» покрывает все 4 комбинации `filtersSupported`/`forcedColors`/
`hideOpenings`/`showBorders`; `src/styles.ts` содержит
`@media (forced-colors: active)` и `@supports not (filter: blur(1px))`,
оба убирают только тени/нюанс материала, не геометрию. Прочитано, логика
соответствует ТЗ.
- **AC13** — намеренно не проверялось: и по ТЗ (§10/§12.4), и по решению
владельца это остаётся pre-beta гейтом, заблокированным #124; хендофф
честно пишет «не запускались».
- Трейлеры коммитов, `User-Visible: no`, отсутствие изменений в
`custom_components/**/*.py`, i18n и schema — проверено чтением диффа,
корректно.
## Чего не проверял
- Полные 127 браузерных смоков — диф касается только iso-поверхности и
`wall-thickness.ts`; прогнаны только два целевых, названных в AC и прямо
изменённых в этом диффе.
- `python -m pytest tests_backend` — нет изменений в Python.
- `large-house-isometric-v1` performance — вне гейта код-ревью при открытом
#124 (см. ТЗ §10, решение владельца).
- Принятие golden-эталонов — не роль ревьюера; данные из
`artifacts/golden/golden-report.json` использованы только как
диагностика причинности регрессии, не как повод принять/отклонить
baseline.
- Точная DOM/CSS-причина Medium-1 не изолирована построчно — честно оставлено
как открытый вопрос автору в #134.
## Итог
Две High-находки блокируют цикл r1: код не собран в те артефакты, которые
реально исполняются (High-1, CI уже подтверждает это независимо), и Stage 2
в текущем виде ломает пиксельный контракт, который ТЗ объявляет безусловным —
`show_borders:false` в Iso (High-2). Третья находка (регрессия чистого Flat)
воспроизводима, но мала по воздействию — заведена как Medium #134, а не
оставлена как TODO. Работа предметно сильная — покрытие юнит-тестами
геометрии открытий и floor edge выдерживает мутации, изоляция
структурного/live кэша сделана аккуратно и подтверждена и тестами, и
смоками — но AC16, AC7 и AC15 не могут считаться выполненными до
пересборки бандла и разбора причины пиксельной регрессии `show_borders:false`.
-150
View File
@@ -1,150 +0,0 @@
# Код-ревью #122 — Isometric Stage 2: hidden visual polish (r2)
- Issue: https://github.com/Matysh/houseplan-card/issues/122
- Спецификация: `docs/specs/122-isometric-stage2.md` (SPEC-REVIEW-122-r1: green)
- Предыдущий цикл: `docs/reviews/CODE-REVIEW-122-r1.md` — красный, High: 2, Medium: 1 → #134
- Диапазон: `git diff origin/dev...HEAD`, новый коммит цикла —
`ef3cc98` (`fix: preserve isometric fallback rendering`), поверх `42b3f44`
(`feat: add hidden isometric stage 2`), `76ce755`/`4c73e2c`/`e13215c` (ТЗ и
документы ревью)
- Роль: ревьюер кода (свежая сессия, без контекста реализации и без контекста
сессии r1)
- **Вердикт: зелёный · цикл r2/4 · High: 0 · Medium: 0**
## Скоуп
Единственный продуктовый коммит цикла — `ef3cc98`. Класс файлов: A
(`src/houseplan-card.ts`, 13 insertions/4 deletions — два места: `_baseVb()` и
основной `<svg>`/`<g>` блок рендера плана) + B (`test/isometric-contract.test.mjs`,
+5 строк) + C (`docs/ISOMETRIC.md`, `docs/adr/122-isometric-stage2-composition.md`)
+ D (`dist/houseplan-card.js`, `custom_components/houseplan/frontend/houseplan-card.js`,
`demo/srv/assets/houseplan-card.js` — все три пересобраны и включены в тот же
коммит). Диапазон узкий и точечный: правка адресует ровно High-1 и High-2 из r1,
`demo/smoke_isometric_contract.mjs` и `demo/smoke_isometric_live_touch.mjs` в этом
коммите не менялись (они уже были частью `42b3f44`).
Трейлеры `ef3cc98`: `Issue: #122`, `User-Visible: no` — корректно, фича
по-прежнему за скрытым Labs-флагом `iso`, публичный changelog не требуется.
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | green, без вывода |
| Unit | `npm test` | green, 766/766 |
| Build | `npm run build` | green |
| Синхронизация 3 копий бандла | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и то же для `demo/srv/assets/houseplan-card.js` | **обе команды green** — байт-в-байт совпадают. High-1 из r1 закрыт |
| `npm run golden:verify` (диф меняет рендер — обязателен) | после `npm run build` + `cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | 45/50 сценариев `passed`; 5 `different` — все пять из семейства «непринятые Iso-эталоны Stage 2» (`isometric-geometry-view-dark/light`, `isometric-live-layers-dark`, `isometric-touch-kiosk-dark`, `isometric-large-warm-remount-dark`), что и предсказывал §12.3 ТЗ и r1. **`isometric-no-borders-dark` теперь `passed`** (High-2 закрыт), **`large-house-zoom-250-dark` теперь `passed`** (Medium-1/#134 регрессия исчезла тем же коммитом) |
| Повтор `golden:verify` для этих двух сценариев | тот же прогон второй раз | оба снова `passed` — детерминированно, не флейк |
| Целевые браузерные смоки (названы в AC1/AC5-AC7/AC9-AC11 и напрямую тронуты правкой `_baseVb`/рендера) | `node demo/smoke_isometric_contract.mjs`, `node demo/smoke_isometric_live_touch.mjs` (после того же build+copy) | оба green; в `live_touch` явно проверяется `noBordersUsesFloorSymbols: true` и `visibleBordersRestoreStage2: true` — оба режима show_borders исполнены в реальном DOM, не только прочитаны |
| Трейлеры коммита | `git log --format=... ef3cc98` | `Issue: #122`, `User-Visible: no` — оба присутствуют и терминальны |
| Backend | не прогонялся | правок в `custom_components/**/*.py` нет — не применимо |
| Performance (`large-house-isometric-v1`) | не прогонялся | как и в r1 — pre-beta гейт, заблокирован #124 по решению владельца, вне скоупа этого код-ревью |
**Чего не проверял и почему:** полный набор из 127 браузерных смоков — правка
цикла ограничена двумя точками в одном файле плюс правкой рендера, которую уже
покрывают целевые смоки/golden; `python -m pytest tests_backend` — нет
Python-правок; принятие golden-эталонов Stage 2 Iso — не роль ревьюера и не
требуется до pre-beta (§12.3 ТЗ, решение владельца); дифференциальный прогон на
`origin/dev` — не повторялся отдельно в этом цикле, т.к. `origin/dev` не менялся
с r1 и там же было доказано, что оба сценария чисты на dev (причинность уже
локализована в диффе задачи).
## Находки
Отсутствуют. Оба High из r1 закрыты, новых High/Medium не найдено.
## Что проверено и корректно
- **High-1 (три копии бандла)** — закрыт. `cmp` подтверждает байт-в-байт
идентичность всех трёх копий после `npm run build`; коммит `ef3cc98` содержит
пересобранные `dist/houseplan-card.js`, `custom_components/houseplan/frontend/houseplan-card.js`
и `demo/srv/assets/houseplan-card.js` внутри себя же (не отдельным коммитом) —
правило «сгенерированное в том же коммите» соблюдено.
- **High-2 (`show_borders:false` в Iso ломал пиксельный контракт)** — закрыт.
Причина по коду (`src/houseplan-card.ts:4558-4573`, `_baseVb()`): при
`!showBorders` рамка теперь строится как `projectedFrame({ rect: flat,
wallHeight: ISO_WALL_HEIGHT })` — это в точности старая Stage-1 проекция без
вклада Stage 2 floor/opening-геометрии, а не безусловный `unionRect(flat,
isoOpeningBounds())`, который ломал сцену в 42b3f44. Синхронно с этим основной
`<svg>`/внутренний `<g>` (строки ~14181-14187) теперь используют
`isoLayers?.structural` вместо `iso` как условие для класса
`iso-floor-scene`/`transform`/viewBox — то есть в ветке «iso активен, но
структура скрыта» контент рендерится без Stage-2-трансформации, тем же путём,
что и Stage 1. Доказано исполнением: `golden:verify` даёт `passed` для
`isometric-no-borders-dark` с нулевым отличием от принятого Stage-1 baseline,
повторено дважды детерминированно; смок `live_touch` отдельно подтверждает
`noBordersUsesFloorSymbols: true` в реальном DOM.
- **Атрибут `class` внешнего `<svg>`** (`class=${isoLayers?.structural ? 'plan-svg' : nothing}`,
новая связка по сравнению с прежним безусловным `class="plan-svg"`) —
прочитано и проверено: `.plan-svg { z-index: 1; }` в `src/styles.ts:472`
имеет смысл только относительно братских `.iso-underlay-svg`/`.iso-shadows-svg`/`.iso-walls-svg`,
а те сами рендерятся строго под условием `iso && isoLayers?.structural`
(`src/houseplan-card.ts:14176`, `:14394`) — то есть ровно тогда же, когда
`plan-svg` получает класс. Вне Flat и вне structural-iso конкурирующих слоёв
с явным z-index нет, поэтому потеря класса не меняет порядок отрисовки.
Единственное внешнее использование селектора `.plan-svg` — `demo/smoke_isometric_contract.mjs:49`,
и там он опрашивается только в сценарии с `show_borders:true` (структурный),
где класс присутствует. Подтверждено исполнением: ни один из 44 не-iso
golden-сценариев (Flat, редакторы, lighting, tray, split-wall и т. д.) не
показал отличий.
- **Medium-1 / #134 (регрессия `large-house-zoom-250-dark` при выключенном
Labs)** — не было целью этого коммита (правка адресует High-1/High-2), но
сценарий проходит чисто тем же коммитом. Открытая гипотеза из r1 (обёртка
`<g class=${iso ? ... }>` как источник 0.1%-регрессии) теперь снята
экспериментально: смена условия с `iso` на `isoLayers?.structural` на этой же
строке устранила и её. Issue #134 остаётся открытым (`S1-new`) — закрытие
issue не входит в роль ревьюера кода (§6 PROCESS.md), но по факту дефект,
который он описывает, воспроизводимо больше не проявляется в этом дереве;
стоит отметить это релиз-менеджеру/автору при обработке #134, а не оставлять
как забытый висящий тикет.
- **AC7 (`unit`+`smoke`+`golden`)** — выполнен: `hide_openings` не трогает
cuts/state/light (не менялось в этом коммите, покрыто существующими тестами
из `npm test`); `show_borders:false` восстанавливает Stage-1 no-volume сцену —
подтверждено golden. **AC15** (Flat и `isometric-no-borders-dark` неизменны) —
выполнен для не-iso baseline; Stage-2-Iso-эталоны намеренно не приняты на
этом этапе (§12.3 ТЗ) и остаются `different` — соответствует контракту, а не
находка. **AC16** (три копии бандла байт-в-байт + зелёные typecheck/unit/build)
— выполнен, доказано `cmp`.
- **`test/isometric-contract.test.mjs`** (+5 строк) — источниковые
regex-проверки на буквальный текст новых веток `_baseVb()` и рендера
(`if (!this._spaceDisplayForRender().showBorders)`,
`projectedFrame({ rect: flat, wallHeight: ISO_WALL_HEIGHT })`,
`preserveAspectRatio=...'xMidYMid meet' : 'none'`, и т. д.). Тест умеет
падать при откате этих строк (буквальное совпадение с исходником), но сам по
себе не проверяет визуальный результат — это делает `golden:verify`
отдельно. Стиль согласован с уже существующими source-grep проверками того
же файла (AC8/AC9/AC14 в r1). Оценка: приемлемо как дополнительная страховка,
не как единственное доказательство AC7/AC15 — реальное доказательство даёт
golden.
- **Документация** (`docs/ISOMETRIC.md`, ADR) — обновлена в том же коммите,
описывает новое поведение точно (Stage-1 projected frame возвращается в
no-borders ветке вместо union с Stage-2-геометрией); расхождений с кодом не
найдено.
## Чего не проверял
- Полные 127 браузерных смоков — правка узкая (две точки в одном файле), вне
необходимости, определяемой diff'ом; прогнаны два целевых, прямо относящихся
к затронутому коду.
- `python -m pytest tests_backend` — нет изменений в Python.
- `large-house-isometric-v1` performance — вне гейта код-ревью, заблокирован
#124 (решение владельца, повторяет позицию r1).
- Принятие golden-эталонов Stage 2 Iso (5 `different`-сценариев) — не роль
ревьюера; это ожидаемое, задокументированное в ТЗ состояние, а не находка.
- Отдельный дифференциальный прогон на `origin/dev` в этом цикле — не
повторялся: `origin/dev` не изменился с r1, где причинность уже была
локализована в диффе задачи, а не в окружении/Chromium.
## Итог
Оба блокирующих High из r1 закрыты одним точечным коммитом (`ef3cc98`):
пересборка всех трёх копий бандла восстановлена и подтверждена `cmp`
(High-1); ветвление рамки/трансформации в `show_borders:false` теперь
воспроизводит принятую Stage-1 no-volume сцену вместо объединения с
Stage-2-геометрией, что подтверждено чистым `golden:verify` дважды подряд
(High-2). Ранее заведённая Medium-находка (#134) как побочный эффект того же
исправления тоже перестала воспроизводиться в golden — issue стоит закрыть
или, как минимум, обновить при её обработке, но это не входит в роль
ревьюера кода. Новых High/Medium-находок нет. Цикл r2/4 закрывается зелёным
вердиктом.
+155
View File
@@ -0,0 +1,155 @@
# CODE-REVIEW-123-r2
- **Issue:** https://github.com/Matysh/houseplan-card/issues/123
- **ТЗ:** `docs/specs/123-corner-split-wall.md` (зелёное ревью
`docs/reviews/SPEC-REVIEW-123-r1.md`)
- **Диапазон:** `git log --oneline origin/dev..HEAD` — 6 коммитов; относительно
предыдущего цикла (`docs/reviews/CODE-REVIEW-123-r1.md`, снят на коммите
`024a1ac`) диапазон вырос ровно на один коммит:
`e79f8f5 Fix corner split smoke geometry input` (`Issue: #123`,
`User-Visible: no`). `git diff 024a1ac..HEAD --stat` подтверждает: изменён
только `demo/smoke_split_corner_wall.mjs` (+6/−1 строк), продуктовый код
(`src/wall-thickness.ts`, `src/houseplan-card.ts`, `src/space-render.ts`) не
тронут ни байтом.
- **Роль:** ревьюер кода (не исполнитель), этап `S7-code-review`
- **Цикл:** r2/4
## Скоуп ревью
Единственная блокирующая находка r1 (`High-1`) — сломанная сигнатура вызова
`_lightBarriers(c._spaceModel())` в `demo/smoke_split_corner_wall.mjs:87`,
из-за которой смок падал необработанным исключением до выполнения хотя бы
одной проверки, и AC7 (`unit + smoke`)/AC8 (`smoke + golden`)/AC9
(`unit + smoke`) не были подтверждены доставленным доказательством. Скоуп
этого цикла: (1) убедиться, что фикс `e79f8f5` действительно чинит вызов, а не
маскирует падение; (2) прогнать смок и убедиться, что все 14 полей — `true`;
(3) убедиться, что тест по-прежнему умеет **содержательно** падать, а не
превратился в тавтологию; (4) поскольку продуктовый код не менялся с r1,
повторно прогнать быстрые гейты и точечные смоки по затронутым поверхностям
для очистки от сомнений, не переделывая заново детальное чтение
`src/wall-thickness.ts`, уже выполненное в r1 (не изменилось — см. diff-статы
выше).
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit | `npm test` | `752/752` (`npm run inventory` подтверждает то же число), 0 fail |
| Build | `npm run build` | зелёный |
| Bundle sync | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и `cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | оба совпадают побайтно; sha256 всех трёх `182fb55a…483ff` — идентичен значению из r1 (ожидаемо: продуктовый код не менялся) |
| Process gate | `node scripts/process-gate.mjs` | `диапазон origin/dev..HEAD, коммитов 6`, `гейт пройден, предупреждений 0` |
| Process gate + issues | `node scripts/process-gate.mjs --issues` | `гейт пройден, предупреждений 0` (метка issue #123 подтверждена через `gh`: ровно одна `S*` — `S7-code-review`) |
| Целевой browser smoke | `node demo/smoke_split_corner_wall.mjs` (после `npm run build`, синхронизация `demo/srv/assets/houseplan-card.js`) | **`OK`**, все 14 полей `true`: `beforeDrawn`, `wall0/15/100KeepsFacade`, `paper0/15/100KeepsFacade`, `dividerChangesInterior`, `lightUsesFacade`, `planViewParity`, `kioskParity`, `isoUsesCanonicalBody`, `staticParity`, `renderDoesNotRewriteConfig` |
| Regression-can-fail (сам доставленный смок, не независимая копия) | доставленный `demo/smoke_split_corner_wall.mjs` (версия из `e79f8f5`) скопирован в чистый `git worktree` на `52ec0fb` (коммит непосредственно перед продуктовым фиксом `47c6f10`, т.е. добаговый `wallBodiesGeometry`), пересобран и прогнан там | `FAILED (7)`: `wall0/15/100KeepsFacade`, `paper0/15/100KeepsFacade`, `lightUsesFacade` — все `expected true, got false`, `planViewParity`/`kioskParity`/`isoUsesCanonicalBody`/`staticParity`/`renderDoesNotRewriteConfig` остаются `true` (паритет между поверхностями держится даже на баге — расходится именно ожидаемый факт «фасад сохранён»). Падение содержательное (конкретные `false`, не исключение), т.е. смок доказывает именно то, что называет AC, а не тавтологию |
| Точечные browser smokes по затронутым поверхностям (split/wall-thickness/glow/iso/static-card) | `node demo/smoke_wall_thickness.mjs`, `smoke_merge_split.mjs`, `smoke_split_nonsnap.mjs`, `smoke_split_polyline.mjs`, `smoke_glow.mjs`, `smoke_isometric_contract.mjs`, `smoke_space_card.mjs` | все `OK`, регрессий на смежных поверхностях нет |
| Golden/performance/backend | не запускались | пре-релизные гейты по `PROCESS.md` §8/§11.4; визуальный/перф/backend-код не менялся с r1 (см. diff-статы), решение о непрогоне уже обосновано в r1 и остаётся в силе |
Полный набор из 128 browser-смоков не прогонялся — правка этого цикла точечная
(один файл демо-гарнеса), затронутые поверхности перечислены выше и покрыты.
## Находки
Блокирующих (High/Medium) находок нет. High-1 из r1 закрыт.
### Low-1 — смок строит `lightPolys` не буквально через хелпер `roomPoly(r)`
**Файл:** `demo/smoke_split_corner_wall.mjs:88-90`
```js
const lightPolys = lightSpace.rooms
.filter((room) => Array.isArray(room.poly))
.map((room) => ({ r: room, poly: room.poly }));
```
Продуктовый `_renderGlowLayer` (`src/houseplan-card.ts:13250-13252`) строит тот
же список через `roomPoly(r)` (`src/logic.ts:103-108`), которая (а) достаёт
`r.poly`, только если в нём **не менее 3** точек, и (б) для комнаты без
явного `poly` вычисляет прямоугольник из `x/y/w/h`. Смок вместо этого
фильтрует `Array.isArray(room.poly)` без проверки длины и не имеет пути для
`x/y/w/h`-комнат.
Для фикстуры issue (все комнаты заданы явным `poly` длиной 3 или 4)
результат совпадает с продуктовым один в один — расхождение не проявляется,
и AC7 доказан корректно для того сценария, который называет ТЗ. Но если этот
файл когда-нибудь расширят на комнату без явного `poly` (`x/y/w/h`), копия
молча исключит такую комнату из `lightPolys` там, где продукт бы её включил
— тихое расхождение, а не падение с сообщением.
**Решение ревьюера:** Low, не блокирует зелёный вердикт — фактическое
поведение для покрываемого сценария корректно, откладываю на усмотрение
автора при следующей правке этого файла (например, заменить построение на
прямой вызов `roomPoly` из продукта, если он становится доступен смоку).
## Что проверено и корректно
- **High-1 (r1) закрыт:** `_lightBarriers(lightSpace, lightPolys, lightPhysical)`
теперь вызывается с тем же числом и порядком аргументов, что и
`_renderGlowLayer` (`polys`, `physical` строятся явно, `physical` — через
тот же `c._physicalBodiesR(lightSpace)`, что и в продукте). Смок выполняется
до конца, `checkAll`/`finish` печатают `OK`, все 14 полей — `true`.
- **AC7 (`unit + smoke`):** `lightUsesFacade: true` — Glow использует то же
исправленное препятствие (`masonryGeometry` из `_lightBarriers`), что и
рендер стен; подтверждено смоком и независимо не расходится с unit-уровнем
r1 (`src/wall-thickness.ts` не менялся).
- **AC8 (`smoke + golden`):** `planViewParity`, `kioskParity`,
`isoUsesCanonicalBody`, `staticParity` — все `true`; Plan, View/kiosk,
скрытая изометрия и `houseplan-space-card` рисуют идентичный путь `d` для
сценария из issue. Golden-эталоны (второй тип доказательства AC8) —
пре-релизный гейт, не запускался, консистентно с r1/§11.4 PROCESS.md.
- **AC9 (`unit + smoke`):** `renderDoesNotRewriteConfig: true` — рендер не
мутирует сохранённые `rooms`/`walls`; сравнение JSON до/после рендера
совпадает.
- **Дисциплина «тест умеет падать» — усилена относительно r1.** В r1 AC7–AC9
были подтверждены независимой копией сценария вне репозитория (сам
доставленный файл падал необработанным исключением). В этом цикле
содержательное падение показано на **самом доставленном** файле — прогон в
чистом worktree на добаговом коде (`52ec0fb`, до `47c6f10`) даёт `FAILED (7)`
с конкретными `expected/got`, не крах. Это закрывает главное сомнение r1:
теперь именно тот файл, что лежит в репозитории, доказывает регресс, а не
только рассуждение ревьюера о нём.
- **Продуктовый код не менялся с r1:** `git diff 024a1ac..HEAD --stat`
показывает изменения только в `demo/smoke_split_corner_wall.mjs`. Всё, что
r1 проверил чтением и тестами по AC1–AC6, AC9 (unit-часть), AC10, AC11
(кэширование), AC12, AC13 (документация/changelog), остаётся в силе без
повторного разбора — предмет разбора не менялся, и разбор `r1` уже прошёл
свой цикл ревью.
- **Трейлеры и процесс:** `node scripts/process-gate.mjs` /
`--issues` — зелёные без предупреждений; коммит `e79f8f5` несёт
`Issue: #123` и `User-Visible: no` — корректно, это правка тестового
гарнеса (`demo/**`, класс B), поведение продукта не меняет, изменений в
changelog не требует и их нет. Метка issue — ровно одна, `S7-code-review`.
`origin/dev` не сдвинулся с момента слияния в ветку задачи (`merge-base`
совпадает с текущим `origin/dev`), ребейз перед мержем не потребуется.
- **Точечные смоки по затронутым поверхностям** (`smoke_wall_thickness`,
`smoke_merge_split`, `smoke_split_nonsnap`, `smoke_split_polyline`,
`smoke_glow`, `smoke_isometric_contract`, `smoke_space_card`) — все `OK`,
регрессий не найдено.
## Чего не проверял
- Полный набор из 128 browser-смоков — правка точечная (один файл демо-
гарнеса), полный прогон не пропорционален объёму изменения; прогнаны
целевой смок AC7–AC9 плюс смоки по затронутым поверхностям (см. таблицу).
- `npm run golden:verify` и `performance_smoke` — пре-релизные гейты
(`PROCESS.md` §8/§11.4), визуальный рендер и перф-чувствительные пути не
менялись с r1; будущий провал чинится по §11.4 без нового код-ревью.
- `tests_backend` — `custom_components/houseplan/**/*.py` не входит в
диапазон.
- Повторное детальное чтение `src/wall-thickness.ts`/`src/houseplan-card.ts`/
`src/space-render.ts` построчно — не требовалось: файлы не изменились со
времени r1, где это чтение уже выполнено и задокументировано.
- Low-1 не проверялся на альтернативной фикстуре (комната без явного `poly`)
— вне сценария, который называет ТЗ; см. решение ревьюера в находке.
## Вердикт
Зелёный. High: 0, Medium: 0 (Low: 1, не блокирует, решение зафиксировано в
находке Low-1 выше — оставлено на усмотрение автора без нового цикла).
Единственная блокирующая находка r1 устранена: доставленный
`demo/smoke_split_corner_wall.mjs` теперь вызывает `_lightBarriers` с полной
сигнатурой, проходит до конца с `OK` по всем 14 полям и содержательно падает
на добаговом коде того же файла (не независимой копии) — AC7, AC8, AC9
подтверждены доказательством, которое называет ТЗ. Продуктовый код не менялся
с r1 и остаётся подтверждённым: 752/752 unit, три bundle-снимка побайтно
идентичны, трейлеры и процесс-гейт зелёные.
-294
View File
@@ -1,294 +0,0 @@
# SPEC-REVIEW-107-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/107
- **ТЗ под ревью:** `docs/specs/107-virtual-light-toggle.md` (коммит `af851cd`,
ветка `issue/107-virtual-light-toggle`)
- **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review`
- **Трек:** обычный (не `small`/`trivial`) — автор корректно не поставил `small`:
задача вводит новый backend WS-контракт, отдельное operational-хранилище и
новый UX-контракт target preview, что прямо нарушает критерии §5 PROCESS.md
(«нет нового UX-контракта», «нет влияния на touch»)
- **Цикл:** r1/4
## Скоуп ревью
Проверялось соответствие ТЗ:
- `docs/SCOPE.md` — попадание в Core user jobs (J1/J3), сохранение
замороженного статуса virtual devices, lock-инвариант;
- `PROCESS.md` §2.4, §2.5 (DoR), §7.1 (обязательные разделы), §12 (запреты);
- `AGENTS.md` — классы файлов, ветка `issue/107-virtual-light-toggle`, трейлеры;
- `docs/LIGHT.md` и `docs/DEVICE-LIGHT-SETTINGS-MATRIX.ru.md` — канон модели
источника света, passive `Always`, OR-контракт controllers (#84/#88);
- `docs/CONFIG-COMPATIBILITY.md` — образец, каким должен быть раздел
compatibility (old/new matrix, `decision-required`/`deprecated-read` и т.п.);
- `docs/TOUCH-SUPPORT.md` и `docs/UX-MODES.md` — блокирующий View/kiosk-контракт,
разрешённые tap-действия;
- `docs/USER-GUIDE.ru.md` — терминология «Переключить состояние», роль
«Всегда», «Виртуальное устройство»;
- фактическому состоянию кода (`src/devices.ts`, `src/device-toggle.ts`,
`src/types.ts`) — на предмет того, что технический диагноз ТЗ (§3) не
является непроверенной догадкой, а описывает существующий код.
## Как проверялось
1. Прочитан весь тред issue #107: тело issue (черновик AC1–AC4, явное решение
владельца «обрабатывать как частный случай, не обобщать»), комментарий
аналитики Q1–Q3 с default-ответами, комментарий владельца от 14.08.2026,
фиксирующий Q1–Q3 без изменений (`#107#issuecomment-5287719167`), и
комментарий «ТЗ готово».
2. Прочитан `docs/SCOPE.md` целиком: Core user jobs J1/J3, «Excess-functionality
audit» (virtual devices — «keep, frozen (no growth)»), lock-инвариант,
правило «никогда не удалять файл пользователя по догадке» (не касается этой
задачи — файлов не удаляет).
3. Построчно сверены обязательные разделы ТЗ (§7.1 `PROCESS.md`) — таблица ниже.
4. Прочитан `src/device-toggle.ts` целиком, в частности `resolveToggleIntent()`
(:590–621) и `resolveOwnEntity`/`ownRoleCandidates` (:355–419): подтверждено
дословно то, что описывает §3 ТЗ — виртуальный marker с `tap_action=toggle`
не имеет кандидатов в `ownRoleCandidates` (нет `bindingRef`/`entities`), и
`resolveOwnEntity` возвращает `null`, что даёт `emptyIntent(origin,
'no-actionable-entity')` через ветку `device.virtual || device.bindingKind
=== 'virtual'` (:616–619) — **независимо от `is_light`**. Это важно: ТЗ
обязано менять поведение только для точной тройки, а не для любого
virtual+toggle, и код подтверждает, что сегодня Auto/Never virtual с
toggle-действием получают тот же `no-actionable-entity` тем же путём — AC1/
AC3 корректно требуют, чтобы для них ничего не менялось.
5. Прочитан `resolvedLightSources()` (`src/devices.ts`:450–565), включая
создание passive-источника `marker:<id>` с `passive: !candidate.eid` и
`on: candidate.eid ? … : true` (:487–493) и OR-контракт контроллеров
(:525–532: `source.on = !control?.linked || […].some(eid => … === 'on')`).
Совпадает дословно с §3 п.1–2 и §6.3 ТЗ.
6. Прочитан `lightGraphFingerprint`/`lightStateFingerprint`
(`src/devices.ts`:331–354) — оба фингерпринта сегодня не содержат никакого
поля мануального override для virtual-источника. Это подтверждает
техническую необходимость AC14 (новая runtime-revision обязана войти в ключ
кэша) — без неё один tap не вызвал бы инвалидацию `resolvedLightSources()`,
и Glow остался бы старым до следующего HA state tick, чего в системе для
virtual-маркера никогда не происходит. Идея «мутантного теста» в §15.1.5
(удаление revision из ключа кэша должно ронять тест) — конкретный и
проверяемый способ закрыть именно этот риск.
7. Прочитан `docs/LIGHT.md` (раздел «Source, state and service identity») —
тройная идентичность `key`/`stateEids`/`serviceEids`, три-стейт `is_light`,
passive forced source, OR нескольких controllers — всё описанное в §3/§6
ТЗ дословно совпадает с каноном, ничего не придумано заново.
8. Прочитан `docs/DEVICE-LIGHT-SETTINGS-MATRIX.ru.md` — матрица про
Live/Ручн./R (цвет, яркость, радиус) не про on/off-агрегацию; задача явно
не трогает geometry/color/brightness/radius (§13 «Не входит»), поэтому
отсутствие правки этого файла в release-артефактах (§17 ТЗ) корректно —
изменения принадлежат `docs/LIGHT.md`, что там и указано.
9. Прочитан `docs/CONFIG-COMPATIBILITY.md` целиком как образец формата
compatibility-записи. §12 ТЗ («отдельный optional operational Store version
1», «отсутствие = пустой off-set/on», «operational state не входит в
export») по форме и содержанию соответствует принятому в проекте стилю
(сравнимо с разделами «Per-marker light role», «marker.controls[]»).
10. Прочитан `docs/TOUCH-SUPPORT.md` («View is fully supported… must be
convenient and reliable», «Kiosk — primary supported environment») и
`docs/UX-MODES.md` («device tap (info / more-info / toggle per settings)» —
разрешённое View-взаимодействие). §10 ТЗ формулирует touch/kiosk как
блокирующие поверхности этими же словами, не эскалируя и не изобретая
более строгий контракт, чем канон.
11. Проверена терминология `docs/USER-GUIDE.ru.md` (строки 467–470, 553–562):
«Переключить состояние», «Является источником света: Авто/Всегда/Никогда»,
«виртуальный маркер» — ТЗ использует ровно эти термины.
12. Проверены существующие i18n-ключи `marker.toggle_none_*` и
`marker.toggle_effect_turn_on/off` (`src/i18n/ru.json`) — новые ключи,
которые требует §9 ТЗ, логически продолжают уже принятую схему именования,
а не вводят параллельную.
13. Проверена запись в `docs/specs/README.md:81` — строка на #107 присутствует
в том же коммите, ссылка issue ↔ ТЗ двусторонняя.
14. Прочитан `docs/types.ts` — поле `tap_action`/`tapAction` существует ровно
в том виде, на который ссылается §5 ТЗ («effective tap action равен
`toggle`»).
15. Проверено количество и релевантность 16 AC (§14) — каждый несёт явный тип
доказательства (`unit`/`backend`/`smoke`/«ревью кода»/`build`) из
допустимого по DoR перечня; ни один AC не двусмысленен относительно того,
какое конкретно поведение проверяется.
## Обязательные разделы (§7.1 PROCESS.md)
| Раздел | Есть | Комментарий |
|---|---|---|
| Сценарий (персона/поверхность/момент) | ✅ | §1 |
| Что человек увидит до/после | ✅ | §2, без терминов реализации |
| Проблема | ⚠️ частично | Явно как отдельный заголовок отсутствует; фактически покрыта §2 («до») и §3 («подтверждённая причина текущего поведения») — см. Low-1 |
| Скоуп / не-скоуп | ✅ | §5 (точное условие) / §13 |
| Контракт поведения | ✅ | §5–§8 |
| UX / i18n / accessibility / touch | ✅ | §9, §10 |
| Модель данных и миграция | ✅ | §12 |
| AC1…ACn с доказательством | ✅ | §14, 16 штук, каждый типизирован |
| План автотестов | ✅ | §15 |
| Риски | ❌ отсутствует как раздел | Риск-релевантный материал есть (fail-safe §7.5, security-граница §16, сложность 7/10 в шапке), но не сведён в один раздел с явной привязкой риск → закрывающий AC, как это сделано, например, в `SPEC-REVIEW-131-r1` — см. Low-2 |
| Откат | ✅ | §18 |
| Release-артефакты | ✅ | §17 |
Десять из двенадцати обязательных разделов присутствуют и содержательны;
два («Проблема», «Риски») по факту не выделены отдельным заголовком, хотя
материал по существу распределён по документу. Дополнительно есть корректно
обособленный §19 «Принятые технические предположения» — граница между
продуктовыми решениями (принятыми владельцем в Q1–Q3) и свободно
пересматриваемой техникой проведена явно, ни одна догадка не выдана за факт
без пометки.
## Находки
Находок уровня **High** нет.
### Low-1 — раздел «Проблема» не выделен отдельно
**Файл:** `docs/specs/107-virtual-light-toggle.md` (между §2 и §3)
Формально PROCESS.md §7.1 перечисляет «проблема» отдельным пунктом в списке
обязательных разделов. В документе нет заголовка с этим названием — «до»
(§2) и технический диагноз (§3) вместе дают эквивалентное содержание, но
читатель, ищущий формулировку «в чём проблема» одним куском, должен собрать
её из двух разделов и самого issue.
**Почему не блокирует:** содержание есть и оно точное (см. «Как проверялось»
п.4) — диагноз в §3 построчно совпадает с реальным кодом, не является
догадкой. Это вопрос оформления, а не отсутствия решения.
**Решение ревьюера:** Low, не блокирует, снимается с записью. На усмотрение
автора — при следующей правке можно дать §2/§3 общий подзаголовок «Проблема»,
либо оставить как есть.
### Low-2 — нет консолидированного раздела «Риски»
**Файл:** `docs/specs/107-virtual-light-toggle.md`
Сложность/риск задачи оценены автором в 7/10 (шапка документа), но в отличие
от, например, `docs/specs/131-readonly-cold-start.md` (§14 «Риски», 5 пунктов,
каждый со ссылкой на закрывающий AC), здесь риск-релевантные утверждения
рассеяны по документу без единого места: fail-safe при revision gap (§7.5),
границы trust boundary (§16), решение о хранении вместо конфигурации (§4 п.1).
Отсутствует явное перечисление, например: «риск: два отдельных HA `Store`
могут разойтись при crash между save — закрыт §7.5 fail-safe reset + AC9»,
«риск: canonical-кэш не инвалидируется без HA tick — закрыт AC14 + мутантный
тест §15.1.5», «риск: manual state перекрывает controller и наоборот путает
пользователя — закрыт §6.3 + AC5/AC12».
**Почему не блокирует:** каждый реальный риск, который я смог определить при
чтении кода и канона, оказался закрыт конкретным AC или конкретным разделом
контракта (перечислено выше) — отсутствует сам факт непокрытого риска, не
хватает только сведения их в один раздел для читаемости и трассируемости.
**Решение ревьюера:** Low, не блокирует, снимается с записью. Рекомендация
автору — на следующей правке (не обязательно в этом цикле) собрать
существующие риск-утверждения в один раздел «Риски» с явной ссылкой
риск → AC, по образцу `SPEC-REVIEW-131-r1`.
## Что проверено и корректно
- **Соответствие `docs/SCOPE.md`.** Задача закрывает J1 (Glow/room fill/room
stats — одно пространственное состояние) и J3 (очевидное безопасное
действие прямо с плана). Она не расширяет «замороженный» статус virtual
devices в общий state engine — владелец явно одобрил именно узкое
исключение в теле issue («не превращать в системный механизм»), и §1/§5 ТЗ
формулируют условие исключения как точную тройку, без обобщения на другие
роли/действия. Lock-инвариант не затронут: virtual target никогда не
резолвится в secure entity или HA service (§8.1, §16) — соответствует
правилу SCOPE.md «любой новый путь актуации либо отказывает locks, либо
добавляется явным абзацем в SCOPE.md» (здесь второе не требуется, так как
путь и так отказывает).
- **Продуктовые вопросы закрыты владельцем, не додуманы автором.** Q1
(жизненный цикл/синхронизация), Q2 (что именно переключается — canonical
state, а не только Glow) и Q3 (приоритет manual state над controller links)
— все три явно продуктовые («что видит/делает пользователь», «что считается
этим же issue»), заданы одним комментарием с default-вариантами и приняты
владельцем дословно 14.08.2026. Открытых продуктовых вопросов в финальной
редакции нет — и это корректно, а не подозрительно: они были заданы и
закрыты на этапе аналитики, а не пропущены.
- **Раздел §19 корректно отделяет технику от продукта.** Все 8 пунктов
(имя Store/класса, механизм доставки initial state, форма typed intent,
раскладка подписок, поведение local cache, консервативный reset вместо
угадывания истории, hidden vs tombstone, поведение импорта) — техническая
реализация уже принятых продуктовых решений Q1–Q3, ни один пункт не прячет
продуктовое решение под видом «технического предположения».
- **Техническая точность диагноза (§3) подтверждена чтением кода**, не
является голословным утверждением автора: `resolveToggleIntent()`,
`resolvedLightSources()`, `lightGraphFingerprint`/`lightStateFingerprint` —
все три технических утверждения совпадают построчно с текущим `src/devices.ts`
и `src/device-toggle.ts` (см. «Как проверялось» п.4–6).
- **Точность границы исключения.** Код подтверждает, что сегодня *любой*
virtual marker с `tap_action=toggle` (Auto/Never/Always) получает
`no-actionable-entity` одним и тем же путём (`resolveOwnEntity` → `null`);
§5 ТЗ и AC1/AC3 корректно требуют не менять этот путь для Auto/Never, а не
просто «для не-Always», что было бы более рискованной (и не запрошенной)
формулировкой.
- **Canonical single-consumer contract (§6.2, AC5/AC6) методологически верный
ответ на риск дублирования источника истины.** Проверено, что
`resolvedLightSources()` — уже сегодня единственный вход для Glow, room fill,
room stats, preview и `houseplan-space-card» (docs/LIGHT.md, «Source, state
and service identity»); требование ТЗ не создавать отдельную ветку в
рендерере, room card, preview или static card прямо предотвращает
повторение уже случившегося в проекте расхождения слоёв (см. историю
«layered model» в docs/LIGHT.md).
- **Кэш-инвалидация (AC14) — не декоративное требование.** Подтверждено, что
без явного добавления runtime-revision в `lightStateFingerprint`
манипуляция чисто виртуальным состоянием (без единой реальной HA entity)
физически не имеет другого триггера инвалидации кэша — HA state tick для
virtual marker никогда не придёт. План теста §15.1.5 («мутантный» тест,
который обязан упасть при удалении revision из ключа кэша) — конкретное и
проверяемое требование именно к этому риску, соответствует принятой в
проекте дисциплине «тест должен уметь падать».
- **Touch/View/kiosk-контракт (§10) сформулирован дословно по канону**
(`docs/TOUCH-SUPPORT.md`, `docs/UX-MODES.md`), не эскалирован и не ослаблен.
- **Терминология UX/i18n (§9) взята из `docs/USER-GUIDE.ru.md`**, не
изобретена; новые ключи логически продолжают существующую схему
`marker.toggle_none_*`/`marker.toggle_effect_*`.
- **Compatibility (§12) не вводит миграцию config/marker/export**, что
соответствует и явному решению владельца (Q1: «operational data, не входит
в config/export») и общему принципу `docs/CONFIG-COMPATIBILITY.md` —
отдельный versioned Store, а не новое поле в `Marker`.
- **Не-скоуп (§13) корректно отсекает обобщение**, прямо запрещённое
владельцем в теле issue: общий state engine для virtual devices, действие
для других ролей/actions, создание `input_boolean`/synthetic entity,
управление цветом/яркостью/радиусом через tap.
- **Release-артефакты (§17) перечисляют реальные существующие файлы**
(`docs/CHANGELOG.md`/`.ru.md`, `docs/USER-GUIDE.ru.md`, `README.md`,
`docs/LIGHT.md`, `docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`, три
bundle snapshot) — ни один не выдуман.
- **Реестр `docs/specs/README.md`** обновлён тем же коммитом, ссылка issue ↔
ТЗ двусторонняя.
- **Трейлеры коммита `af851cd`** (`Issue: #107`, `User-Visible: no` —
ожидаемо, так как коммит правит только ТЗ) корректны для документационного
коммита; продуктовый код действительно не тронут (подтверждено и текстом
ТЗ, и комментарием «ТЗ готово»: «продуктовый код не менялся»).
## Чего не проверял
- Не проверял, реализуем ли предложенный backend design (§7: общий
load-modify-save lock между config и operational store, атомарный WS
toggle) без побочных эффектов на существующие писатели конфигурации — по
§19 п.1/п.6 ТЗ это свободно изменяемое техническое предположение автора
кода и предмет код-ревью, а не ревью ТЗ.
- Не запускал никаких автотестов, не собирал бандл и не проверял backend на
Python — на этапе `spec` это не требуется; все технические утверждения,
которые проверялись, проверены чтением существующего TypeScript-кода, не
исполнением.
- Не проверял golden/скриншоты — ТЗ §15.4 явно и обоснованно откладывает их
до pre-beta gate (переиспользуется существующая flat-сцена, новый
художественный baseline не проектируется).
- Не проверял детали Python-реализации `houseplan.virtual_lights` (имя класса,
формат хранения) — §19 п.1 прямо помечает точные имена как свободно
изменяемые технические детали.
- Не проверял полноту 127 browser-smoke сценариев и не запускал ни одного —
на этапе ревью ТЗ это не требуется и не относится к гейтам код-ревью §8
PROCESS.md; целевой smoke-сценарий (§15.3 ТЗ) описан достаточно конкретно
(одна eligible лампа, один контроллер, один независимый источник, две full
card и одна static card) для последующей проверки на этапе код-ревью.
- Не проверял, действительно ли предложенный «typed operational target»
(§8.1) может быть добавлен в `ResolvedToggleIntent` без расширения его типа
несовместимым образом — вопрос реализации, накрытый AC6 и предметом
код-ревью.
## Вердикт
Зелёный. High: 0, Medium: 0. Две находки Low — (1) раздел «Проблема» не
выделен отдельным заголовком, содержание фактически распределено по §2/§3;
(2) риск-релевантный материал не сведён в консолидированный раздел «Риски»
со ссылками риск → AC. Обе не блокируют: содержание по существу присутствует
и подтверждено построчной сверкой с реальным кодом и каноном подсистемы,
открытых продуктовых вопросов нет (Q1–Q3 закрыты владельцем 14.08.2026), AC
однозначны и типизированы, скоуп/не-скоуп точно повторяют явное решение
владельца «не обобщать». Обе находки сняты с записью в этом документе, на
усмотрение автора учесть при следующей правке.
-241
View File
@@ -1,241 +0,0 @@
# SPEC-REVIEW-122-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/122
- **ТЗ под ревью:** `docs/specs/122-isometric-stage2.md` (коммит `76ce755`,
ветка `issue/122-isometric-stage2`)
- **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review`
- **Трек:** обычный (не `small`) — аналитика владельца оценила сложность и
риск 9/10, поверхностей больше одной (рендер/геометрия/UX/perf); лёгкий трек
корректно не применён, `docs/specs/<NN>-*.md` присутствует
- **Цикл:** r1/4 (полный трек, лимит 4)
## Скоуп ревью
Проверялось соответствие ТЗ:
- `docs/SCOPE.md` — попадание в Core user jobs (J1/J2/J3), узкое
owner-approved исключение из фотореалистичного/3D-запрета, инвариант lock;
- `PROCESS.md` §2.4/§2.5 (DoR), §7.1 (обязательные разделы ТЗ), §12
(запреты), §3 (правила 1, 2, 6, 9);
- `AGENTS.md` — классы файлов (только `docs/**` в этом коммите), ветка,
трейлеры;
- нормативному предшественнику `docs/specs/089-isometric-view-stage1.md` и
`docs/adr/089-isometric-stage1-renderer.md` — что Stage 2 не переписывает
принятые решения Stage 1 задним числом;
- каноническим документам затронутой подсистемы: `docs/ISOMETRIC.md`,
`docs/WALL-THICKNESS.md`, `docs/LIGHT.md`, `docs/TOUCH-SUPPORT.md`,
`docs/UX-MODES.md`;
- `docs/USER-GUIDE.ru.md` — что видимого пользователю текста не появляется и
документ не должен меняться;
- фактическому состоянию кода (`src/render/opening-symbol.ts`, `src/logic.ts`,
`src/labs.ts`, `demo/golden/matrix.mjs`) — на предмет того, что технические
утверждения ТЗ не являются непроверенной догадкой;
- треду issue #122 целиком, включая аналитику S2, вопросы владельца Q0–Q6 и
их принятие, а также связанному issue #124 (performance-блокер).
## Как проверялось
1. Прочитан весь тред issue #122: аналитика (ценность 7/10, P2, сложность
9/10), пакет вопросов владельцу Q0–Q6 с default-вариантами, принятие всех
default'ов владельцем 2026-08-13, хендофф автора «ТЗ готово к ревью».
Подтверждено, что ни один вопрос не был техническим, замаскированным под
продуктовый: Q0 (приоритет/ценность), Q1 (объём видимого сравнения
Stage1/Stage2 — второй флаг или нет), Q2 (что задают референсы: материал
или замена содержимого), Q3 (визуальное представление проёмов), Q4 (новый
декоративный свет — да/нет), Q5 (floor edge/тени), Q6 (деградация) — все
про то, что видит пользователь, и про объём этого issue.
2. Сверены обязательные разделы ТЗ (§7.1 `PROCESS.md`) — таблица ниже.
3. Прочитан `docs/specs/089-isometric-view-stage1.md` целиком и
`docs/ISOMETRIC.md` — подтверждено, что Stage 2 ссылается на реальные
решения Stage 1 (`wallBodiesGeometry()`, единая проекция, fingerprint без
`_cfgEpoch`/HA state, LRU-кэш на 8 сцен, latched flat fallback,
`projectedFrame()`, Labs-грамматика `iso`/`since 1.62.0`/`expires 1.65.0`)
и не выдаёт их за собственное изобретение и не переписывает Stage 1 задним
числом (ADR-89 не трогается, заводится новый `docs/adr/122-*.md`).
4. Прочитан `src/labs.ts` (`since: '1.62.0'`, `expires: '1.65.0'`) и
`package.json` (`version: 1.63.0`) — утверждение ТЗ «expiry не расширяется,
`1.65.0-beta.1` уже мёртв» технически корректно и согласуется с описанным в
`docs/ISOMETRIC.md` числовым сравнением ядра версии.
5. Прочитан `src/render/opening-symbol.ts` — подтверждены буквально: gate
открывается ровно на 10° (`10 * amount`, `docs/specs/122:213-215` против
`opening-symbol.ts:97`), window — двухстворчатый casement
(`rotate(${-90*amount})`/`rotate(${90*amount})`, `opening-symbol.ts:83-89`
против §6.5 «two-leaf/casement»). Это не догадка автора, а точное описание
существующего кода.
6. Прочитан `openingAmount()` в `src/logic.ts:316-323` — подтверждено
поведение «no contact → door/gate open (1), window closed (0)», которое ТЗ
называет «static-open default when no contact exists» (§6.5, Door/Gate).
7. Прочитан `docs/LIGHT.md` и `docs/WALL-THICKNESS.md` — подтверждено, что
«один регион света на источник», «wallBodiesGeometry как единственный
источник тела стены», «exterior silhouette из union центральных линий» —
реальные, а не придуманные инварианты, на которые ТЗ ссылается в §6.3, §6.7,
§7.
8. Прочитан `docs/TOUCH-SUPPORT.md` целиком — формулировки ТЗ §6.8
(«pointer/focus/ARIA inert», «View и kiosk остаются полностью
поддерживаемыми», «touch-only failure — product defect») дословно
отражают контракт, а не изобретены заново.
9. Прочитан `docs/USER-GUIDE.ru.md` — поиском подтверждено отсутствие каких-
либо упоминаний изометрии/объёмного вида; утверждение ТЗ §9 «документ
остаётся молчащим про скрытый Iso» верно на текущий момент.
10. Проверено существование golden-сцены `isometric-no-borders-dark` в
`demo/golden/matrix.mjs:23` — AC15/§12.3 ссылаются на реальный, а не
вымышленный сценарий.
11. Прочитан issue #124 (`gh issue view 124`) — подтверждено: воспроизводимый
performance-регресс `viewToggleMs.median 192.8мс` vs лимит `131.7мс` на
exact-SHA профиле `large-house-isometric-v1`, статус `S1-new` (ещё не
проанализирован). ТЗ §10 корректно описывает его как DoR-блокер
реализации (не ревью), с двумя явными путями снятия (закрытие #124 или
отдельное явное решение владельца) — ни то, ни другое не подменяет и не
ослабляет существующий бюджет.
12. Проверено `docs/specs/README.md:80` — строка на #122 добавлена в том же
коммите, ссылка issue ↔ ТЗ двусторонняя.
13. Проверены трейлеры коммита `76ce755`: `Issue: #122`, `User-Visible: no`;
`git show --stat` подтверждает изменение только `docs/specs/122-*.md` и
`docs/specs/README.md` — класс C, продуктовый код (класс A) не менялся,
правило №1 `AGENTS.md`/`PROCESS.md` соблюдено на этапе `spec`.
## Обязательные разделы (§7.1 PROCESS.md)
| Раздел | Есть | Комментарий |
|---|---|---|
| Сценарий (персона/поверхность/момент) | ✅ | §1 |
| Что человек увидит до/после (без терминов реализации) | ✅ | §1, одной фразой |
| Проблема | ✅ | §2 |
| Скоуп / не-скоуп | ✅ | §4 / §5 |
| Контракт поведения | ✅ | §6 (8 подразделов) |
| UX | ✅ | §9 (явно: нет нового UI/i18n) |
| Модель данных и миграция | ✅ | §8 |
| i18n | ✅ | §9 (пусто, обосновано) |
| AC1…ACn с доказательством | ✅ | §11, 16 штук, каждый с типом |
| План автотестов | ✅ | §12 (unit/smoke/golden/performance/backend) |
| Риски | ✅ | §14, 12 строк с вероятностью/impact/mitigation |
| Откат | ✅ | §15 |
| Release-артефакты | ✅ | §13 |
Все обязательные разделы присутствуют и содержательны. Дополнительно
присутствуют продуктовые под-разделы §7.1 (персона/поверхность/момент внутри
§1) и явный блок §16 «принятые технические предположения — можно менять без
пересмотра продукта», корректно отделённый от решений владельца §3.
## Находки
Находок уровня **High** и **Medium** нет.
### Low-1 — отсутствует буквальная декларация «Touch editor: …»
**Файл:** `docs/specs/122-isometric-stage2.md` (весь документ)
`docs/TOUCH-SUPPORT.md` («Documentation rule») требует от новых спецификаций
редакторных фич явно указывать одно из: `Touch editor: supported` / `best
effort / intentionally degraded` / `not exposed`. Предшественник, Stage 1
(`docs/specs/089-isometric-view-stage1.md`, §7 «Touch editor: не exposed»),
эту декларацию давал буквально, хотя ситуация идентична — редакторы Stage 2
не касается вовсе (§6.1 «Editors and houseplan-space-card stay Flat»). Stage 2
такой буквальной строки не содержит, хотя по содержанию §6.1/§6.8 поведение
однозначно совпадает с «not exposed» — реальной неоднозначности для
разработчика или ревьюера кода нет.
**Решение ревьюера:** Low, не блокирует. Формально Stage 2 не вводит новую
редакторную фичу (правило `TOUCH-SUPPORT.md` адресовано именно им), а
содержательно контракт «редакторы не меняются» зафиксирован дважды (§6.1,
§9). Оставляю на усмотрение автора — можно добавить строку `Touch editor: not
exposed` при следующей правке для единообразия с Stage 1, можно снять этой
записью.
### Low-2 — три AC используют тип доказательства вне буквального перечня §2.5
**Файл:** `docs/specs/122-isometric-stage2.md:445-457` (AC13, AC15, AC16)
DoR (`PROCESS.md` §2.5) перечисляет типы доказательства как `unit` / `backend`
/ `smoke` / `golden` / «ревью кода». AC13 помечен только `(performance)`,
AC15 — `(golden + documentation review)`, AC16 — `(typecheck + unit + build)`.
Буквально `performance`, `typecheck`, `build` и `documentation review` в этот
перечень не входят. По существу все три доказуемы существующими механизмами:
`performance_smoke`/Full Performance и `typecheck`/`build` — реальные
release-blocking гейты `PROCESS.md` §8, а «documentation review» — часть
обычного код-ревью (§2.7). Тот же паттерн уже был отмечен как Low-1 в
`SPEC-REVIEW-123-r1` и оставлен автору без блокировки цикла.
**Решение ревьюера:** Low, не блокирует. Переформулировка в термины §2.5 (например,
«ревью кода со ссылкой на `performance_smoke`/exact-SHA Full Performance» и
«ревью кода со ссылкой на зелёный `typecheck`/`test`/`build`») повысила бы
буквальную трассируемость, но не меняет, чем на практике будет доказан
критерий. Оставляю на усмотрение автора или снимаю этой записью.
## Что проверено и корректно
- Соответствие `docs/SCOPE.md`: задача закрывает J1/J2/J3, остаётся внутри
узкого owner-approved исключения (2.5D, не фотореализм/не свободная камера/
не interior editor), не трогает единственную санкционированную поверхность
actuation замков (`docs/SCOPE.md` «The lock invariant»).
- Все вопросы владельцу (Q0–Q6) — продуктовые (видимое поведение, объём
видимых изменений), ни один технический вопрос не был переадресован
владельцу; все ответы получены и внесены в ТЗ без расширения скоупа.
- ТЗ явно наследует, а не переопределяет Stage 1: проекция, `wallBodiesGeometry`,
fingerprint без `_cfgEpoch`/HA-состояния, LRU-кэш 8 сцен, latched flat
fallback, Labs-грамматика `iso`/`1.62.0`/`1.65.0` — все повторно
использованы корректно, ни одно принятое решение Stage 1 не переписывается
задним числом (историческая ADR-89 остаётся неприкосновенной, заводится
новая `docs/adr/122-isometric-stage2-composition.md`).
- Технические утверждения о существующем коде (10° gate, two-leaf window,
static-open default при отсутствии contact, один регион света на источник,
`wallBodiesGeometry` как единственный источник геометрии стен) подтверждены
чтением реального кода, а не приняты на слово автора.
- Скоуп/не-скоуп (§4/§5) корректно отсекает публичный rollout, второй флаг
`iso2`, свободную камеру/#82, новые material-настройки, объёмные редакторы и
`houseplan-space-card`, схемные поля высоты/подоконника, WebGL/Three.js — все
типичные места, где скоуп мог бы незаметно расшириться, явно закрыты.
- Зависимость от #124 обработана корректно: не как продуктовое решение
Q0–Q6 (не переоткрывает их), а как отдельный DoR-блокер реализации (§10) с
двумя явными путями снятия; ревью ТЗ по правилам `PROCESS.md` §10 разрешено
вести, пока #124 открыт.
- i18n/UX (§9): подтверждено отсутствием новых строк, README/CHANGELOG/
USER-GUIDE не обещают публичную функцию — соответствует `User-Visible: no`
контракту для всей будущей реализации.
- Модель данных и совместимость (§8): подтверждено отсутствием новых
схемных полей, ключей `localStorage` версий и compatibility-записей;
откат (§15) не требует миграции и работает немедленно через
`?hp-labs=-iso`/`off`, как и в Stage 1.
- AC1–AC16 однозначны, у каждого указан тип доказательства (за вычетом Low-2)
и план автотестов (§12) даёт конкретный, воспроизводимый по коду маршрут;
§12.1 явно требует, чтобы каждый unit-мутант «умел падать» (удаление
внешнего edge, сдвиг leaf с jamb, включение HA-состояния в cache key,
дублирование floor symbol, лишний light layer).
- Не найдено ни одного продуктового утверждения о поведении, которое не
следует ни из канонических документов, ни из решений владельца Q0–Q6, ни
из чтения существующего кода, и при этом не помечено как предположение;
раздел §16 корректно отделяет свободно изменяемые технические детали
(именование модулей, точные проценты высоты панелей, число SVG-корней) от
зафиксированных владельцем решений.
- Реестр `docs/specs/README.md` обновлён тем же коммитом, коммит несёт
корректные трейлеры (`Issue: #122`, `User-Visible: no`) и не содержит
изменений класса A — правило №1 соблюдено на этапе `spec`.
## Чего не проверял
- Не проверял реализуемость конкретной геометрической декомпозиции
(`iso-openings.ts`/`iso-scene.ts`, точные проценты высоты панелей, число
SVG-defs) — по правилам ТЗ (§16) это свободно изменяемое техническое
предположение автора кода, не предмет ревью ТЗ.
- Не проверял реальный performance-профиль `large-house-isometric-v1` и не
прогонял #124 — на этапе `spec` это не требуется; факт открытости и
статус #124 (`S1-new`) проверен через `gh issue view`, не исполнением
бенчмарка.
- Не запускал никаких автотестов, golden или смоков — на этапе `spec` это не
требуется; существование референсных артефактов (`isometric-no-borders-dark`
в `demo/golden/matrix.mjs`) проверено чтением файла, не исполнением.
- Не проверял содержимое `docs/adr/089-isometric-stage1-renderer.md` построчно
на полное покрытие всех констант камеры — доверился ссылке ТЗ и
`docs/ISOMETRIC.md` как согласованному пересказу; расхождений при чтении
обоих источников не обнаружено.
- Не оценивал субъективную эстетическую адекватность референсов владельца
(issue #89, комментарий с изображениями) — это продуктовое решение,
принятое владельцем через Q2 и не подлежащее пересмотру ревьюером ТЗ.
## Вердикт
Зелёный. High: 0, Medium: 0. Две находки Low (отсутствие буквальной
декларации `Touch editor: …` и три AC с типом доказательства вне буквального
перечня §2.5) — не блокируют, оставлены автору на усмотрение с записью в этом
документе; при желании их можно поправить в этом же цикле без возврата в
`S3-spec`.
-226
View File
@@ -1,226 +0,0 @@
# SPEC-REVIEW-131-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/131
- **ТЗ под ревью:** `docs/specs/131-readonly-cold-start.md` (коммит `0164e65`)
- **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review`
- **Трек:** обычный (не `small`/`trivial`) — владелец явно принял D1/D2
(оценка сложности 4/10, но затрагивает async boot, cache/navigation
precedence, reload и warm continuity; лёгкий трек корректно не применён)
- **Цикл:** r1/4
## Скоуп ревью
Проверялось соответствие ТЗ:
- `docs/SCOPE.md` — попадание в Core user jobs, отсутствие расширения скоупа,
сохранение lock-инварианта и правила «никогда не удалять файл по догадке»
(задача файлов не касается);
- `PROCESS.md` §2.4, §2.5 (DoR), §7.1 (обязательные разделы), §12 (запреты);
- `AGENTS.md` — классы файлов (класс A: `src/houseplan-card.ts`; класс B:
тесты/demo; класс C: документация/changelog), ветка `issue/131-readonly-cold-start`,
трейлеры;
- `docs/TOUCH-SUPPORT.md` — View/kiosk как гарантированные touch-поверхности;
- `docs/USER-GUIDE.ru.md` — терминология «пространство», «вкладка», «киоск»;
- фактическому состоянию кода `src/houseplan-card.ts` — на предмет того, что
технические утверждения ТЗ (диагноз причины, поведение hash/`LS_NAV`/warm
viewport, no-op клика активной вкладки) не являются непроверенной догадкой,
а описывают код, который действительно существует.
## Как проверялось
1. Прочитан весь тред issue #131: исходный отчёт с двумя скриншотами,
аналитика S2 (таблица диагностики на `dev` SHA `0e69c4a18337`, два
пространства `home`/`upstairs`), решение владельца о defaults D1–D3
(https://github.com/Matysh/houseplan-card/issues/131#issuecomment-5287280361),
комментарий «Взял» и комментарий «ТЗ готово».
2. Сверены обязательные разделы ТЗ (§7.1 `PROCESS.md`) построчно — таблица ниже.
3. Прочитан `setConfig()` (`src/houseplan-card.ts:2258-2321`): подтверждено, что
`default_floor` и cache-приоритет (`hash → LS_NAV → default_floor → model[0]`)
применяются здесь только когда есть валидный `LS_CFG`; без кэша `_space`
остаётся дефолтным `'f1'` до серверного ответа — совпадает с §3 ТЗ.
4. Прочитан `_loadFromServer()` (`src/houseplan-card.ts:3193-3316`): подтверждено
дословно то, что описывает §3/§8 ТЗ —
- `_adoptStructuralResponses()` (принятие config/layout/`can_write`) вызывается
**до** трёх последовательных `await this.hass.connection.subscribeEvents(...)`
(`houseplan_config_updated`, `houseplan_trail_updated`, `houseplan_layout_updated`);
- выбор `_space` по hash/`LS_NAV`/`_norm`-fallback и `_cacheSnapshot()`
находятся **после** этих трёх `await`, внутри того же `try`;
- отклонённый `subscribeEvents` уходит во внешний `catch`, который при уже
установленном `_serverCfg` вызывает `_scheduleLoadRetry(true)` — то есть
ошибка необязательной подписки трактуется как отказ всей загрузки и
провоцирует непрерывный retry. Это ровно то, что ТЗ называет в §3 п.4 и
в риске §14.2.
5. Прочитан `_warmAdoptViewport()` (`src/houseplan-card.ts:2451-2499`) и
окружающие флаги `_hashApplied`/`_navApplied` (`:1521-1522`, `:2296-2299`,
`:2466`, `:3266-3277`): подтверждено, что explicit `#space=` hash уже сегодня
выигрывает у warm-viewport (`if (this._hashApplied || …) { this._warmVp = null; return; }`),
а принятый warm viewport не сбрасывается менее точным `LS_NAV`/`default_floor`
(`_navApplied = true` защищает ветку в `_loadFromServer`). Формулировка §7.2
ТЗ об этом взаимодействии технически точна, а не придумана заново.
6. Прочитан `_pickSpace()` (`src/houseplan-card.ts:1087-1097`): `if (id === this._space) return;`
— клик по уже активной вкладке уже сегодня no-op. AC4 ТЗ («после исправления
клик активной вкладки — no-op») не вводит новое поведение клика, а лишь
требует, чтобы `_space` корректно совпадал с реальным выбором к моменту клика.
7. Прочитана генерация id пространства (`src/houseplan-card.ts:12067`,
`spaceId = 's' + Date.now().toString(36)`): реальные id никогда не выглядят
как `f1`/`f2`, то есть легаси-дефолт `_space = 'f1'` (`:611`) — синтетический
сентинел, а не потенциально валидный id; диагностическая таблица ТЗ с
`home`/`upstairs` репрезентативна, коллизии не подразумевается.
8. Проверено `docs/CONFIG-COMPATIBILITY.md` — ни `LS_CFG`, ни server-config
schema там не упомянуты в связи с этим изменением; заявление ТЗ §9 «миграция
не нужна» не противоречит канону.
9. Проверено существующее browser-smoke покрытие: `demo/smoke_warm_remount.mjs`,
`demo/smoke_kiosk.mjs`, `demo/smoke_warm_owners.mjs`, `demo/smoke_warm_dialogs.mjs`
реально существуют — предположение §17 п.5 («smoke может расширить
существующий WS/warm lifecycle сценарий») опирается на реальную
инфраструктуру, а не на вымышленный файл.
10. Проверена запись в `docs/specs/README.md` — строка на #131 добавлена в том
же коммите `0164e65`, ссылка issue ↔ ТЗ двусторонняя.
11. Сверена терминология с `docs/USER-GUIDE.ru.md` (§7 «Пространства», §17
«Киоск-режим», обычный просмотр «не отдельная вкладка») — ТЗ использует
«пространство», «вкладка», «киоск» ровно в этом значении, ничего не
изобретает.
12. Сверено с `docs/TOUCH-SUPPORT.md` — «View is fully supported/must be
convenient and reliable» и «kiosk is the primary supported environment»:
формулировка ТЗ §10 «View на touch блокирующий… в киоске требование ещё
строже» дословно соответствует канону, а не собственная эскалация автора.
## Обязательные разделы (§7.1 PROCESS.md)
| Раздел | Есть | Комментарий |
|---|---|---|
| Сценарий (персона/поверхность/момент) | ✅ | §1 |
| Что человек увидит до/после (без терминов реализации) | ✅ | §2, одной фразой каждое состояние |
| Проблема | ✅ | §3, с подтверждённой диагностикой и таблицей на конкретном SHA |
| Скоуп / не-скоуп | ✅ | §5 / §6 |
| Контракт поведения | ✅ | §7 (инвариант, приоритет, mandatory/optional, деградация, reload/cache) |
| UX / i18n / accessibility / touch | ✅ | §10 |
| Модель данных и миграция | ✅ | §9 |
| AC1…ACn с доказательством | ✅ | §11, 12 штук, каждый с типом (`unit`/`smoke`/«ревью кода»/`build`) |
| План автотестов | ✅ | §12 |
| Риски | ✅ | §14, 5 пунктов, каждый со ссылкой на закрывающий AC |
| Откат | ✅ | §16 |
| Release-артефакты | ✅ | §15 |
Все обязательные разделы присутствуют и содержательны. Дополнительно есть
раздел §8 «Архитектурный контракт реализации» и §17 «Принятые технические
предположения» — оба корректно отделены от продуктового контракта.
## Находки
Находок уровня **High** и **Medium** нет.
### Low-1 — плотная формулировка §7.2 про hash/warm-viewport взаимодействие
**Файл:** `docs/specs/131-readonly-cold-start.md:146-160`
Абзац описывает две ветки (без принятого warm viewport / с ним) одним плотным
текстом: «применяется существующий порядок… Explicit hash по-прежнему
выигрывает. Уже принятый валидный same-route warm viewport остаётся
существующим continuity-исключением… и не сбрасывается менее точным
saved/default значением». При беглом чтении можно ошибочно понять, что
`explicit hash` всегда переопределяет уже принятый warm viewport — в
реальности (проверено чтением `_warmAdoptViewport`, `src/houseplan-card.ts:2466`)
hash блокирует **принятие** нового warm viewport, но не выбивает уже
принятый в текущем цикле рендера; вторая фраза говорит о независимой ветке
«when warm viewport already accepted», а не о конкуренции с первой веткой в
рамках одного и того же кадра. Технически формулировка точна (я сверил её с
кодом и противоречия не нашёл), но растянута до состояния, что имплементатор
может перечитать её как конфликт правил.
**Почему не блокирует:** AC1/AC5/AC6 explicitly не расширяют матрицу на
конкуренцию «hash vs уже принятый warm viewport» — этот случай прямо в
не-скоупе (§6: «изменение выбора пространства… deep link… не входит»), то
есть задача обязана лишь не сломать существующее поведение, а не
переописывать его заново. Формулировка описывает уже существующий, а не
новый контракт, и не создаёт риска неверной реализации, потому что §8 п.2
и риск §14.1 уже требуют «одного resolver» и защищают его тем же AC1/AC5/AC6.
**Решение ревьюера:** Low, не блокирует. На усмотрение автора — можно
перефразировать двумя явными пунктами («без warm viewport: …», «warm viewport
уже принят: …») при следующей правке или оставить как есть.
## Что проверено и корректно
- Соответствие `docs/SCOPE.md`: задача закрывает J1 («план должен быть читаемым
и полным сразу при открытии») и J6-грань «reload/reconnect/техническое
перемонтирование не должны менять состав видимого плана» — регрессия внутри
уже закрытых Core user jobs, не новая функциональность и не расширение
скоупа; ни один пункт «Out of scope» не задет.
- Гарантированный View/touch/kiosk-контракт (`docs/TOUCH-SUPPORT.md`) учтён
верно и процитирован дословно, не переизобретён.
- Владелец лично принял defaults D1–D3 и приоритет P1 (комментарий
2026-08-14) — открытых продуктовых вопросов в финальной редакции ТЗ нет, и
это корректно: вопросы (триггер cold start vs прав, поведение при
нескольких пространствах, переживание reload) были заданы и закрыты на
этапе аналитики диагностическим прогоном, а не додуманы автором ТЗ.
Раздел §17 «Принятые технические предположения» отделяет свободно
изменяемые технические решения (имя resolver'а, форма orchestration
подписок, механизм retry, файл browser smoke) от продуктовых решений D1–D3,
которые пересмотру не подлежат — ни одна догадка не выдана за факт без
пометки.
- Технический диагноз причины (порядок `_adoptStructuralResponses` → три
`await subscribeEvents` → выбор пространства/`_cacheSnapshot`, отказ
подписки уходит во внешний `catch` и триггерит `_scheduleLoadRetry(true)`)
подтверждён построчным чтением `_loadFromServer()` — не голословное
утверждение автора, а точное описание существующего кода.
- Поведение hash/`LS_NAV`/warm-viewport и no-op клика активной вкладки
(AC4) подтверждено чтением `setConfig`, `_warmAdoptViewport`, `_pickSpace`
— расхождений с ТЗ не найдено (см. Low-1 только по ясности формулировки,
не по фактической корректности).
- AC1–AC12 однозначны, у каждого указан тип доказательства из допустимого по
DoR перечня (`unit`/`smoke`/«ревью кода»/`build`); план автотестов (§12)
даёт конкретный маршрут, включая явное требование покрыть матрицу
valid/stale hash × `LS_NAV` × `default_floor` × 0/1/N пространств (AC1) и
проверять «умеет падать» через фиксированный HA snapshot до/после no-op
клика (AC4).
- Не-скоуп (§6) корректно отсекает смежные соблазны: не менять backend
permissions/websocket API, не добавлять новый toast/recovery overlay, не
трогать порядок вкладок/deep-link/формат cache, не менять
`houseplan-space-card`, не переписывать весь boot lifecycle — типичные
места, где скоуп мог бы незаметно расшириться из-за близости к async boot.
- Раздел §13 (Производительность и security) обоснованно закрывает вопрос
«не ослабляет ли исправление read-only-границу»: подписка на запрещённые
события не эмулируется, write API не вызывается — прямо отражает
требование AC10.
- Release-артефакты (§15) перечисляют реальные файлы:
`docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` (существуют), `docs/ARCHITECTURE.md`
(существует), `test/*.test.mjs` и `demo/smoke_*.mjs` (существующая
инфраструктура, см. «Как проверялось» п.9), три bundle snapshot
(существующая практика синхронизации по `AGENTS.md`).
- Реестр `docs/specs/README.md` обновлён тем же коммитом, ссылка issue ↔ ТЗ
двусторонняя (`PROCESS.md` §7.1).
- Трейлеры коммита `0164e65` (`Issue: #131`, `User-Visible: no`) корректны:
изменение — только документация ТЗ, продуктовый код не тронут, что явно
подтверждено и в тексте самого ТЗ, и в комментарии «ТЗ готово».
## Чего не проверял
- Не проверял, что предложенная в §8 архитектурная декомпозиция (единый pure
resolver cold precedence) реализуема без побочных эффектов на смежные поля
`_hashApplied`/`_navApplied`/`_warmVpArmed` — по правилам ТЗ (§17 п.1–2) это
свободно изменяемое техническое предположение автора кода, предмет
код-ревью, а не ревью ТЗ.
- Не запускал никаких автотестов и не собирал бандл — на этапе `spec` это не
требуется; существование упомянутых тестовых файлов и smoke-инфраструктуры
проверено чтением файловой системы, не исполнением.
- Не проверял golden/скриншоты — ТЗ §12 явно и обоснованно заявляет их
ненужность (новый визуал совпадает с уже принятым состоянием после клика);
это будущий предмет пре-релизного гейта, не ревью ТЗ.
- Не проверял поведение `houseplan-space-card` (статическая карточка) —
явно вынесено в не-скоуп §6, и это верно: она получает пространство
отдельным обязательным параметром, а не через выбор, который чинит эта
задача.
- Не проверял, действительно ли трёх-подписочный orchestration в текущем
коде уже идемпотентен при повторном вызове `_loadFromServer` после отказа
(авторитет `_unsubCfg`/`_unsubTrail`/`_unsubLayout`, AC7/AC11) —
прочитал структуру `if (!this._unsubX) { this._unsubX = await … }`
(`src/houseplan-card.ts:3232-3263`) и она выглядит согласованной с
требованием, но полное покрытие гонок (двойной параллельный вызов
`_loadFromServer`) — предмет код-ревью на реализованном коде, не ревью ТЗ.
## Вердикт
Зелёный. High: 0, Medium: 0. Одна находка Low (плотность формулировки §7.2
про hash/warm-viewport) — не блокирует, технически формулировка проверена и
корректна, оставлена автору на усмотрение с записью в этом документе.
-576
View File
@@ -1,576 +0,0 @@
# Issue #107 — переключение виртуального источника света «Всегда»
- **Issue:** https://github.com/Matysh/houseplan-card/issues/107
- **Редакция:** первая редакция для независимого ревью; статус задачи определяется
только метками issue
- **Тип / приоритет:** feature + polish / P2
- **Оценка:** пользовательская ценность 6/10; ценность для разработки 3/10;
сложность и риск 7/10
- **Область:** View-action, canonical light resolver, target preview, backend
operational state, multi-card live-sync, i18n, compatibility и тесты
- **Связано:** #65, #84, #88, #94, #97, `docs/SCOPE.md`,
`docs/TOUCH-SUPPORT.md`, `docs/LIGHT.md`,
`docs/CONFIG-COMPATIBILITY.md`
## 1. Сценарий и продуктовый контекст
**Персона:** home admin либо домочадец, который пользуется обычным View на
настенной панели, телефоне или desktop dashboard. Право редактировать план для
этого действия не требуется.
**Поверхность и момент:** на плане есть virtual marker, у которого роль источника
света явно установлена в «Всегда», а действие по нажатию — «Переключить
состояние». Пользователь нажимает marker, чтобы погасить или снова включить
изображённый виртуальный светильник.
Задача обслуживает:
- **J1:** Glow, room fill и room light statistics показывают одно текущее
пространственное состояние;
- **J3:** очевидное безопасное действие выполняется прямо с плана;
- гарантированный touch/View/kiosk-контракт: tap работает так же, как click, и
результат не расходится между настенной панелью, телефоном и второй карточкой.
Это утверждённое владельцем узкое исключение к замороженной функциональности
virtual devices. Оно не создаёт общего state engine для виртуальных маркеров и
не меняет их роль placeholder в остальных комбинациях.
## 2. Что человек увидит до и после
**До:** диалог честно сообщает, что переключаемого состояния нет, а нажатие в
View является тихим no-op. Passive `Always` source без контроллера всегда
считается включённым по контракту #84.
**После:** в точной утверждённой комбинации preview показывает сам виртуальный
источник, его текущее состояние и следующий эффект. Нажатие выключает его, а
повторное включает. Одновременно меняются его Glow и все room-light consumers.
Результат сразу видят другие карточки и он сохраняется после reload страницы и
restart Home Assistant.
## 3. Подтверждённая причина текущего поведения
1. `resolvedLightSources()` создаёт для passive `Always` источник с identity
`marker:<id>`, пустыми `stateEids`/`serviceEids` и default `on`.
2. Если сохранены incoming controller links, #84 заменяет default на OR их HA
driver entities. Ручного состояния у источника нет.
3. `resolveToggleIntent()` ищет только HA service target. Virtual marker без
actionable entity получает `no-actionable-entity`.
4. `_clickDevice()` выполняет только HA service command и при его отсутствии
намеренно ничего не делает.
5. Hint в диалоге форматирует тот же resolved intent и поэтому показывает
`marker.toggle_none_no_actionable_entity`.
Локальная ветка только в renderer не подходит: #84 закрепил
`resolvedLightSources()` как authority для Glow, room fill, room state,
`N из M`, room card и preview. Локальный browser state также нарушил бы принятое
решение о reload/restart и multi-card synchronization.
## 4. Решения владельца
Владелец принял defaults Q1–Q3 14.08.2026. Каноническая запись:
https://github.com/Matysh/houseplan-card/issues/107#issuecomment-5287719167
1. Состояние общее для всех карточек, live-синхронизируется, переживает reload
страницы и restart Home Assistant. Источник без сохранённой записи начинает
в `on`, сохраняя совместимость с #84. State является operational data и не
входит в конфигурацию плана или export.
2. Переключается canonical state источника, а не один SVG Glow. Вместе меняются
Glow, room light fill, room statistics/card и preview. Геометрия, цвет,
яркость и радиус не меняются.
3. Пока действует точная тройка `binding=virtual` + `is_light=true` + effective
`tap_action=toggle`, ручное состояние авторитетно; incoming controller links
сохраняются, но не управляют этим источником. Когда тройка перестаёт
действовать, ручная запись удаляется и немедленно возвращается обычная
семантика #84. Повторное включение тройки начинает с `on`.
## 5. Точное условие исключения
Manual virtual-light action существует только для активного marker, у которого
одновременно выполняется:
1. `marker.binding === 'virtual'`;
2. `marker.is_light === true`;
3. effective tap action равен `toggle`;
4. marker не является удалённым tombstone (`removed !== true`).
Для virtual marker нет HA domain, который мог бы неявно спроецировать default
`toggle`, поэтому сохранённый action token обязан быть `toggle`. Backend всё
равно проверяет persisted config, а не доверяет утверждению frontend.
`hidden: true` временно убирает источник из визуальных consumers, но не стирает
его ручное состояние: возврат видимости восстанавливает прежний `on/off`.
В точной тройке manual target имеет приоритет действия. Tap переключает только
состояние собственного virtual source и не вызывает HA service даже при наличии
сохранённых outgoing `controls`. Сами `controls` остаются lossless и продолжают
участвовать в light graph по правилам #65/#84. После выхода из тройки обычный
resolver #94 снова получает их без изменения.
Все прочие сочетания — virtual Auto/Never, non-virtual Always, другое действие,
удалённый marker — сохраняют текущее поведение без нового state target.
## 6. Нормативная модель состояния
### 6.1. Значение по умолчанию
Отсутствующая запись означает `on`. Поэтому существующие passive `Always`
источники не меняют вид после обновления, а первый tap переводит источник в
`off`.
Хранилище может сохранять только множество выключенных marker IDs: отсутствие
ID является canonical `on`. Это техническая форма, а не новый пользовательский
режим. В ответах frontend получает revisioned snapshot, достаточный для
однозначного восстановления `on/off`.
### 6.2. Один canonical consumer contract
Для eligible marker manual state подменяет только вычисление `source.on`.
Identity, room ownership, position, `castsGlow`, geometry, manual/live colour,
brightness и radius остаются теми же.
Один и тот же resolved source обязан дать одинаковый результат для:
- spatial Glow full card и kiosk;
- light room fill;
- room light state и `resolvedLightStats` / `N из M`;
- room card и device presentation;
- device-editor preview;
- статического `houseplan-space-card`.
Нельзя добавлять manual-state ветку отдельно в SVG renderer, room card,
preview или static card. Runtime snapshot является входом canonical light
resolver, а его revision входит в cache invalidation. Один tap не должен ждать
следующего HA state tick.
### 6.3. Controller semantics
- Для eligible source manual `on/off` выигрывает у incoming controllers.
- Incoming `marker:<id>` links не удаляются, не становятся service targets и не
меняют HA entities.
- Контроллер продолжает выполнять собственное действие #94, но его HA state не
меняет manual source, пока тройка активна.
- После выхода target из тройки manual record очищается; incoming links снова
определяют passive source по OR-контракту #84 без Save со стороны controller.
- Остальные источники того же room продолжают давать независимые votes. Поэтому
выключенный manual source не обязан погасить комнату, если в ней включён другой
источник.
## 7. Backend storage и WebSocket contract
### 7.1. Отдельные operational data
State хранится в отдельном versioned Home Assistant `Store`, например
`houseplan.virtual_lights`, а не в `Marker`, layout, Lovelace config,
localStorage-authority или HA entity registry.
Минимальная логическая структура:
- monotonic runtime revision;
- последняя согласованная config revision;
- bounded set/map выключенных eligible marker IDs.
Точные Python class/key names можно изменить. Обязательны отсутствие записи =
`on`, лимит не выше числа markers, отказ от произвольных строк вне live config и
отдельность от export/import payload.
### 7.2. Initial snapshot
`houseplan/config/get` возвращает дополнительное optional поле с revisioned
virtual-light snapshot. Оно читается вместе с той же config revision, по которой
backend проверил eligibility.
Это поле является обязательной частью корректного нового frontend frame:
сохранённый `off` не может сначала отрисоваться как `on` и исправиться только
после необязательной подписки. Старый frontend игнорирует добавочное поле.
Поле optional на wire только для rolling/downgrade compatibility. Новый frontend
при его отсутствии применяет legacy/default `on`; попытка toggle на старом
backend завершается контролируемой ошибкой, без optimistic ложного состояния.
### 7.3. Atomic toggle command
Добавляется один authenticated WS command вида
`houseplan/virtual_light/toggle`, принимающий только `marker_id`.
Backend под общим для config/state load-modify-save lock:
1. загружает текущую config revision и marker;
2. повторно проверяет точное условие §5;
3. читает текущее значение (`on`, если записи нет);
4. атомарно инвертирует его и сохраняет state revision;
5. возвращает `{marker_id, on, rev}`;
6. после успешного save публикует `houseplan_virtual_light_updated` с тем же
payload.
Клиент не присылает желаемый boolean и не выполняет read→set: две одновременные
команды сериализуются как два toggle, а не теряют одну запись. Event не
публикуется до durable save. Invalid/missing/ineligible marker получает
стабильную ошибку `not_toggleable`; store и event при этом не меняются.
### 7.4. Права
Команда доступна любому аутентифицированному HA-пользователю, которому доступна
карточка. `may_write` и опция admin-only защищают редактирование плана, но не
View-action; иначе утверждённая household/kiosk persona увидела бы рабочий hint
и получила no-op.
Это разрешение уже, чем HA service toggle: request содержит только marker
ID, backend разрешает ровно утверждённую тройку и не вызывает HA service. Guest
без authenticated HA session доступа не получает.
### 7.5. Config coherence и cleanup
Каждый backend writer конфигурации — обычный Save, import apply, optimize/undo,
setup migration и другие пути замены config — согласует operational store с
новой config revision до публикации завершённого config update:
- сохраняет `off` только для marker IDs, которые всё ещё eligible;
- удаляет записи deleted/rebound/Auto/Never/non-toggle markers;
- сохраняет state при rename, move, room/space reassignment, hidden/unhidden и
несвязанных edits;
- записывает согласованную config revision.
Из-за двух отдельных HA Store файлов crash между saves не может быть общей
транзакцией. Fail-safe правило: если operational store видит неизвестный разрыв
config revision (включая downgrade, старый writer или прерванный save), он
сбрасывает manual-off overrides к безопасному compatibility default `on`, а не
рискует воскресить старый `off` после повторного включения тройки. Следующий
snapshot сохраняет согласованную revision.
Все config writers должны использовать один backend helper; список разрозненных
ручных `state_store.async_save()` не допускается.
## 8. Frontend action и synchronization
### 8.1. Единый resolved intent
Authority #94 расширяется typed operational target, а не обходится ранним
`if` непосредственно в `_clickDevice()` или диалоге.
Для eligible marker resolver возвращает:
- stable target identity `marker:<id>` и читаемое имя marker;
- current `on/off` из принятого runtime snapshot;
- next effect `turn-off` / `turn-on`;
- executable operation `virtual-light-toggle`, отличную по типу от HA service
command.
Formatter preview/confirmation получает эту же модель. Virtual target никогда
не выдаётся за `entity_id` и никогда не попадает в `callService`.
### 8.2. Tap, click и confirmation
- Обычный tap/click отправляет atomic WS toggle и принимает только response/event
с revision не старее локального.
- Optimistic визуальный flip запрещён: transport/backend failure не должен
показывать несохранённое состояние.
- `tap_confirm: true` сохраняет существующий confirmation dialog. Перед
выполнением eligibility/target re-resolve повторяется; текущий direction
решает серверный atomic toggle.
- Если marker перестал быть eligible до подтверждения или ответа, action не
retargets на controls/HA entity. Пользователь получает существующий смысл
«цель изменилась» либо локализованную контролируемую ошибку.
- Ошибка WS использует локализованный toast и оставляет последний подтверждённый
state. Page error и unhandled rejection недопустимы.
### 8.3. Live-sync и несколько карточек
Full card подписывается на `houseplan_virtual_light_updated` рядом с текущими
House Plan events. `houseplan-space-card` получает тот же snapshot/event через
module-level shared cache, чтобы N static cards не создавали N fetch/subscription.
Monotonic revision защищает от перестановки response и event. Применение более
новой revision:
1. заменяет immutable runtime snapshot;
2. инвалидирует light/presentation cache;
3. обновляет local server-snapshot cache;
4. просит render без structural config/device rebuild и без HA state mutation.
Missed event после disconnect исправляется следующим `config/get`. Подписка
best-effort не заменяет initial snapshot и не является условием первого полного
кадра.
### 8.4. Local cache
`LS_CFG` может хранить последний revisioned operational snapshot рядом с
config/layout для мгновенного stale-while-revalidate кадра. Он не становится
authority: server response всегда выигрывает по revision/config coherence.
Старый cache без поля означает default `on`. Corrupt/oversized/wrong-shape state
игнорируется целиком, а не частично внедряется в resolver. Runtime event обновляет
cache, чтобы reload не давал краткого обратного Glow flash.
## 9. UX редактора и i18n
В точной тройке прежний текст
`marker.toggle_none_no_actionable_entity` не показывается. Вместо него preview
содержит локализованные строки:
- target: виртуальный источник с именем marker, без вымышленного entity ID;
- current state: «включён/выключен»;
- next effect: «включить/выключить».
Новый несохранённый draft и draft, который только что вошёл в тройку, показывают
initial `on → off`. Редактируемый сохранённый marker показывает server state.
Cancel не меняет operational state; Save меняет eligibility и запускает cleanup
только после принятого config write.
Нужны полноценные RU/EN keys для virtual target/state и ошибки toggle. Нельзя
склеивать предложения или показывать backend error text как готовый перевод.
Существующие HA entity/group/secure/missing hints #94 не меняются.
## 10. Accessibility и touch
Новых controls и focus order нет. Existing device marker, selector,
confirmation и toast сохраняют текущие accessible surfaces.
Touch View и kiosk — блокирующие поверхности:
- один tap выполняет ровно один toggle;
- pan, pinch, long-press и suppressed synthetic click не выполняют action;
- confirmation остаётся доступным и не теряет focus contract;
- live event не перехватывает focus и не создаёт screen-reader spam;
- static card остаётся неинтерактивной, но показывает тот же state.
Touch editor остаётся best effort: новый control не добавляется, а существующий
desktop-first preview должен оставаться читаемым без горизонтального overflow.
## 11. Lifecycle и edge cases
1. Existing eligible marker без state entry: `on`; первый tap → `off`.
2. Повторный tap: `off` → `on`, off-entry может быть удалена.
3. Reload/new browser/restart HA: последнее durable значение восстановлено.
4. Две full cards и static card: одно событие приводит все к одной revision.
5. Два одновременных tap: две server revisions, итог равен исходному состоянию.
6. Response пришёл после более нового event: stale response игнорируется.
7. Incoming controller включён, manual source выключен: source остаётся `off`.
8. Manual source выключен, другой room source включён: room остаётся `on`, stats
считают голоса раздельно.
9. Outgoing controls есть: manual tap не вызывает их HA services; config
сохраняется lossless.
10. `Always → Auto/Never`: off-entry очищена, #84/#65 действует сразу.
11. `toggle → info/run/more-info`: запись очищена, новое действие штатное.
12. Virtual → HA binding/rebind: запись очищена и не приклеивается к новой цели.
13. Delete/tombstone: запись очищена; restore/recreate начинает `on`.
14. Rename/move/room/space/hidden: state сохраняется по stable marker ID.
15. Full import сохраняет state только для совпавших всё ещё eligible IDs;
импорт не приносит state из backup.
16. Config revision gap/downgrade writer: conservative reset к `on`, без stale
resurrection.
17. Backend unavailable/command failure: confirmed state остаётся, generic no-op
не маскирует ошибку.
18. Old frontend + new backend: extra snapshot/event игнорируются, config не
повреждается; marker ведёт себя по старому.
19. New frontend + old backend: default `on`, команда не создаёт local false
state и заканчивается контролируемой ошибкой.
20. Auto/Never/non-virtual/missing marker request, подделанный вручную: backend
`not_toggleable`, без save/event/service.
## 12. Модель данных, compatibility и import/export
`Marker`, `ServerConfig`, layout и Lovelace card config не получают новых полей.
Backend `CONFIG_SCHEMA`, import schema и config revision не меняются. Миграция
плана и materialization отсутствуют.
Создаётся отдельный optional operational Store version 1. Его отсутствие при
upgrade/downgrade означает пустой off-set/default `on`. Старые integration и
frontend его не читают и не уничтожают plan config. Config revision gap после
старого writer обрабатывается fail-safe правилом §7.5.
Operational state намеренно не входит в:
- full/space export и import preview/apply;
- marker JSON и compatibility passthrough;
- Lovelace config/local editor draft;
- HA entity/device registry.
Диагностика может показывать version/revision/count, но не обязана экспортировать
полную map marker IDs. `docs/CONFIG-COMPATIBILITY.md` фиксирует wire field,
отдельный Store, old/new matrix и reset-on-unknown-revision.
## 13. Не входит в задачу
- общее переключаемое состояние для virtual devices;
- действие для virtual Auto/Never либо для `info`, `run`, `more-info`;
- создание `input_boolean`, synthetic HA light/entity или helper;
- управление цветом, brightness, radius, geometry или animation через tap;
- запись state в marker config, layout или export backup;
- изменение OR/links semantics #84 вне manual-authority исключения;
- переключение HA entities из exact manual action;
- история состояний, расписания, automations или restoration UI;
- новый editor control «начальное состояние»;
- polling, remote API либо отдельная general-purpose runtime-state platform.
## 14. Критерии приёмки
- **AC1 (`unit`):** eligibility matrix разрешает operational target только для
active `virtual + is_light:true + explicit/effective toggle`; Auto, Never,
non-virtual, other action и removed сохраняют прежний resolver #94.
- **AC2 (`backend`):** новый eligible marker без записи возвращает `on`; atomic
toggle сохраняет `off`, второй сохраняет `on`, увеличивает revision и
публикует event только после успешного durable save.
- **AC3 (`backend`):** два concurrent toggle одного marker сериализуются без
lost update; response/event revisions монотонны, итог соответствует двум
инверсиям.
- **AC4 (`backend` + `ревью кода`):** non-admin authenticated connection может
выполнить eligible toggle без `may_write`; forged missing/ineligible ID
получает `not_toggleable`, не меняет store и не вызывает HA service.
- **AC5 (`unit`):** manual `off` является `source.on=false` во всех canonical
light consumers; incoming controller `on` его не перекрывает, а другой
независимый room source продолжает влиять на room aggregate.
- **AC6 (`unit` + `ревью кода`):** exact manual intent имеет typed marker target,
current/next effect и operational operation; marker ID никогда не попадает в
`hass.states` как entity и в `callService`/`entity_id`.
- **AC7 (`unit` + `smoke`):** диалог exact-комбинации не показывает
`toggle_none_no_actionable_entity`, показывает virtual target и current/next;
остальные hints/confirmation #94 не изменились.
- **AC8 (`backend`):** restart/recreation runtime data загружает сохранённый
`off`; config/get возвращает согласованный revisioned snapshot, достаточный
для первого `off` frame без ожидания event.
- **AC9 (`backend`):** все config writers сохраняют state для rename/move/
hidden/unrelated edits и очищают его при role/action/binding/delete change;
неизвестный config revision gap сбрасывает overrides к `on`.
- **AC10 (`smoke`):** tap/click в full View меняет Glow, room fill и room
`N из M` вместе; повторный tap возвращает их. Touch pointer выполняет один
toggle, pan/pinch/long-press не выполняют.
- **AC11 (`smoke`):** две full cards и `houseplan-space-card` принимают один
server event и показывают одинаковый state; stale response не откатывает
более новую revision, reconnect/reload исправляет missed event.
- **AC12 (`unit` + `smoke`):** outgoing controls exact marker не получают HA
service от manual tap; после выхода из тройки прежний resolver #94 получает
сохранённые controls без потери.
- **AC13 (`unit` + `smoke`):** `tap_confirm` re-resolves stable marker target,
выполняет server-current atomic toggle и безопасно отказывает, если eligibility
изменилась до подтверждения.
- **AC14 (`unit` + `ревью кода`):** runtime revision инвалидирует canonical
light/presentation cache без HA tick, polling и structural config/device
rebuild; N static cards разделяют cache/subscription.
- **AC15 (`unit` + `build`):** RU/EN parity, malformed cache/store input и
old/new optional field matrix покрыты; три bundle snapshot после build
побайтно совпадают.
- **AC16 (`ревью кода`):** оба changelog, RU user guide, README light recipe,
LIGHT, ARCHITECTURE и CONFIG-COMPATIBILITY описывают точное исключение,
persistence/permissions и отсутствие export.
## 15. План тестов и доказательств
### 15.1. Frontend unit
1. Eligibility/action matrix AC1/AC6/AC12.
2. Canonical source matrix: default on, manual off/on, controller OR ignored,
other room sources, hidden and exit from triple.
3. Intent formatter: virtual target/current/next, no fake entity ID, no old
no-actionable text, confirmation target stability.
4. Runtime snapshot cache: revision ordering, event-before-response,
reconnect snapshot and malformed local cache.
5. Resolver-cache mutant: removing runtime revision from cache key must make a
test fail because state would stay stale without HA tick.
### 15.2. Backend
1. Store absence/default, off/on persistence and runtime recreation.
2. Atomic concurrency with controlled interleaving; save/event ordering.
3. Admin and non-admin authenticated connection; invalid marker matrix.
4. Config writer lifecycle for ordinary set, import apply, optimize/undo and
setup migration path.
5. Config revision gap, corrupt/oversized Store, bounded pruning and save
failure without false event.
6. Wire compatibility: optional snapshot, old client payload and no config
schema/export changes.
### 15.3. Browser smoke
Targeted scenario builds one eligible virtual lamp, one incoming controller,
one unrelated room source, two full cards and one static card. It checks
AC7/AC10–AC13 using real click and touch pointer sequences, visible Glow/room
presentation, WS call log, event revisions and zero HA service calls.
Reload starts from server `off` and proves the first committed candidate is
already off. A disconnected card misses an event, reconnects and adopts the
current snapshot. Confirmation race changes config before confirm and proves no
retarget.
По действующему процессу smoke пишется вместе с реализацией, но в цикле
реализации запускаются только typecheck, unit и build. Целевой smoke и полный
browser smoke исполняются перед бетой; reviewer может запустить узкий сценарий
для доказательства AC.
### 15.4. Golden и performance
Golden matrix получает точный on/off virtual-lamp fixture на существующей flat
View сцене; новый художественный baseline не проектируется. Golden verify и
обновление принятого baseline выполняются только на pre-beta gate.
Performance gate должен подтвердить отсутствие polling, дополнительного HA
state subscription, per-source network call и полного config/device rebuild на
event. Отдельный новый численный budget не вводится: операция пользовательская,
state snapshot bounded числом markers, а resolver остаётся frame-cached.
## 16. Security и надёжность
Основной новый trust boundary — authenticated non-admin WS mutation. Он
ограничен следующими правилами:
- marker выбирается только из server config;
- eligibility вычисляет backend;
- command не принимает state, entity ID, service/domain или config fragment;
- payload и persisted set ограничены существующим marker limit;
- одна команда делает одну atomic inversion;
- никакой HA service/file/network action не выполняется;
- failed save не создаёт success response/event;
- revision gap деградирует в `on`, а не в stale manual state.
Lock/alarm invariant `docs/SCOPE.md` не затронут: virtual operational target не
может резолвиться в secure entity или HA service.
## 17. Release-артефакты
Реализационный пользовательский коммит содержит:
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #107;
- `docs/USER-GUIDE.ru.md` — рецепт точной тройки, persistence и отличие от
controller link;
- `README.md` — краткий EN recipe/contract;
- `docs/LIGHT.md` — manual authority в canonical resolver;
- `docs/ARCHITECTURE.md` — operational Store/WS/live-sync boundary;
- `docs/CONFIG-COMPATIBILITY.md` — old/new matrix, config-revision coherence и
export exclusion;
- frontend unit, backend tests и browser scenario;
- синхронные `dist/houseplan-card.js`,
`custom_components/houseplan/frontend/houseplan-card.js` и
`demo/srv/assets/houseplan-card.js`.
Новых migration UI, screenshot документации и отдельного security report не
требуется. Перед бетой идут штатные golden verify, browser smoke и performance
gate по release runbook.
## 18. Откат
Откат — обычный revert frontend/backend изменения, тестов, документации,
changelog и bundle snapshot. Plan config/layout/import formats не меняются.
Отдельный operational Store после downgrade безопасно игнорируется; старая
версия снова считает passive `Always` source включённым и не умеет его
переключать. При последующем upgrade незнакомый config revision сбрасывает stale
overrides к `on`. Ручное редактирование `.storage` пользователю не предлагается.
## 19. Принятые технические предположения — можно менять без пересмотра продукта
1. Отдельный Store и хранение только off IDs предпочтительны, но точный key и
Python class names не являются продуктовым решением.
2. Initial state может ехать дополнительным полем `config/get` либо другим
атомарным snapshot endpoint, если первый frame, old/new compatibility и
config-revision coherence полностью сохранены.
3. Typed intent можно оформить union-командой либо отдельным operational action
рядом с HA command; запрещены только две расходящиеся resolver-ветки.
4. Full card может иметь собственную event subscription, а static cards — общую
module-level; точная раскладка допустима при отсутствии N одинаковых fetch.
5. Local cache хранит snapshot для continuity, но server revision остаётся
authority и optimistic toggle не вводится.
6. Config writer helper может консервативно сбросить все manual overrides при
неизвестном revision gap вместо попытки угадать историю eligibility.
7. Hidden marker сохраняет state, tombstone удаляет; это следует существующей
границе visual hide против semantic delete.
8. Full import не импортирует state, но может сохранить текущее значение
совпавшего stable eligible marker ID после reconciliation.
-623
View File
@@ -1,623 +0,0 @@
# Issue #122 — Isometric Stage 2: hidden visual polish
- **Issue:** https://github.com/Matysh/houseplan-card/issues/122
- **Predecessor:** #89, normative Stage 1 contract
[`089-isometric-view-stage1.md`](089-isometric-view-stage1.md)
- **Owner decisions:**
https://github.com/Matysh/houseplan-card/issues/122#issuecomment-5286229767
- **Visual references:**
https://github.com/Matysh/houseplan-card/issues/89#issuecomment-5279077001
- **Type / priority:** feature / P2
- **Assessment:** user value 7/10; development value 6/10; complexity and
risk 9/10
- **Revision:** first revision for independent spec review; task status is
defined only by the issue labels
- **Related:** #124 (existing Iso ↔ Flat performance debt), #82 (camera
animation, explicitly out of scope)
## 1. Scenario and product context
**Persona:** a home administrator who deliberately enables the hidden `iso`
Labs experiment to evaluate it on a real plan. Household members and guests do
not discover or enable Stage 2 through the product UI.
**Surface and moment:** the administrator opens ordinary View or kiosk on a
desktop, phone or wall tablet, activates the existing `iso` Labs flag and
switches the current space from Flat to the volumetric presentation.
**Before → after, without implementation terms:** today the hidden volumetric
view shows technically correct but schematic white/grey wall slabs and flat
opening symbols; after Stage 2 the same live home has restrained material
depth, vertical openings, a low outside floor edge and soft grounding shadows,
without losing any state, action or existing floor effect.
The task serves:
- **J1:** the live whole-home view becomes easier to read spatially;
- **J2:** opening, lock and alert state stays located on the same plan;
- **J3:** existing safe actions remain available at the same projected anchors.
This remains inside the owner-approved narrow exception in `docs/SCOPE.md`:
deterministic 2.5D presentation of the canonical plan, not a second model,
photorealistic renderer, free camera or interior editor. View is still the
product; the experiment being hidden does not relax the View/kiosk touch
contract.
## 2. Problem
Stage 1 established the hard parts of the renderer: one fixed orthographic
projection, canonical wall geometry, complete opening cuts, screen-facing
overlays, live floor layers, fallback, warm-remount and bounded geometry cache.
Its deliberate visual limits are now the gap:
- wall tops and sides are flat solid colours with minimal depth cues;
- the floor has no visible outside edge and the plan does not feel grounded;
- doors, windows and gates keep their old floor-plane symbols inside a
volumetric wall gap;
- there is no ambient, contact or leaf shadow;
- owner references and the accepted pseudo-3D direction are not yet represented.
A literal copy of the references would be wrong. They omit labels, live fills,
decor, furniture, columns, Glow, sunlight and vacuum data, and they imply
concrete/wood semantics that House Plan does not store. Stage 2 must adopt the
references' language of depth while retaining the user's canonical content and
the semantic meaning of every live layer.
## 3. Accepted owner decisions
The owner accepted defaults Q0–Q6 on 13 August 2026.
1. Priority is P2; Stage 2 is valuable but hidden and does not displace public
P1 defects.
2. Stage 2 evolves the existing `iso` flag. There is no second flag, public
setting or rollout surface; Flat remains the initial presentation.
3. The references guide wall/opening/edge material and depth only. They do not
replace the plan image, room fills, labels, decor/backdrop, furniture,
Glow/spill, sun, vacuum or canonical columns, and do not add inferred
concrete/wood/terrace materials.
4. In Iso, doors use one vertical leaf, gates use their existing pair of short
leaves and windows use a light vertical insert. The old floor symbol is not
duplicated while the volumetric representation is active. Existing contact,
inversion, hinge/side, live state, lock card/badge and actions remain.
5. There is no new decorative window-light beam on the floor. Windows may have
a local material highlight; directional window light remains the existing
live sunlight and room light remains Glow.
6. One low outside floor edge and restrained ambient/contact/leaf shadows are
included. There are no per-room steps or new terrace/porch entities.
7. Unsupported decorative filters may disappear without losing structural
volume, openings, live layers or actions. Flat fallback remains reserved for
structural projection/topology failure.
## 4. Scope
Stage 2 includes:
1. evolution of the existing hidden `iso` presentation under the same Labs
activation and expiry contract;
2. theme-aware matte material for wall tops and visible sides, with a stable
distinction between top, side and outline in light and dark themes;
3. one low outside floor edge for every connected exterior component of the
canonical room footprint, with no edge at internal room boundaries;
4. a restrained ambient shadow for the complete footprint, contact shadow at
physical walls and soft shadow for visible vertical leaves;
5. vertical Iso presentation of doors, windows and gates derived only from the
existing opening model and live `openingAmount()` semantics;
6. exact preservation of opening gaps, jamb faces, lock markers, room fills,
hover, Glow/spill, sunlight, plan image, decor, furniture, vacuum and all
device/room overlays;
7. deterministic behaviour for `show_borders`, `hide_openings`, themes,
forced colours and missing decorative-filter support;
8. fit/pan/zoom/warm-remount/fallback bounds that include structural floor and
opening volume without clipping;
9. unit, targeted browser smoke, golden and exact-SHA performance evidence;
10. internal architecture, Iso and status documentation.
## 5. Out of scope
- public activation, settings, onboarding, announcement or documentation of
Iso; public rollout requires a separate owner-approved issue;
- a second `iso2` flag or an A/B selector between Stage 1 and Stage 2;
- free rotation, perspective, user tilt, camera presets or #82 animation;
- new wall, floor, door or window material settings;
- inferred room types, concrete, wood, terrace, porch or floor finish;
- a constant decorative beam from each window or a second light model;
- geometric occlusion of device markers, labels, cards or popovers by walls;
- volumetric Plan/Devices/Background editors;
- volumetric `houseplan-space-card`;
- opening height/sill/frame fields, schema changes or compatibility aliases;
- 3D furniture, ceiling, roof, external-object shadows or weather lighting;
- WebGL, Three.js, CSS `preserve-3d`, raster textures or a runtime dependency;
- fixing or weakening the independent performance defect/budget in #124;
- backend, import/export, HA registry, service calls or lock security changes.
## 6. Behaviour contract
### 6.1 Labs and surface boundary
- `iso` remains the only Labs id. Query/hash/storage grammar, Flat default,
per-space preference, kiosk preference and `-iso`/`off` rollback remain the
Stage 1 contract.
- Stage 2 is available only while that registry entry is live. Its existing
exclusive expiry at `1.65.0` is not extended by this issue; a `1.65.0-beta.*`
build must fail closed unless a separate reviewed graduation decision exists.
- Editors and `houseplan-space-card` stay Flat. Entering an editor, returning
to View, changing space and warm-remount preserve the existing logical centre
and projection preference.
- With Labs inactive, no Stage 2 geometry, definitions, filters, capability
work or DOM is created. Existing Flat DOM and pixels remain the reference.
### 6.2 Material language
- Walls remain low, neutral and matte. Top faces are lighter than visible side
faces in both themes; an outline keeps adjacent faces distinguishable.
- Material uses deterministic CSS/SVG tokens. A subtle bounded gradient or
reusable low-amplitude treatment is allowed, but no raster texture, random
noise, glossy highlight or floor-material replacement is allowed.
- Theme or colour-scheme changes update presentation only and do not rebuild
topology or change cache keys.
- User room/border colours retain their existing Flat/editor meaning. Stage 2
does not reinterpret them as physical material.
- Materials do not encode HA state. All live state remains in the existing
room/device/opening layers.
### 6.3 Canonical floor edge
The Stage 2 floor footprint is computed from the union of room floor polygons
and their derived exterior masonry. It deliberately excludes detached
partitions, columns, decor, background images and far markers from deciding the
slab perimeter.
- Each connected exterior component receives one low vertical edge below the
current floor plane.
- Shared room edges and other internal boundaries never form a step or seam.
- Nested-room/internal holes do not create an additional decorative step.
- Two genuinely detached room components may each have an outside edge; they
are not bridged by a bounding box.
- A space without room geometry has no inferred floor edge.
- The existing floor nodes remain the top surface. Stage 2 must not paint a new
opaque floor over plan images, fills, Glow, sunlight or decor.
### 6.4 Shadows
There are three decorative depth cues:
1. one ambient shadow for the complete connected plan footprint;
2. a restrained contact shadow where physical masonry meets the floor;
3. a soft shadow belonging to each visible door/gate leaf.
They are presentation only:
- no shadow changes light visibility, room state, hit testing or HA action;
- shadow opacity is bounded so room fills, Glow and sunlight remain readable;
- no per-edge SVG filter is created; filter/definition count is O(1) per card,
while geometry remains O(E + O) for wall edges and openings;
- theme, hover and an HA-only device update do not reconstruct shadow geometry;
- shadows from roofs, neighbouring wings, furniture or outdoor objects are not
modelled;
- reduced/unsupported decoration removes the shadow rather than substituting a
hard, misleading shape.
### 6.5 Vertical openings
All opening placement and live meaning continues to come from existing
`OpeningCfg`, `OpeningWallIndex`, wall cuts and `openingAmount()`.
**Door**
- one thin vertical leaf is hinged exactly at the owning jamb;
- closed state lies in the wall opening; open amount follows the same 0–1 path
and opening side/hinge choices as the current floor symbol;
- no handle, hinge hardware or decorative panel is added.
**Gate**
- two equal vertical leaves meet at the opening centre when closed;
- live amount preserves the current 0–10° outward contract, including the
static-open default when no contact exists;
- gate lock semantics remain identical to a door.
**Window**
- a closed window is a light neutral insert in the wall opening;
- open state uses the existing two-leaf/casement direction and amount rather
than inventing a new sash type;
- the insert may have a local highlight but emits no floor beam and does not
replace the existing sun layer.
For every type:
- leaf/panel height and thickness are fixed presentation ratios to Stage 1 wall
height; no persisted setting is introduced;
- jamb attachment error is at most the existing geometric epsilon and the
projected visual gap at the hinge is at most 1 CSS px;
- panels do not fill the masonry cut or bridge the full-height opening with a
wall strip;
- panels and their shadows are `aria-hidden`/pointer-inert. Existing View
inertness, lock badge, card and secure action boundary remain the only
interaction contract;
- a contact-state update changes only the live leaf projection and cannot
rebuild wall union, floor footprint or opening-wall topology.
### 6.6 Display settings
- `hide_openings: true` hides vertical panels and leaf shadows, while the wall
cut, contact/lock references, Glow passage and sunlight behaviour remain as
today.
- `show_borders: false` preserves the Stage 1 no-volume contract: Stage 2 wall
material, vertical panels, floor edge and all new shadows are absent. The
existing floor-plane opening symbol remains available under its current
`hide_openings` rule so openings do not become invisible floating gaps.
- With borders visible, the vertical panel replaces rather than duplicates the
old floor-plane symbol in Iso.
- Flat and every editor keep their current symbols and settings behaviour.
### 6.7 Existing live layers and actions
The following invariants are exact, not best effort:
- the same resolved rooms, sources, opening state, markers and vacuum data are
consumed in Flat and Iso;
- room fills/hover, Glow/spill, sun, backdrop/decor/furniture, vacuum and
screen-facing overlays retain their existing node/state/action contract;
- Stage 2 creates no new HA call, websocket request, fetch, storage write or
network resource;
- device actions, opening cards and the lock invariant from `docs/SCOPE.md`
remain unchanged;
- decorative shadow may modulate final pixels but may not remove, replace or
reclassify any live effect;
- there is no second window-light, Glow or sun layer.
### 6.8 Accessibility, touch and graceful degradation
- View and kiosk remain fully supported on desktop and touch: pan, pinch,
swipe, double-tap reset, orientation resize, space change and long-press
kiosk settings are unchanged.
- New volume is non-interactive and cannot steal pointer, focus or assistive
technology navigation. Existing HTML devices, room cards and lock badges
stay screen-facing and above it.
- New geometry has no animation timeline. Live opening state may update, but
no decorative continuous motion is introduced; `prefers-reduced-motion`
therefore retains the Stage 1 transition contract.
- In forced-colours/high-contrast mode, structural faces and outlines remain
distinguishable without relying only on a gradient; decorative shadows and
material nuance may be absent.
- A detected unsupported filter/paint capability degrades to solid material
and no decorative shadows. Geometry, vertical openings, floor edge, live
layers and actions remain.
- Canonical supported browsers must render full Stage 2 and pass performance.
Fallback may not be forced in the benchmark merely to make the budget green.
- A structural projection/topology exception uses the existing latched Flat
fallback for `space|fingerprint`. Decorative-filter failure alone never
changes the selected presentation to Flat.
## 7. Rendering and architecture contract
Stage 1 projection and canonical wall decisions remain accepted. Stage 2 may
extend their scene result, but must not create a second coordinate system or
replace `wallBodiesGeometry()`.
Required boundaries:
1. One structural Stage 2 scene is keyed by the complete geometry fingerprint:
rooms, canonical masonry, openings and flips, wall/camera/edge height and an
algorithm revision. HA state, theme, hover, day/night and filter capability
are excluded.
2. Cached opening structure stores jamb/basis/topology only. Live
`openingAmount()` is applied after the structural cache, O(O), without a
boolean wall operation.
3. Floor-edge geometry derives from canonical room/exterior geometry, not from
SVG DOM measurement, a background image or duplicated room inference.
4. Structural complexity remains O(E + O). A constant number of shared SVG
gradients/filters is allowed; per-face filters and data URLs are forbidden.
5. Render order is explicit and testable:
```text
stage background
→ optional ambient shadow
→ exterior floor edge
→ existing projected floor SVG and all live floor layers
→ optional contact/leaf shadows
→ canonical wall sides/top and vertical opening panels
→ existing screen-facing HTML overlays
```
6. If implementation uses more than one absolutely positioned SVG, they share
the same `viewBox` snapshot and projection; no layer computes fit, pan or
zoom independently.
7. `projectedFrame()` or its Stage 2 successor includes wall tops, opening tops
and structural floor edge. Filter blur may overflow safely but must not
expand logical content bounds or change zoom when decoration degrades.
8. Flat fallback is independent of the Stage 2 cache and can render even when
Stage 2 construction throws.
9. Existing LRU cap remains eight structural scenes per card and does not grow
on HA updates, theme changes, view toggles or filter fallback.
10. The implementation records its composition/cache/fallback decisions in a
new `docs/adr/122-isometric-stage2-composition.md`; the historical Stage 1
ADR is not rewritten as if it had decided Stage 2.
Likely implementation files, subject to code review:
- `src/iso-projection.ts` for Stage 2 structural bounds only if required;
- `src/iso-walls.ts` or a new pure `src/iso-scene.ts` for cached walls and floor
edge;
- a pure `src/iso-openings.ts` (or equivalent) for live vertical-panel
projection;
- `src/houseplan-card.ts` for orchestration and exact layer order;
- `src/styles.ts` for theme/capability/forced-colour material;
- `src/labs.ts` only for traceability metadata, without changing id/lifetime;
- unit, smoke, golden/performance harness and internal docs named in §11–§13.
## 8. Model data, compatibility and migration
No persisted model changes.
- `space.openings[]`, room/wall geometry and display settings retain their
current schema;
- there are no door/window height, sill, material or Stage 2 fields;
- no config version, backend validation, import/export or compatibility-registry
entry changes;
- current `houseplan_card_labs_v1` and `houseplan_card_view_v1` values remain
readable and unchanged;
- merely rendering Stage 2 writes no config/layout and does not rewrite local
storage beyond the existing Stage 1 Labs/preference operations;
- downgrade to Stage 1 or Flat requires no migration and loses no user data.
The `iso` registry `since: 1.62.0` and exclusive `expires: 1.65.0` values are
unchanged. The metadata issue/summary may point to #122 for current diagnostics,
provided internal docs retain the #89 predecessor link.
## 9. UX and i18n
There is no new public control or text. The existing hidden projection button,
its 44×44 target, labels “Volumetric view” / “Flat view” and per-space preference
remain unchanged.
- no new i18n keys;
- no public settings/YAML option;
- no README, user-guide or HACS promise;
- no change to editor wording or opening settings;
- no new focusable node or interaction path.
`docs/USER-GUIDE.ru.md` remains deliberately silent about hidden Iso. The
implementation must nevertheless preserve its normative contracts for View,
kiosk, openings, Glow, sunlight and lock safety.
## 10. Dependency and Definition of Ready
#124 records a reproducible failure of the existing Stage 1
`large-house-isometric-v1` view-toggle budget. Stage 2 must not absorb that bug,
hide it with decorative fallback or weaken its budget.
This specification may be reviewed while #124 is open. After a green spec
review, #122 may carry `S5-ready`, but it also receives `blocked` and may not be
claimed into `S6-in-progress` until one of these is true:
1. #124 is resolved with a green exact-SHA Full Performance comparison using at
least seven samples and unchanged budgets; or
2. the owner records a separate explicit arbitration that #122 may start with
the known baseline debt.
Stage 2's own implementation must then pass the same unchanged profile on its
exact SHA. A coincidentally green single local sample is not a substitute.
## 11. Acceptance criteria
- **AC1 (`unit` + `smoke` + code review):** the same `iso` Labs id exposes Stage
2 while live; Flat remains default, expiry remains exclusive at `1.65.0`, and
there is no second flag, public setting or config key.
- **AC2 (`unit` + existing flat smoke + existing flat golden):** with Labs
inactive, Flat/editors/static card do not create Stage 2 geometry, filters or
DOM and retain their existing state/action/pixel contract.
- **AC3 (`unit` + `golden`):** light and dark Iso show deterministic matte wall
top/side/outline separation without raster textures, random output, inferred
room material or per-face filters.
- **AC4 (`unit` + `golden`):** every connected room footprint receives one low
outside edge; shared/internal room edges and nested holes create no steps,
detached components are not bridged, and detached partitions/columns do not
expand the slab.
- **AC5 (`unit` + `smoke` + `golden`):** door, window and gate vertical geometry
matches existing jamb, side, flip and `openingAmount()` semantics for
closed/open/no-contact/unavailable/inverted states; projected hinge gap is
≤1 CSS px and the masonry opening remains full-height.
- **AC6 (`smoke` + code review):** vertical panels are pointer/focus/ARIA inert;
the existing lock badge/card and safe action result are unchanged and no new
service or network call exists.
- **AC7 (`unit` + `smoke` + `golden`):** `hide_openings` hides panels/leaf shadows
but not cuts/state/light; `show_borders:false` preserves the Stage 1
no-volume scene and existing floor symbols without Stage 2 edge/shadows.
- **AC8 (`smoke` + `golden`):** room fills/hover, Glow/spill, sun,
backdrop/decor/furniture, vacuum, markers, labels/cards and opening locks keep
the same resolved state, source count, action outcome and layer order; no
second window-light/Glow/sun layer appears.
- **AC9 (`unit` + `smoke`):** HA-only state changes do not rebuild wall/floor
topology; an opening contact update changes only live leaf projection; theme,
hover and filter fallback do not grow the structural cache.
- **AC10 (`smoke` + `golden`):** desktop View, touch View and kiosk retain
pan/pinch/tap/long-press/swipe/orientation/space/warm-remount behaviour, use one
projection snapshot and never expose volumetric editors.
- **AC11 (`unit` + `smoke` + code review):** unsupported decoration and forced
colours retain solid readable structure with no decorative shadows; a
decorative failure stays Iso, while a structural failure uses the existing
latched Flat fallback and emits no personal data.
- **AC12 (`unit` + `smoke`):** fit/home and projection toggle include wall,
opening and floor-edge structural bounds without clipping, preserve scalar
zoom/logical floor centre and do not change when blur is disabled.
- **AC13 (`performance`):** after #124's precondition, exact-SHA Full Performance
for `large-house-isometric-v1` is green with ≥7 samples and unchanged budgets;
Flat remains inside noise, filter/definition count is bounded and
`isoGeometry` cache cap/growth remain 8/0.
- **AC14 (`unit` + smoke + code review):** plan/backend/import/export schema,
local-storage keys, HA actions, network traffic and i18n keys are unchanged;
render-only use does not write config or layout.
- **AC15 (`golden` + documentation review):** reviewed Stage 2 Iso baselines show
materials, openings, edge, shadows and live-layer parity; all Flat and
`isometric-no-borders-dark` baselines remain unchanged; internal docs describe
the hidden experiment without public rollout claims.
- **AC16 (`typecheck` + `unit` + `build`):** implementation-loop gates are green
and all three generated bundle copies are byte-identical.
## 12. Automated test plan
### 12.1 Unit
1. Extend pure Iso geometry coverage with room union/exterior floor-edge cases:
one room, shared rooms, nested room, two detached components, detached
partition/column and empty space.
2. Prove floor-edge geometry has no segment on an internal shared boundary and
remains stable under room order/winding changes.
3. Add pure vertical-opening fixtures for door/window/gate across amount 0/1,
no contact, unavailable, invert and both flips. Assert exact jamb anchors,
leaf count, wall-height bounds and stable output order.
4. Prove an HA amount change reuses the structural basis and cannot mutate or
grow the wall/floor cache.
5. Verify structural fingerprint includes opening geometry/flips, wall/edge
height and algorithm revision, but excludes HA state, theme, hover and
filter capability.
6. Verify layer/capability resolution: full decoration, unsupported filter,
forced colours, `hide_openings` and `show_borders:false`.
7. Source-contract tests assert no new config key, dependency, network call,
public control or second light layer.
Tests must be capable of failing independently: removing one exterior edge,
moving a leaf off its jamb, including HA state in the cache key, duplicating a
floor symbol or adding an extra light layer must make the relevant test red.
### 12.2 Targeted browser smoke
Extend or add a production-bundle scenario with:
1. light and dark Stage 2 activation under existing `iso`;
2. door/window/gate with live contact and lock references, including state
changes without topology rebuild;
3. visible borders, `hide_openings`, `show_borders:false` and return to visible
borders;
4. room fill + two Glow sources + sunlight + decor + vacuum + room/device
actions in the same frame;
5. Flat ↔ Iso, editor round-trip, space switch, resize/orientation and warm
remount;
6. touch View and kiosk gestures with pointer-inert Stage 2 layers;
7. forced/unsupported-decoration mode and structural-failure Flat fallback;
8. assertions for one light model, stable cache counts and no config/layout/
network write.
Per the implementation-loop policy, browser smoke is authored with the code but
run in the full pre-beta gate, not on every local iteration.
### 12.3 Golden
The existing matrix remains the base. Expected work:
- update reviewed Stage 2 baselines for geometry light/dark, live layers,
touch kiosk and large warm-remount;
- keep every Flat baseline and `isometric-no-borders-dark` pixel-identical;
- add a focused light-theme and dark-theme opening/material scenario containing
door, window, gate, floor edge and shadows;
- add a detached-footprint/internal-boundary scenario if existing geometry
fixtures cannot prove AC4 visually;
- inspect live-effect readability rather than accepting lower contrast merely
because the diff is expected.
Baselines are accepted only through `npm run golden:accept -- --reviewed` from a
complete reviewed Linux CI artifact with the required `Release:` and
`Baseline-Reviewed:` trailers. Stage 2's spec approval is not baseline approval.
### 12.4 Performance
Use the existing `large-house-isometric-v1` runner and unchanged budget file:
- at least seven samples on the exact candidate SHA;
- Flat baseline comparison and Iso candidate comparison;
- view toggle, first stable render, state update, space switch, pan/zoom,
long tasks, heap, cache caps and growth;
- a DOM metric or smoke assertion bounding shared material/filter definitions
and vertical opening count to O(1) definitions + O(O) geometry.
A local run is diagnostic only. #124's evidence and Stage 2's own exact-SHA
comparison are separate required results.
### 12.5 Backend
Backend is unchanged. No new backend test is required. Existing Linux Validate
still runs the full project gate before beta.
## 13. Documentation and release artefacts
Stage 2 remains hidden, so its implementation commits use
`User-Visible: no`. They do **not** add public changelog, README, HACS or
user-guide promises. Public documentation belongs to a separate rollout issue.
Update with the implementation:
- `docs/ISOMETRIC.md` — Stage 2 material/opening/edge/layer/fallback contract;
- `docs/ARCHITECTURE.md` — one cached structural scene and live-opening split;
- `docs/adr/122-isometric-stage2-composition.md` — accepted composition,
caching and degradation decisions;
- `docs/STATUS.md` — current hidden Labs stage and release-cycle state;
- `AGENTS.md` only if Labs authoring/lifetime rules themselves change (not
expected).
Visual release artefacts:
- reviewed golden set from §12.3;
- exact-SHA Validate and Full Performance evidence from §12.4;
- no public screenshot or announcement;
- no separate security report because there are no inputs, services or data
changes; code review explicitly confirms the negative security contract.
The feature/material change must pass through a published beta before stable,
even though it remains hidden.
## 14. Risks and mitigation
| Risk | Probability / impact | Mitigation |
|---|---|---|
| Vertical panel drifts from jamb or crosses masonry | medium / high | pure opening basis, ≤1 px smoke, focused golden |
| Floor edge follows room seams or bridges detached plans | medium / high | canonical exterior union, permutation units, detached golden |
| Shadows obscure Glow/sun or alter live meaning | medium / high | bounded shared filters, combined live-layer golden, no new light model |
| Per-face filters or live topology rebuild break performance | high / high | O(1) definitions, structural/live split, unchanged exact-SHA budget |
| #124 debt is hidden by Stage 2 fallback | high / high | explicit DoR blocker and benchmark full decoration |
| `show_borders:false` leaves floating panels | medium / medium | retain Stage 1 floor symbols in the no-volume branch |
| Touch/kiosk layer steals gestures | low / high | pointer-inert geometry and targeted touch/kiosk smoke |
| Dark/forced-colour material becomes unreadable | medium / medium | outline-based distinction and solid fallback |
| Filter exception unnecessarily drops to Flat | medium / medium | separate decorative capability from structural latch |
| New cache key includes HA state and grows every tick | medium / high | basis/live split and cache-growth unit/performance assertion |
| Internal feature leaks into public docs | low / medium | internal artefact list, no changelog/user-guide/README |
| Stage 2 misses Labs expiry | medium / medium | unchanged exclusive 1.65 expiry and separate graduation decision |
## 15. Rollback
Immediate tester rollback needs no build: `?hp-labs=-iso` or
`?hp-labs=off` makes Flat effective, and warm state cannot resurrect Iso.
Code rollback reverts the Stage 2 behaviour/artefact commit while retaining the
accepted Stage 1 renderer. There is no data or schema migration to reverse.
Existing Labs and view-preference storage remain valid. If only decorative
filters fail, the supported runtime fallback removes them without disabling
structural Stage 2; if Flat itself changes or fails, beta publication is blocked.
## 16. Technical assumptions — may change without product review
1. Recommended fixed ratios are: floor edge roughly 10–16% of wall height,
door/gate panels roughly 85–100% and window panels roughly 45–70% of wall
height. Exact constants are selected by reviewed golden output and do not
become user settings.
2. A new pure `iso-openings.ts`/`iso-scene.ts` split is preferred, but names and
file boundaries may change while the structural/live-cache contract holds.
3. Gradients and shadows should use shared SVG defs/CSS variables. Exact tokens,
blur radii and alpha may change in review within the restrained material
contract and performance budget.
4. `show_borders:false` uses the existing floor symbols rather than floating
vertical panels; this is the deterministic consequence of the accepted
no-volume setting, not a new public mode.
5. The ambient shadow may be rendered in a dedicated shared-projection SVG
beneath the existing floor SVG. The number of SVG roots is not a product
contract; the one-snapshot/layer-order invariant is.
6. The Labs registry diagnostic may change its issue metadata from #89 to #122,
but id, storage key, since and expiry do not change.
7. Capability detection may use CSS/forced-colour media queries or a cached
per-Document probe. It must not classify performance from viewport width or
coarse pointer alone.
8. #124 is a process/technical prerequisite, not Stage 2 product scope. Its fix
may change the renderer internals this spec builds upon without reopening
owner decisions Q0–Q6.
-397
View File
@@ -1,397 +0,0 @@
# Issue #131 — полный первый кадр View у read-only-пользователя
- **Issue:** https://github.com/Matysh/houseplan-card/issues/131
- **Редакция:** первая редакция для независимого ревью; статус задачи определяется
только метками issue
- **Тип / приоритет:** bug / P1
- **Оценка:** пользовательская ценность 9/10; ценность для разработки 7/10;
сложность и риск 4/10
- **Область:** холодная загрузка `houseplan-card`, выбор пространства,
read-only View/киоск, локальный config-cache, reload и warm remount
- **Модель данных:** без изменений и миграции
- **Связано:** #73, #93, `docs/SCOPE.md`, `docs/TOUCH-SUPPORT.md`,
`docs/UX-MODES.md`, `docs/USER-GUIDE.ru.md`, `docs/ARCHITECTURE.md`
## 1. Сценарий и продуктовый контекст
**Персона:** домочадец без права редактирования плана либо пользователь
настенной панели/киоска. Для него View — основной продуктовый режим, а не
предпросмотр перед редактором.
**Поверхность и момент:** пользователь впервые открывает Lovelace-страницу после
чистой установки, очистки локального состояния или на новом браузере. Сервер
разрешает прочитать House Plan, но HA-сессия не разрешает одну или несколько
подписок на служебные события интеграции.
Задача поддерживает:
- **J1:** план должен быть читаемым и полным сразу при открытии;
- **J6:** reload, reconnect и техническое перемонтирование не должны менять
смысл или состав видимого плана;
- гарантированный View/touch/kiosk-контракт: телефон, планшет и настенная панель
являются целевыми устройствами просмотра и управления.
## 2. Что человек увидит до и после
**До:** при первом открытии видна только часть плана, ни одно пространство не
выделено, а нажатие его вкладки неожиданно «дорисовывает» пол, мебель, стены,
свет и подписи состояний.
**После:** первый кадр уже совпадает с нормальным видом после нажатия вкладки;
пространство выбрано, все его слои и состояния на месте, а нажатие активной
вкладки ничего не исправляет.
## 3. Проблема и подтверждённая причина
`setConfig()` начинает без server-cache с legacy-значения `_space = 'f1'`.
После ответов `houseplan/config/get` и `houseplan/layout/get` метод
`_loadFromServer()` принимает конфигурацию через `_adoptStructuralResponses()`,
но до выбора реального пространства последовательно ожидает три live-sync
подписки.
Отказ первой `subscribeEvents()` попадает во внешний `catch` и пропускает весь
оставшийся хвост успешной инициализации:
1. hash/saved/default/first precedence не применяется;
2. `_cacheSnapshot()` не вызывается;
3. zoom/viewport не восстанавливается по принятому пространству;
4. ошибка ошибочно считается сбоем всей загрузки и запускает полный retry.
При этом `_loadOk` уже установлен и server config уже принят. `_spaceModel()`
скрывает часть ошибки, подставляя первый model space вместо несуществующего
`f1`, поэтому устройства и часть геометрии видны. Но `_curSpaceCfg` ищет точное
совпадение и остаётся `undefined`; header также сравнивает точные ID. Из-за этого
неактивна вкладка и отсутствуют зависящие от raw-space слои.
Диагностика на `dev` SHA `0e69c4a18337` с двумя пространствами `home` и
`upstairs`:
| Сценарий | `_space` | model fallback | exact space | active tab |
|---|---|---|---|---|
| Cold start, подписка запрещена | `f1` | `home` | — | — |
| После клика `home` | `home` | `home` | `home` | `home` |
| Технический remount без config-cache | `f1` | `home` | — | — |
| Новый экземпляр с валидным `LS_NAV`, без config-cache | `f1` | `home` | — | — |
| Reload с валидным config-cache | `home` | `home` | `home` | `home` |
`can_write: false` без отказа подписок загружается правильно. Значит, условие
дефекта — не запрет редактирования сам по себе, а ошибка необязательной подписки
в обязательной последовательности cold start.
## 4. Решения владельца
Владелец принял defaults D1–D3 14.08.2026. Каноническая запись:
https://github.com/Matysh/houseplan-card/issues/131#issuecomment-5287280361
1. Задача имеет приоритет P1 и идёт полным маршрутом без `small`/`trivial`.
2. После принятия серверной конфигурации карточка выбирает ровно одно
существующее пространство по действующему приоритету до пространственного
рендера.
3. Полнота initial snapshot не зависит от права записи, результата
необязательных live-sync-подписок, числа пространств, наличия cache, reload
или технического remount. Отказ подписки может отключить только последующую
live-синхронизацию.
## 5. Скоуп
В задачу входят:
1. обязательное завершение server snapshot после успешных `config/get` и
`layout/get`, даже если любая houseplan event subscription отклонена;
2. выбор валидного пространства до первого пространственного кадра;
3. cold start без `LS_CFG`, включая валидный и stale `LS_NAV`;
4. одно и несколько пространств, валидные и stale `#space`/`default_floor`;
5. сохранение принятого server snapshot в `LS_CFG` после успешной загрузки;
6. reload и same-route warm remount;
7. View и киоск при `can_write: false`; обычная admin-сессия остаётся той же;
8. независимая best-effort установка config, trail и layout subscriptions;
9. отсутствие полного load-retry только из-за отказа необязательной подписки;
10. unit/browser regression coverage, архитектурная документация и два
changelog.
## 6. Не входит в задачу
- выдача read-only-пользователю права редактировать план или подписываться на
запрещённые HA events;
- изменение backend permissions, websocket API или Home Assistant auth;
- гарантированная live-синхронизация после явно запрещённой подписки;
- новый warning, toast, recovery overlay или индикатор ограниченных прав;
- изменение порядка вкладок, названий пространств, `default_floor`, deep link
или формата локального cache/navigation;
- изменение выбора пространства в `houseplan-space-card`, где пространство
задаётся отдельным обязательным параметром;
- изменение рендера пола, мебели, стен, Glow, устройств либо их состояний после
того, как raw space уже выбран правильно;
- изменение reconnect/asset-failure контракта #73 для обязательных данных;
- schema/config migration, import/export и новые compatibility-поля;
- отдельная оптимизация производительности или переработка всего boot lifecycle.
## 7. Контракт поведения
### 7.1. Инвариант выбранного пространства
Если принятая серверная конфигурация содержит хотя бы одно пространство, перед
публикацией spatial candidate одновременно выполняются условия:
- `_space` равен ID существующего пространства;
- model space и exact raw space описывают один и тот же ID;
- ровно одна вкладка full card имеет active-состояние, кроме киоска, где header
намеренно не рендерится;
- все raw-space consumers получают один и тот же объект пространства;
- несуществующее legacy/stale значение не остаётся скрытым за model fallback.
Если пространств нет, карточка сохраняет существующий empty/onboarding-контракт:
она не обязана выдумывать ID и не падает.
### 7.2. Приоритет выбора
Для cold/reload без уже принятого валидного same-route warm viewport применяется
существующий порядок, причём каждый кандидат обязан присутствовать в live model:
1. валидный `#space=<id>`;
2. валидное сохранённое пространство `LS_NAV`;
3. валидный `default_floor` карточки;
4. первое пространство live config.
Невалидный кандидат пропускается, а не сохраняется как `_space`. Explicit hash
по-прежнему выигрывает. Уже принятый валидный same-route warm viewport остаётся
существующим continuity-исключением #73/#93 и не сбрасывается менее точным
saved/default значением; при отсутствии его пространства в live config порядок
выше применяется заново.
### 7.3. Обязательная и необязательная части загрузки
Успешный initial snapshot состоит из обязательных шагов:
1. получить config и layout;
2. при структурном изменении подготовить обязательный backdrop;
3. принять config/layout, revisions и `can_write`;
4. выбрать валидное пространство;
5. записать локальный snapshot;
6. восстановить применимый viewport/zoom;
7. опубликовать полный candidate frame и построить устройства.
Config/trail/layout event subscriptions являются best-effort live-sync. Ни одна
из них не может отменить или задержать перечисленные гарантии после принятия
server data.
### 7.4. Деградация live-sync
- Каждая подписка устанавливается независимо; отказ одной не запрещает попытки
установить остальные.
- Успешная подписка сохраняет текущий idempotent one-subscription-per-card
контракт и штатно очищается при disconnect.
- Неуспешная подписка остаётся доступной для повторной попытки при следующем
обычном load/reconnect, но не запускает tight loop и не перечитывает весь
snapshot только ради подписки.
- Отказ подписки не показывает новый toast и не подменяет принятый config
fallback-данными.
- Ошибка обязательных config/layout calls, обязательного asset или последующей
config reload сохраняет нынешний stale-while-revalidate/recovery-контракт и
не маскируется как успех.
### 7.5. Reload, cache и warm remount
- После успешного server snapshot `LS_CFG` записывается независимо от результата
подписок.
- Сохранённый `LS_NAV` применяется после появления live model, даже если до
server response не было config-cache, в котором можно было проверить ID.
- Технический same-route remount сохраняет валидное пространство по текущему
warm/navigation contract; отсутствие cache не возвращает `_space` к `f1`.
- Валидный старый cache может дать мгновенный полный кадр, но не является
условием корректности.
- После исправления клик уже активной вкладки является no-op и не меняет состав
сцены.
## 8. Архитектурный контракт реализации
Конкретные helper names являются техническим выбором автора, но обязательны
следующие границы:
1. Нормализация пространства является частью принятия structural snapshot и
выполняется до первого `await`, который относится только к live-sync.
2. Один resolver владеет проверкой существования и cold precedence; setConfig,
live load и warm path не должны получать расходящиеся копии правил.
3. Model fallback не считается доказательством валидного exact space. После
принятия непустого config invariant проверяется по точному ID.
4. Подписки изолируют rejection по отдельности; rejected Promise не достигает
outer catch обязательной загрузки и не создаёт unhandled rejection.
5. Ошибка optional subscription не устанавливает recovery/error state полного
кадра и не запускает `_scheduleLoadRetry(true)` сама по себе.
6. Существующие `_unsubCfg`, `_unsubTrail`, `_unsubLayout` остаются authority
идемпотентности: успешную подписку нельзя дублировать.
7. Cache записывает только уже принятые server config/layout и revisions;
optional subscription state в persistent data не добавляется.
8. Device rebuild и continuity candidate читают уже нормализованное пространство;
отдельной исправленной ветки рендера для read-only быть не должно.
Предполагаемые файлы реализации:
- `src/houseplan-card.ts`;
- при полезном выделении pure resolver — небольшой frontend module;
- соответствующий `test/*.test.mjs`;
- `demo/smoke_readonly_cold_start.mjs` либо эквивалентный browser scenario;
- `docs/ARCHITECTURE.md`, `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`.
## 9. Модель данных, compatibility и миграция
Форматы `CardConfig`, server config, layout, `LS_CFG`, `LS_NAV` и warm memo не
меняются. Новых полей, schema version, backend validation и compatibility aliases
нет.
Сохранённые планы не переписываются. Исправление вычисляется на каждом старте;
конфигурации прежней версии остаются читаемыми новой и старой версиями. Прямая
и обратная миграция не нужны.
## 10. UX, i18n, accessibility и touch
Новых controls, текстов, фокуса, клавиатурных команд или motion нет. Новые
i18n-ключи en/ru не требуются.
Активная вкладка сохраняет существующую семантику кнопки и focus order.
Исправление не должно программно переводить фокус и не должно объявлять
служебную ошибку подписки через live region.
View на touch блокирующий: первый кадр телефона/планшета обязан быть полным без
тапа по вкладке. В киоске требование ещё строже, потому что header скрыт и обход
недоступен. Touch editor остаётся вне скоупа; право редактирования не меняется.
## 11. Критерии приёмки
- **AC1 (`unit`):** resolver cold selection покрывает матрицу valid/stale
hash, `LS_NAV`, `default_floor`, legacy current ID, одного/нескольких/нулевого
числа пространств и всегда возвращает существующий ID при непустом model.
- **AC2 (`unit` + `ревью кода`):** после принятия config/layout exact space
нормализован и cache/viewport finalization выполнены до optional subscription;
rejection любой подписки не может пропустить эти шаги.
- **AC3 (`smoke`):** read-only cold start без `LS_CFG`, с пространствами
`home/upstairs`, `can_write: false` и rejected houseplan subscriptions сразу
имеет `_space = home`, exact raw space `home`, одну active-вкладку и полный
набор floor/decor/wall/Glow/device-state слоёв без клика и page error.
- **AC4 (`smoke`):** fixed HA snapshot до клика активной вкладки и после её
no-op клика имеет одинаковые проверяемые spatial layers; сцена не
«дорисовывается».
- **AC5 (`unit` + `smoke`):** valid/stale saved/default/hash precedence на
нескольких пространствах выбирает ожидаемый live ID; stale `f1` никогда не
остаётся exact selection, если такого пространства нет.
- **AC6 (`smoke`):** после cold start с rejected subscriptions `LS_CFG`
существует; reload и same-route warm remount без предварительного admin-cache
сохраняют полный кадр и валидное пространство.
- **AC7 (`unit` + `ревью кода`):** rejection config, trail или layout
subscription изолирован: остальные подписки всё равно предпринимаются,
успешные не дублируются, unhandled rejection и full-load retry storm нет.
- **AC8 (`smoke`):** тот же cold сценарий с `kiosk: true` рендерит полный план
при скрытом header; никакого пользовательского действия для восстановления
не требуется.
- **AC9 (`unit` + `ревью кода`):** обязательный `config/get`, `layout/get` или
asset failure продолжает действующий stale-while-revalidate/recovery путь;
optional и mandatory errors не смешиваются.
- **AC10 (`ревью кода`):** исправление не выдаёт право записи, не вызывает
write/service API, не показывает редакторы при `can_write: false` и не меняет
backend/security boundary.
- **AC11 (`unit` + `ревью кода`):** на успешную карточку остаётся не более одной
подписки каждого типа; нормализация не добавляется в HA state render hot path.
- **AC12 (`build` + `ревью кода`):** оба changelog и архитектурный документ
описывают исправление, пользовательское руководство остаётся правдивым без
нового контракта, а три bundle snapshot после build побайтно совпадают.
## 12. План автотестов и проверок
### Unit
1. Выделить или напрямую покрыть pure resolution matrix из AC1/AC5.
2. Покрыть mandatory/optional orchestration контролируемыми fulfilled/rejected
Promises: порядок finalization, независимость трёх подписок, idempotency и
отсутствие retry от optional failure.
3. Сохранить регрессию обязательного fetch/asset failure из AC9.
### Browser smoke
Новый узкий сценарий создаёт отдельную full card с пустым House Plan
localStorage, non-admin hass, `can_write: false`, двумя пространствами с ID, не
равными `f1`, и управляемыми subscription outcomes. Он проверяет AC3/AC4/AC6/AC8
по внутреннему exact state и реальному shadow DOM, а не только по `_model`
fallback.
По действующему решению владельца в цикле реализации запускаются только
`typecheck`, unit и build. Smoke добавляется вместе с кодом, а его штатный
прогон входит в pre-beta browser-smoke gate; независимый код-ревьюер вправе
выполнить целевой сценарий для проверки AC.
### Golden и ручные изображения
Нового намеренного визуала нет: правильный результат уже совпадает с состоянием
после клика и существующими View/golden. Новые baseline и их принятие не нужны.
Скриншоты до/после уже приложены к issue и используются как диагностическая
ссылка, не как новый эталон.
## 13. Производительность и security
Изменение выполняется один раз на structural load, а не на HA state tick.
Дополнительного boolean geometry, DOM-слоя, polling или per-frame resolver нет.
Количество успешных подписок не растёт. Performance benchmark и новый budget
не нужны; штатный performance gate остаётся pre-beta проверкой.
Security boundary не ослабляется: read-only-пользователь только использует уже
разрешённые read calls. Запрещённая подписка не эмулируется, backend permission
не обходится, write API не вызывается. Отдельный security artifact не нужен.
## 14. Риски
1. **Регрессия precedence:** повторная нормализация может затереть valid hash или
same-route warm viewport. Закрывается AC1/AC5/AC6.
2. **Дубликаты подписок:** независимый retry может создать два listener после
позднего успеха. Закрывается authority через `_unsub*` и AC7/AC11.
3. **Ложный успех mandatory load:** слишком широкий `catch` может скрыть отказ
config/layout/asset. Закрывается явной границей phases и AC9.
4. **Partial live-sync:** read-only-сессия может не получать последующие внешние
изменения. Это допустимая деградация запрещённой подписки, но initial snapshot
обязан оставаться полным.
5. **Cache leakage между browser tests:** сценарий должен изолировать и очищать
House Plan keys, иначе валидный admin-cache маскирует регрессию.
## 15. Release-артефакты
Поскольку исправление пользовательски видимо, реализационный коммит содержит:
- `docs/CHANGELOG.md` — EN bug-fix bulletin со ссылкой на #131;
- `docs/CHANGELOG.ru.md` — эквивалентный RU bulletin со ссылкой на #131;
- `docs/ARCHITECTURE.md` — граница mandatory initial snapshot и best-effort
live-sync subscriptions, плюс invariant exact space;
- user guide — **без изменения**: он уже обещает правильный first space,
read-only View и полноценный kiosk/touch; задача приводит код к этому тексту;
- новый unit и browser regression scenario;
- build-синхронизация `dist/houseplan-card.js`,
`custom_components/houseplan/frontend/houseplan-card.js` и
`demo/srv/assets/houseplan-card.js`.
Golden baseline, screenshots, migration, backend, performance и security
артефакты не создаются по причинам из разделов 9, 12 и 13. Перед бетой идут
штатные golden verify, полный browser smoke и performance gate по release runbook.
## 16. Откат
Откат — обычный revert frontend-изменения и синхронных документации/changelog/
тестов/bundle snapshot. Данные и localStorage форматы не меняются, поэтому
чистить cache или восстанавливать конфигурацию не требуется.
После отката у затронутой read-only-сессии вернётся прежний неполный cold frame;
пользовательским временным обходом остаётся выбор пространства там, где header
доступен. Feature flag и обратная миграция не нужны.
## 17. Принятые технические предположения — можно менять без пересмотра продукта
1. Предпочтительно выделить один pure resolver cold precedence, но точное имя и
файл не являются продуктовым решением.
2. Подписки можно устанавливать после mandatory finalization последовательно
с локальными `try/catch` или общей best-effort orchestration; наблюдаемый
контракт и AC важнее формы.
3. Повторная попытка rejected subscription использует следующий штатный
load/reconnect, без отдельного polling timer.
4. Отказ optional subscription остаётся тихим: новый UX ограниченных live
updates потребовал бы отдельного продуктового решения.
5. Browser smoke может расширить существующий WS/warm lifecycle scenario вместо
нового файла, если сохраняет изоляцию cache и все проверки AC.
6. Valid same-route warm viewport трактуется как уже принятое пространство, а
не как новый пятый cold persistence source; explicit valid hash сохраняет
действующий приоритет.
+1 -4
View File
@@ -1,6 +1,6 @@
# Спецификации задач P1 и P2
Актуально на 2026-08-14.
Актуально на 2026-08-12.
GitHub Issues и GitHub Projects (v2) остаются единственным каноническим backlog проекта. Этот каталог содержит развёрнутые ТЗ: каждое ТЗ ссылается на issue, а issue — на соответствующий файл. Статус, приоритет и факт завершения меняются только в GitHub.
@@ -45,7 +45,6 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#89](https://github.com/Matysh/houseplan-card/issues/89) Опциональный объёмный 2.5D/изометрический вид | [089-isometric-view.md](089-isometric-view.md) |
| [#89](https://github.com/Matysh/houseplan-card/issues/89) Этап 1: объёмный вид за флагом Labs | [089-isometric-view-stage1.md](089-isometric-view-stage1.md) |
| [#98](https://github.com/Matysh/houseplan-card/issues/98) Единая система пульсаций и активностей устройства | [098-device-pulse-system.md](098-device-pulse-system.md) |
| [#131](https://github.com/Matysh/houseplan-card/issues/131) Полный первый кадр View у read-only-пользователя | [131-readonly-cold-start.md](131-readonly-cold-start.md) |
## P2
@@ -78,8 +77,6 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#90](https://github.com/Matysh/houseplan-card/issues/90) Управляемый бейдж со значением | [090-device-value-badge.md](090-device-value-badge.md) |
| [#94](https://github.com/Matysh/houseplan-card/issues/94) Универсальное действие «Переключить состояние» | [094-universal-state-toggle.md](094-universal-state-toggle.md) |
| [#101](https://github.com/Matysh/houseplan-card/issues/101) Плавный переход View ↔ редакторы | [101-view-editor-transition.md](101-view-editor-transition.md) |
| [#107](https://github.com/Matysh/houseplan-card/issues/107) Переключение виртуального источника света «Всегда» | [107-virtual-light-toggle.md](107-virtual-light-toggle.md) |
| [#122](https://github.com/Matysh/houseplan-card/issues/122) Изометрический режим Stage 2: скрытый режим и визуальная полировка | [122-isometric-stage2.md](122-isometric-stage2.md) |
| [#123](https://github.com/Matysh/houseplan-card/issues/123) Split из вершины не меняет наружную геометрию стен | [123-corner-split-wall.md](123-corner-split-wall.md) |
## Правило актуализации
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.63.0",
"version": "1.63.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.63.0",
"version": "1.63.0-beta.1",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.63.0",
"version": "1.63.0-beta.1",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
-261
View File
@@ -1,261 +0,0 @@
#!/usr/bin/env node
// Реестр известных поломок (mutation gate), issue #85.
//
// node scripts/mutation-gate.mjs --list мутанты и кого они проверяют
// node scripts/mutation-gate.mjs --check патчи применимы к текущему коду
// node scripts/mutation-gate.mjs полный прогон: все мутанты
// node scripts/mutation-gate.mjs --id=<mutant> один мутант
// node scripts/mutation-gate.mjs --build-only применить и собрать, тест не гонять
//
// Код выхода: 0 — каждый тест поймал свою поломку, 1 — хотя бы один не поймал,
// 2 — не смог проверить (патч не лёг, сборка упала).
//
// Зачем. Зелёный тест в этом проекте несколько раз означал «ничего не
// проверено», и выяснялось это после того, как баг доезжал до владельца:
// смок непрерывности не заметил удаления механизма, который защищает; golden,
// заведённый под #71, был пуст — 1 177 тёплых пикселей против 107 119, и все
// 1 177 были иконками. Общее у всех случаев: тест ни разу не проверяли на
// способность падать. Этот гейт делает такую проверку регулярной.
//
// Каждый мутант — маленький патч продуктового исходника, воспроизводящий
// известную поломку, и имя теста, который ОБЯЗАН на ней покраснеть. Прогон:
// worktree → патч → сборка бандла → бандл в demo/srv/assets → тест. Тест,
// оставшийся зелёным, — это провал гейта, а не успех теста.
//
// Прогон дорогой (пересборка бандла на мутанта), поэтому его место — перед
// стабильным релизом (.github/workflows/mutation-gate.yml), не на каждой бете.
// Дешёвая часть — «патчи применимы, guard-файлы существуют» — живёт в
// test/mutation-gate.test.mjs и идёт с обычными юнитами: реестр, отставший от
// кода, хуже отсутствующего, потому что выглядит защитой.
import { spawnSync } from 'node:child_process';
import {
cpSync, existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
// --- реестр ---------------------------------------------------------------
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
export const MUTANTS = [
{
id: 'continuity-long-resume-noop',
guard: 'node demo/smoke_visual_continuity.mjs',
because: 'смок обязан доказывать, что long-resume СРАБОТАЛ (токен ушёл вперёд), '
+ 'а не что ничего не спрятали: в beta.7 он не заметил удаления всего механизма',
patches: [{
file: 'src/visual-continuity.ts',
find: "if (!signal.long && (signal.kind === 'visible' || signal.kind === 'pageshow')) {",
replace: "if ((signal.kind === 'visible' || signal.kind === 'pageshow')) {",
}],
},
{
id: 'opening-cut-degenerate',
guard: 'node demo/smoke_glow.mjs',
because: 'проём, выродившийся в точку, остаётся кладкой — свет перестаёт '
+ 'проходить через дверь; смок обязан это увидеть по освещённому полу за проёмом',
patches: [{
file: 'src/houseplan-card.ts',
find: 'cuts.push([o.rx - dx, o.ry - dy, o.rx + dx, o.ry + dy]);',
replace: 'cuts.push([o.rx, o.ry, o.rx, o.ry]);',
}],
},
{
id: 'column-shadow-removed',
guard: 'node demo/smoke_glow.mjs',
because: 'физические тела выпали из окклюдеров — колонна перестаёт отбрасывать '
+ 'тень; исторически смок теней был зелёным, пока тени физически не рисовались',
patches: [{
file: 'src/houseplan-card.ts',
find: 'for (const body of physical) occluders.push(...polygonSegments(body));',
replace: 'void physical;',
}],
},
{
id: 'feather-20px',
guard: 'node demo/smoke_glow.mjs',
because: 'растушёвка 20 px вместо 2 размывает границу света на полкомнаты; '
+ 'смок читает data-feather-px и обязан отвергнуть значение больше 3',
patches: [{
file: 'src/houseplan-card.ts',
find: 'const GLOW_EDGE_FEATHER_PX = 2;',
replace: 'const GLOW_EDGE_FEATHER_PX = 20;',
}],
},
{
id: 'barrier-cache-never-invalidated',
guard: 'node demo/smoke_openwall.mjs',
because: 'кэш барьеров, который не инвалидируется по содержимому, — это свет '
+ 'сквозь стену, которая уже существует; смок переключает виртуальную границу '
+ 'и обязан увидеть смену освещённости соседней комнаты',
patches: [{
file: 'src/houseplan-card.ts',
find: 'if (this._lightBarrierCache?.key === cacheKey) return this._lightBarrierCache.value;',
replace: 'if (this._lightBarrierCache) return this._lightBarrierCache.value;',
}],
},
{
id: 'golden-lamp-out-of-reach',
guard: 'node demo/golden/run.mjs --mode=verify --scenario=lighting-opaque-glow-two-doorways-dark',
because: 'сцена заведена как защита дверного света (#71) и однажды уже была '
+ 'пустой: лампа стояла так, что пятно не доходило до стены. Уведённая лампа '
+ 'обязана ронять семантический ассерт сцены, а не только пиксельный дифф',
// Мутируется фикстура сцены, не продуктовый код: пустота сцены — свойство
// фикстуры. Пересборка бандла всё равно нужна, путь тот же.
patches: [{
file: 'demo/golden/matrix.mjs',
find: "layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.40, y: 0.48 } },",
replace: "layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.06, y: 0.90 } },",
}],
},
];
// --- механика ---------------------------------------------------------------
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
export function applyPatches(root, patches) {
for (const patch of patches) {
const path = join(root, patch.file);
const source = readFileSync(path, 'utf8');
const hits = source.split(patch.find).length - 1;
if (hits !== 1) {
throw new Error(`${patch.file}: якорь найден ${hits} раз(а), нужен ровно 1 — реестр отстал от кода`);
}
writeFileSync(path, source.replace(patch.find, patch.replace));
}
}
function sh(cmd, cwd, extraEnv = {}) {
return spawnSync(cmd, {
cwd, shell: true, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
env: { ...process.env, ...extraEnv },
});
}
function makeWorktree() {
const dir = mkdtempSync(join(tmpdir(), 'hp-mutant-'));
const added = spawnSync('git', ['-C', repoRoot, 'worktree', 'add', '--detach', dir, 'HEAD'],
{ encoding: 'utf8' });
if (added.status !== 0) throw new Error(`git worktree add: ${added.stderr}`);
// node_modules не копируется — символическая ссылка на настоящий. Установка
// зависимостей на каждого мутанта превратила бы вечерний гейт в суточный.
symlinkSync(join(repoRoot, 'node_modules'), join(dir, 'node_modules'), 'junction');
return dir;
}
function dropWorktree(dir) {
spawnSync('git', ['-C', repoRoot, 'worktree', 'remove', '--force', dir], { encoding: 'utf8' });
rmSync(dir, { recursive: true, force: true });
}
function buildBundle(dir) {
// Только rollup, без tsc --noEmit: мутант имеет право быть нестрогим по
// типам — он воспроизводит поломку, а не образцовый код.
const built = sh('npx rollup -c', dir);
if (built.status !== 0) {
throw new Error(`сборка мутанта упала:\n${(built.stderr || built.stdout).slice(-2000)}`);
}
cpSync(join(dir, 'dist', 'houseplan-card.js'), join(dir, 'demo', 'srv', 'assets', 'houseplan-card.js'));
}
function runMutant(mutant) {
const dir = makeWorktree();
try {
applyPatches(dir, mutant.patches);
buildBundle(dir);
const guard = sh(mutant.guard, dir);
if (guard.status === 0) {
console.log(`FAIL ${mutant.id}: тест остался зелёным на сломанном коде`);
console.log(` guard: ${mutant.guard}`);
console.log(` ${mutant.because}`);
return false;
}
console.log(`ok ${mutant.id}: тест покраснел, как обязан`);
return true;
} finally {
dropWorktree(dir);
}
}
// Чистый прогон каждого guard ровно один раз: тест, красный и без мутанта,
// «ловит» поломку тривиально и не доказывает ничего.
function runCleanGuards(mutants) {
const guards = [...new Set(mutants.map((m) => m.guard))];
const dir = makeWorktree();
try {
buildBundle(dir);
for (const guard of guards) {
const result = sh(guard, dir);
if (result.status !== 0) {
console.log(`FAIL чистый прогон: ${guard} красный без мутанта`);
console.log((result.stderr || result.stdout).slice(-1500));
return false;
}
console.log(`ok чистый прогон: ${guard}`);
}
return true;
} finally {
dropWorktree(dir);
}
}
function main(argv) {
const idArg = argv.find((a) => a.startsWith('--id='))?.slice(5);
const selected = idArg ? MUTANTS.filter((m) => m.id === idArg) : MUTANTS;
if (idArg && !selected.length) {
console.error(`мутант «${idArg}» не объявлен; --list покажет реестр`);
return 2;
}
if (argv.includes('--list')) {
for (const m of MUTANTS) console.log(`${m.id}\n guard: ${m.guard}\n ${m.because}\n`);
return 0;
}
if (argv.includes('--check')) {
let stale = 0;
for (const m of selected) {
try {
for (const patch of m.patches) {
const source = readFileSync(join(repoRoot, patch.file), 'utf8');
const hits = source.split(patch.find).length - 1;
if (hits !== 1) throw new Error(`якорь найден ${hits} раз(а)`);
}
console.log(`ok ${m.id}`);
} catch (error) {
console.log(`FAIL ${m.id}: ${error.message}`);
stale++;
}
}
return stale ? 2 : 0;
}
if (argv.includes('--build-only')) {
for (const m of selected) {
const dir = makeWorktree();
try {
applyPatches(dir, m.patches);
buildBundle(dir);
console.log(`ok ${m.id}: патч лёг, бандл собрался`);
} catch (error) {
console.log(`FAIL ${m.id}: ${error.message}`);
return 2;
} finally {
dropWorktree(dir);
}
}
return 0;
}
if (!runCleanGuards(selected)) return 2;
let caught = 0;
for (const m of selected) if (runMutant(m)) caught++;
console.log(`\nпоймано ${caught} из ${selected.length}`);
return caught === selected.length ? 0 : 1;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
process.exit(main(process.argv.slice(2)));
}
+4 -43
View File
@@ -67,12 +67,6 @@ const CLASS_C = [
const CHANGELOGS = ['docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md'];
// Метки, при которых файла ТЗ в docs/specs/ быть не должно: на лёгком треке ТЗ
// живёт в теле issue (§5), на коротком — там же, и ревью ТЗ вообще не проводится
// (§5.1, issue #128). Офлайн эти случаи неотличимы от «ТЗ не написано», поэтому
// проверка 3 краснеет только когда метки прочитаны.
export const NO_SPEC_FILE = ['small', 'trivial'];
export const ALLOWED_STATUS = ['S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'];
export const STRICT_STATUS = ['S5-ready', 'S6-in-progress', 'S7-code-review'];
@@ -241,12 +235,12 @@ export function checkSpecs(commits, specFiles, labelsOf = null) {
if (labels === null) {
out.push({
level: 'warn', rule: 3, sha: c.short,
msg: `класс A по ${t}, но ТЗ docs/specs/${nn}-*.md не найдено — допустимо при метке small или trivial`,
msg: `класс A по ${t}, но ТЗ docs/specs/${nn}-*.md не найдено — допустимо только при метке small`,
});
} else if (!labels.some((l) => NO_SPEC_FILE.includes(l))) {
} else if (!labels.includes('small')) {
out.push({
level: 'fail', rule: 3, sha: c.short,
msg: `класс A по ${t}: ТЗ docs/specs/${nn}-*.md нет, и метки ${NO_SPEC_FILE.join(' / ')} на issue нет — код без ТЗ`,
msg: `класс A по ${t}: ТЗ docs/specs/${nn}-*.md нет, и метки small на issue нет — код без ТЗ`,
});
}
}
@@ -289,22 +283,6 @@ export function commitsUnderRuleOne(commits) {
);
}
export function isStableTarget(targetRef) {
return /^(?:refs\/heads\/)?main$/.test(targetRef ?? '');
}
// При stable promotion диапазон main..candidate закономерно содержит коммиты,
// уже выпущенные prerelease-тегом. Их issue к этому моменту обязаны быть закрыты
// (§2.8), поэтому повторная online-проверка статуса дала бы ложный отказ. Новые
// post-beta коммиты остаются в выборке и проверяются fail-closed как обычно.
export function commitsNeedingIssueStatus(
commits, { targetRef = '', isPublishedPrereleaseCommit = () => false } = {},
) {
const underRuleOne = commitsUnderRuleOne(commits);
if (!isStableTarget(targetRef)) return underRuleOne;
return underRuleOne.filter((commit) => !isPublishedPrereleaseCommit(commit.sha));
}
// 8. статус issue. Fail closed: недоступный или закрытый issue — отказ, а не
// пропуск. Гейт, который молчит при недоступном источнике правды, бесполезен.
export function checkIssueStatuses(numbers, runner, { allowed = ALLOWED_STATUS } = {}) {
@@ -398,7 +376,6 @@ function main(argv) {
const repo = value('repo', process.cwd());
const allowed = flag('no-merged') ? STRICT_STATUS : ALLOWED_STATUS;
const targetRef = value('target-ref', process.env.TARGET_REF ?? '');
let range = value('range');
if (!range && flag('github-range')) {
@@ -445,23 +422,7 @@ function main(argv) {
// проверки 3. Второй запрос по тому же issue — лишний сетевой вызов.
let labelsOf = null;
if (flag('issues')) {
const prereleaseTags = isStableTarget(targetRef)
? git(['tag', '--list'], repo).split('\n').map((s) => s.trim()).filter((tag) =>
/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-[0-9A-Za-z.-]+$/.test(tag))
: [];
const publishedCache = new Map();
const isPublishedPrereleaseCommit = (sha) => {
if (!publishedCache.has(sha)) {
publishedCache.set(sha, prereleaseTags.some((tag) =>
spawnSync('git', ['-C', repo, 'merge-base', '--is-ancestor', sha, `${tag}^{commit}`],
{ encoding: 'utf8' }).status === 0));
}
return publishedCache.get(sha);
};
const statusCommits = commitsNeedingIssueStatus(commits, {
targetRef, isPublishedPrereleaseCommit,
});
const numbers = [...new Set(statusCommits.flatMap((c) => c.issues).map((t) => t.slice(1)))];
const numbers = [...new Set(commitsUnderRuleOne(commits).flatMap((c) => c.issues).map((t) => t.slice(1)))];
const runner = ghRunner(process.env.HP_REPO ?? 'Matysh/houseplan-card', process.env.GH_BIN ?? 'gh');
const cache = new Map();
const cached = (nn) => {
+7 -56
View File
@@ -9,12 +9,6 @@
* so embedded cards paint immediately, then refresh from the server in the background.
*/
import { contentFingerprint } from './visual-continuity';
import {
adoptVirtualLightServerSnapshot,
applyVirtualLightEvent,
virtualLightSnapshot,
type VirtualLightSnapshot,
} from './virtual-light-state';
const LS_CFG = 'houseplan_card_cfg_v1';
@@ -25,21 +19,15 @@ export interface HpConfigSnapshot {
layout: Record<string, any>;
layoutRev: number;
layoutFingerprint: string;
virtualLights: VirtualLightSnapshot;
}
let cache: HpConfigSnapshot | null = null;
let inflight: Promise<HpConfigSnapshot> | null = null;
let fetchGeneration = 0;
let inflightGeneration = -1;
let subscribedConnection: any = null;
let subscriptionUnsubscribers: Array<() => void> = [];
let subscribed = false;
const listeners = new Set<() => void>();
const keepUnsubscriber = (value: unknown, target: Array<() => void>): void => {
if (typeof value === 'function') target.push(value as () => void);
};
/** Instant, synchronous best-effort snapshot from the full card's localStorage cache. */
export function cachedSnapshot(): HpConfigSnapshot | null {
if (cache) return cache;
@@ -54,7 +42,6 @@ export function cachedSnapshot(): HpConfigSnapshot | null {
layout,
layoutRev: c.layout_rev || 0,
layoutFingerprint: c.layout_fingerprint || contentFingerprint(layout),
virtualLights: virtualLightSnapshot(c.virtual_lights, c.rev || 0),
};
}
} catch {
@@ -68,70 +55,34 @@ async function fetchFresh(hass: any, generation: number): Promise<HpConfigSnapsh
hass.callWS({ type: 'houseplan/config/get' }),
hass.callWS({ type: 'houseplan/layout/get' }),
]);
const configRev = cfgResp?.rev ?? 0;
const virtualLights = cache
? adoptVirtualLightServerSnapshot(
cache.virtualLights,
cfgResp?.virtual_lights,
configRev,
!!cfgResp && 'virtual_lights' in cfgResp,
)
: virtualLightSnapshot(cfgResp?.virtual_lights, configRev);
const snapshot: HpConfigSnapshot = {
config: cfgResp?.config ?? null,
rev: configRev,
rev: cfgResp?.rev ?? 0,
configFingerprint: contentFingerprint(cfgResp?.config ?? null),
layout: layResp?.layout ?? {},
layoutRev: layResp?.rev ?? 0,
layoutFingerprint: contentFingerprint(layResp?.layout ?? {}),
virtualLights,
};
// A force request may arrive while this fetch is in flight. Its older
// response remains useful to its original caller, but must not repopulate
// the shared cache after the newer generation invalidated it.
if (generation === fetchGeneration) cache = snapshot;
const connection = hass.connection;
if (connection?.subscribeEvents && subscribedConnection !== connection) {
for (const unsubscribe of subscriptionUnsubscribers) unsubscribe();
subscriptionUnsubscribers = [];
subscribedConnection = connection;
if (!subscribed && hass.connection?.subscribeEvents) {
subscribed = true;
const invalidate = () => {
cache = null; // invalidate; listeners reload
fetchGeneration++;
listeners.forEach((l) => l());
};
const pendingUnsubscribers: Array<() => void> = [];
try {
keepUnsubscriber(
await connection.subscribeEvents(invalidate, 'houseplan_config_updated'),
pendingUnsubscribers,
);
await hass.connection.subscribeEvents(invalidate, 'houseplan_config_updated');
// Layout is separate state: dragging an icon on the full card writes only
// the layout, so a static card on the same dashboard kept showing the old
// position until the config changed or the page was reloaded — possibly
// forever on a wall tablet (HP-1454-08).
keepUnsubscriber(
await connection.subscribeEvents(invalidate, 'houseplan_layout_updated'),
pendingUnsubscribers,
);
keepUnsubscriber(await connection.subscribeEvents((event: any) => {
if (cache) {
const next = applyVirtualLightEvent(cache.virtualLights, event?.data);
if (next === cache.virtualLights) return;
cache = { ...cache, virtualLights: next };
} else {
fetchGeneration++;
}
listeners.forEach((listener) => listener());
}, 'houseplan_virtual_light_updated'), pendingUnsubscribers);
if (subscribedConnection === connection) {
subscriptionUnsubscribers = pendingUnsubscribers;
} else {
for (const unsubscribe of pendingUnsubscribers) unsubscribe();
}
await hass.connection.subscribeEvents(invalidate, 'houseplan_layout_updated');
} catch {
for (const unsubscribe of pendingUnsubscribers) unsubscribe();
if (subscribedConnection === connection) subscribedConnection = null;
subscribed = false;
}
}
return snapshot;
+3 -10
View File
@@ -24,7 +24,6 @@ import type { DevItem } from './types';
import { safeStoredColor } from './color';
import { resolveDeviceValueBadge, type ResolvedValueBadge } from './device-value-badge';
import { resolveDevicePulse, type ResolvedDevicePulse } from './device-pulse';
import { isManualVirtualLightMarker } from './virtual-light-state';
export type PresentationSourceKind =
| 'cover' | 'light' | 'controls' | 'device_role' | 'primary' | 'none';
@@ -249,14 +248,9 @@ export function resolvePresentationSources(
source.device.id === d.id && source.via !== 'controls'
);
const localOwned = localLights.filter((source) => source.via !== 'controls');
const ownedLights = globalOwned.length ? globalOwned : localOwned;
// In #107's exact manual mode, outgoing controls remain saved and active in
// the plan graph, but they do not own this marker's face. Its icon/status is
// the same canonical manual source used by Glow, room fill and statistics.
const manualVirtualFace = isManualVirtualLightMarker(d.marker);
const lights = manualVirtualFace ? [...ownedLights] : [
const lights = [
...localLights.filter((source) => source.via === 'controls'),
...ownedLights,
...(globalOwned.length ? globalOwned : localOwned),
];
if (markerRefs.size) {
for (const source of globalLights) {
@@ -283,8 +277,7 @@ export function resolvePresentationSources(
sourceKind = 'cover';
visualSources = [sourceOf(hass, cover, 'cover')];
} else if (lights.length) {
sourceKind = !manualVirtualFace && lights.some((source) => source.via === 'controls')
? 'controls' : 'light';
sourceKind = lights.some((source) => source.via === 'controls') ? 'controls' : 'light';
visualSources = lights.map((source) => {
const role = source.via === 'controls' ? 'control'
: source.via === 'forced' ? 'forced_light' : 'light';
+2 -61
View File
@@ -14,11 +14,6 @@ import {
} from './devices';
import { COVER_GUARDED_CLASSES, isControllable } from './logic';
import type { DevItem } from './types';
import {
isManualVirtualLightMarker,
virtualLightIsOn,
type VirtualLightSnapshot,
} from './virtual-light-state';
export type ToggleOrigin = 'explicit-toggle' | 'default-light' | 'legacy-cover';
export type ToggleSemantics = 'power' | 'group-power' | 'cover' | 'valve';
@@ -37,8 +32,7 @@ export type ToggleTargetVia =
| 'binding'
| 'device-role'
| 'control-entity'
| 'control-marker-driver'
| 'virtual-light';
| 'control-marker-driver';
export interface ResolvedToggleTarget {
entityId: string;
@@ -60,10 +54,6 @@ export interface ToggleCommand {
data: { entity_id: string | string[] };
}
export type ToggleOperation =
| { kind: 'ha-service'; command: ToggleCommand }
| { kind: 'virtual-light'; markerId: string };
export interface ResolvedToggleIntent {
origin: ToggleOrigin;
kind: 'single' | 'group' | 'none';
@@ -74,8 +64,6 @@ export interface ResolvedToggleIntent {
noneReason: ToggleNoneReason | null;
nextEffect: ToggleNextEffect | null;
command: ToggleCommand | null;
/** Non-HA operational target. HA intents continue to expose `command`. */
operation?: ToggleOperation | null;
}
export interface ResolveToggleOptions {
@@ -86,7 +74,6 @@ export interface ResolveToggleOptions {
device: DevItem;
/** Reuse a plan-wide light graph when the caller already has one. */
lightSources?: readonly ResolvedLightSource<DevItem>[];
virtualLights?: VirtualLightSnapshot | null;
}
type PowerService = 'turn_on' | 'turn_off' | 'toggle';
@@ -493,8 +480,7 @@ function resolveControls(options: ResolveToggleOptions): ResolvedToggleIntent {
const refs = persistedExternalControls(
device.marker?.binding, device.marker?.controls ?? device.controls, device.entities,
);
const sources = options.lightSources
|| resolvedLightSources(hass, devices, null, options.virtualLights);
const sources = options.lightSources || resolvedLightSources(hass, devices);
const markerSources = new Map<string, ResolvedLightSource<DevItem>[]>();
for (const source of sources) {
if (!source.key.startsWith('marker:')) continue;
@@ -607,30 +593,6 @@ export function resolveToggleIntent(options: ResolveToggleOptions): ResolvedTogg
const origin = toggleOriginOf(device);
if (!origin) return null;
// This operational target intentionally precedes `controls`: controls stay
// persisted losslessly, but while the exact manual mode is active a tap
// changes the marker's own server state and never calls an HA service.
const marker = device.marker;
if (origin === 'explicit-toggle' && isManualVirtualLightMarker(marker)) {
const on = virtualLightIsOn(marker, options.virtualLights);
return {
origin,
kind: 'single',
semantics: 'power',
targets: [{
entityId: '',
name: device.name,
state: on ? 'on' : 'off',
via: 'virtual-light',
}],
skippedTargets: [],
noneReason: null,
nextEffect: on ? 'turn-off' : 'turn-on',
command: null,
operation: { kind: 'virtual-light', markerId: marker!.id! },
};
}
if (origin === 'explicit-toggle') {
const refs = persistedExternalControls(
device.marker?.binding, device.marker?.controls ?? device.controls, device.entities,
@@ -676,27 +638,6 @@ export function sameToggleCommandTargets(a: ToggleCommand | null, b: ToggleComma
return left.length === right.length && left.every((entityId, index) => entityId === right[index]);
}
export function toggleOperation(intent: ResolvedToggleIntent | null): ToggleOperation | null {
if (!intent) return null;
if (intent.operation) return intent.operation;
return intent.command ? { kind: 'ha-service', command: intent.command } : null;
}
/** Stable confirmation identity; direction is deliberately re-resolved later. */
export function sameToggleOperationTargets(
a: ResolvedToggleIntent | null,
b: ResolvedToggleIntent | null,
): boolean {
const left = toggleOperation(a);
const right = toggleOperation(b);
if (!left || !right || left.kind !== right.kind) return false;
if (left.kind === 'virtual-light' && right.kind === 'virtual-light') {
return left.markerId === right.markerId;
}
return left.kind === 'ha-service' && right.kind === 'ha-service'
&& sameToggleCommandTargets(left.command, right.command);
}
export interface ToggleIntentFormatter {
single: (target: ResolvedToggleTarget | SkippedToggleTarget) => string;
group: (targets: readonly ResolvedToggleTarget[]) => string;
+2 -19
View File
@@ -6,12 +6,6 @@ import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type Co
import { averageLqi, isControllable } from './logic';
import { isDevicePowerSwitch, isSemanticBinaryEntity } from './device-visual';
import type { DevItem, Marker, ServerConfig } from './types';
import {
isManualVirtualLightMarker,
virtualLightFingerprint,
virtualLightIsOn,
type VirtualLightSnapshot,
} from './virtual-light-state';
import {
activeRegistryHass, fullRegistryHass, haRegistrySnapshot, isRegistryEntryEnabled,
resolveHaBindingStatus, type HaRegistrySnapshot,
@@ -328,7 +322,6 @@ const LIGHT_GRAPH_CACHE = new WeakMap<object, CachedLightGraph<any>>();
interface CachedResolvedLightSources<D extends LightSourceDevice> {
graphFingerprint: string;
stateFingerprint: string;
virtualLightFingerprint: string;
registry: unknown;
sources: ResolvedLightSource<D>[];
}
@@ -341,7 +334,6 @@ function lightGraphFingerprint(devices: readonly LightSourceDevice[]): string {
[...device.entities].join(','), device.controls === undefined
? '<runtime-undefined>' : [...device.controls].join(','),
device.marker?.id || '', device.marker?.binding || '', device.marker?.is_light,
(device.marker as any)?.tap_action || '', (device.marker as any)?.removed === true ? 1 : 0,
device.marker?.light_entity || '', device.marker?.controls == null
? '<persisted-null>' : [...device.marker.controls].join(','),
].join('\u001f')).join('\u001e');
@@ -459,22 +451,19 @@ export function resolvedLightSources<D extends LightSourceDevice>(
hass: any,
devices: readonly D[],
room?: LightSourceRoom | null,
virtualLights?: VirtualLightSnapshot | null,
): ResolvedLightSource<D>[] {
// Resolve the graph once per frame and scope by the source owner's precise
// room binding. This preserves cross-room controllers while avoiding a full
// O(devices + links) graph walk for every room consumer.
if (room != null) {
return resolvedLightSources(hass, devices, null, virtualLights).filter((source) =>
return resolvedLightSources(hass, devices).filter((source) =>
lightSourceBelongsToRoom(source.device, room));
}
const graphFingerprint = lightGraphFingerprint(devices);
const stateFingerprint = lightStateFingerprint(hass, devices);
const manualFingerprint = virtualLightFingerprint(virtualLights);
const cached = RESOLVED_LIGHT_CACHE.get(devices as object) as CachedResolvedLightSources<D> | undefined;
if (cached?.graphFingerprint === graphFingerprint
&& cached.stateFingerprint === stateFingerprint
&& cached.virtualLightFingerprint === manualFingerprint
&& cached.registry === hass?.entities) return cached.sources;
type Candidate = ResolvedLightSource<D>;
@@ -539,11 +528,6 @@ export function resolvedLightSources<D extends LightSourceDevice>(
const markerId = source.key.slice('marker:'.length);
const control = incoming.get(markerId);
source.on = !control?.linked || [...control.drivers].some((eid) => hass.states?.[eid]?.state === 'on');
// Manual persistent state overrides incoming controller links only while
// the exact virtual + light + toggle eligibility triple remains active.
if (isManualVirtualLightMarker(source.device.marker)) {
source.on = virtualLightIsOn(source.device.marker, virtualLights);
}
}
}
@@ -575,8 +559,7 @@ export function resolvedLightSources<D extends LightSourceDevice>(
}
const sources = [...byKey.values()];
RESOLVED_LIGHT_CACHE.set(devices as object, {
graphFingerprint, stateFingerprint, virtualLightFingerprint: manualFingerprint,
registry: hass?.entities, sources,
graphFingerprint, stateFingerprint, registry: hass?.entities, sources,
});
return sources;
}
+106 -442
View File
@@ -56,7 +56,6 @@ import {
degradeWalls, rekeyWallsAfterMove,
setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits,
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath,
floorFootprintGeometry,
innerContourForRoom, roomWallProfile, outsetContour,
openingInnerFaceOffsetFromIndex, openingTunnelGeometriesFromIndex,
openingWallIndex as buildOpeningWallIndex, applyWallThicknessToNewRoom,
@@ -74,9 +73,6 @@ import {
type OpenSpanEntry, type BoundaryTarget,
} from './open-spans';
import { ContentSigner } from './signing';
import {
resolveInitialSpace, settleBestEffort, type InitialSpaceSelection,
} from './initial-load';
import { mdiHomeCityOutline } from '@mdi/js';
import {
Affine, applyAffine, readVacTelemetry,
@@ -103,19 +99,10 @@ import {
} from './devices';
import {
formatToggleIntent, projectedTapAction, resolveToggleIntent,
sameToggleOperationTargets, toggleCoverEntity, toggleIntentName, toggleOperation,
sameToggleCommandTargets, toggleCoverEntity, toggleIntentName,
type ResolvedToggleIntent, type ToggleNextEffect, type ToggleNoneReason,
type ToggleSkipReason,
} from './device-toggle';
import {
adoptVirtualLightServerSnapshot,
applyVirtualLightEvent,
reconcileVirtualLightSnapshot,
virtualLightFingerprint,
virtualLightSnapshot,
virtualLightWire,
type VirtualLightSnapshot,
} from './virtual-light-state';
import type {
OpeningCfg,
RoomCfg, RoomDraftCfg, PartitionCfg, WallColumnCfg,
@@ -189,18 +176,12 @@ import {
currentLabs, hashSpace, noteLabsRender, subscribeLabs, type LabsSnapshot,
} from './labs';
import {
ISO_CAMERA, ISO_FLOOR_EDGE_HEIGHT, ISO_WALL_HEIGHT, isoFloorMatrixCss,
projectPlanPoint, projectedFrame,
ISO_CAMERA, ISO_WALL_HEIGHT, projectPlanPoint, projectedFrame,
unprojectFloorPoint, type ScenePoint, type ViewRect,
} from './iso-projection';
import {
buildIsoFloorGeometry, buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint,
type IsoFloorGeometry, type IsoWallGeometry,
buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint, type IsoWallGeometry,
} from './iso-walls';
import {
buildIsoOpeningBasis, isoOpeningBounds, projectIsoOpening, resolveIsoDecoration,
type IsoDecorationLayers, type IsoOpeningBasis, type IsoOpeningPanel,
} from './iso-openings';
import {
acquireHaRegistries, activeRegistryHass, cacheHaBindingStatuses,
fullRegistryHass, haRegistryBuildSignature, haRegistryDiagnostics, haRegistrySnapshot,
@@ -227,7 +208,7 @@ import {
} from './opening-placement';
import { safeStoredColor } from './color';
const CARD_VERSION = '1.63.0';
const CARD_VERSION = '1.63.0-beta.1';
const DISPLAY_LABEL_KEYS: Record<DeviceDisplayMode, I18nKey> = {
badge: 'display.badge',
icon_ripple: 'display.icon_ripple',
@@ -633,13 +614,8 @@ class HouseplanCard extends LitElement {
private _cfgContentFingerprint = '';
private _unsubCfg: (() => void) | null = null;
private _unsubLayout: (() => void) | null = null;
private _unsubVirtual: (() => void) | null = null;
private _liveSyncAttempt: Promise<void> | null = null;
private _liveSyncGeneration = 0;
private _liveSyncConnection: any = null;
private _layoutRev = 0;
private _layoutContentFingerprint = '';
private _virtualLights: VirtualLightSnapshot = virtualLightSnapshot(null);
/** One-deep server snapshot; invalidated by the first later plan edit. */
private _canOptimizeUndo = false;
private _undoKind: 'optimize' | 'import' | null = null;
@@ -1176,12 +1152,7 @@ class HouseplanCard extends LitElement {
key: string;
value: ReturnType<typeof wallBodiesUnionPath>;
} | null = null;
private _isoGeometryCache = new Map<string, {
geometry: IsoWallGeometry;
floor: IsoFloorGeometry;
openings: readonly IsoOpeningBasis[];
frame: Rect;
}>();
private _isoGeometryCache = new Map<string, { geometry: IsoWallGeometry; frame: Rect }>();
private _isoFallback = new Set<string>();
private _openingTunnelCache: {
key: string;
@@ -1581,7 +1552,6 @@ class HouseplanCard extends LitElement {
private _capturedSnapshotLayout: Record<string, { x: number; y: number; s?: string; k?: number }> | null = null;
private _capturedSnapshotActivity = '';
private _capturedSnapshotConfigEpoch = -1;
private _capturedSnapshotVirtual = '';
private _lastValidStageSize: [number, number] | null = null;
private _pendingRefitSize: [number, number] | null = null;
private _refitRaf = 0;
@@ -1872,7 +1842,6 @@ class HouseplanCard extends LitElement {
if (!this._loadOk && this._serverCfg && this.hass) this._scheduleLoadRetry();
// AUD-159B1-01: the placement is only knowable once we are IN the DOM.
if (!this._warmSlot && this._config) this._warmAdopt();
if (this._loadOk) this._ensureLiveSyncSubscriptions();
// DEV-B703-03: one task later the element Lovelace replaced has detached
// — only then is its open dialog ours to take over.
if (this._warmVp && !this._warmRevivePending && this._warmReviveTimer === undefined) {
@@ -1957,17 +1926,6 @@ class HouseplanCard extends LitElement {
this._unsubLayout();
this._unsubLayout = null;
}
if (this._unsubVirtual) {
this._unsubVirtual();
this._unsubVirtual = null;
}
if (this._unsubTrail) {
this._unsubTrail();
this._unsubTrail = undefined;
}
this._liveSyncGeneration++;
this._liveSyncAttempt = null;
this._liveSyncConnection = null;
clearTimeout(this._layoutSyncTimer);
clearTimeout(this._duplicateColumnTimer);
for (const timer of this._glowFadeTimers.values()) clearTimeout(timer);
@@ -2320,9 +2278,13 @@ class HouseplanCard extends LitElement {
this._layout = c.layout || {};
this._layoutRev = c.layout_rev || 0;
this._layoutContentFingerprint = c.layout_fingerprint || contentFingerprint(this._layout);
this._virtualLights = virtualLightSnapshot(c.virtual_lights, this._cfgRev);
this._serverStorage = true;
this._adoptInitialSpace(this._model);
const hs = this._hashSpace();
const nav = this._savedNav();
if (hs && this._model.find((sp) => sp.id === hs)) { this._space = hs; this._hashApplied = true; }
else if (nav?.space && this._model.find((sp) => sp.id === nav.space)) { this._space = nav.space; this._navApplied = true; }
else if (config.default_floor) this._space = config.default_floor;
else if (!this._model.find((sp) => sp.id === this._space)) this._space = this._model[0]?.id || this._space;
}
} catch {
/* ignore */
@@ -2715,9 +2677,6 @@ class HouseplanCard extends LitElement {
// keep the accepted identity paired with exactly what is cached.
this._cfgContentFingerprint = contentFingerprint(this._serverCfg);
this._layoutContentFingerprint = contentFingerprint(this._layout);
this._virtualLights = reconcileVirtualLightSnapshot(
this._virtualLights, this._serverCfg, this._cfgRev,
);
localStorage.setItem(LS_CFG, JSON.stringify({
config: this._serverCfg,
rev: this._cfgRev,
@@ -2725,7 +2684,6 @@ class HouseplanCard extends LitElement {
layout: this._layout,
layout_rev: this._layoutRev,
layout_fingerprint: this._layoutContentFingerprint,
virtual_lights: virtualLightWire(this._virtualLights),
}));
} catch {
/* ignore */
@@ -2761,32 +2719,13 @@ class HouseplanCard extends LitElement {
return !space?.bg?.href || this._signer.isReady(this.hass, space.bg.href);
}
private _initialSpaceSelection(models: SpaceModel[]): InitialSpaceSelection {
return resolveInitialSpace({
spaceIds: models.map((space) => space.id),
hashSpace: this._hashSpace(),
acceptHash: !this._hashApplied,
currentSpace: this._space,
preserveCurrent: this._hashApplied || this._navApplied || this._warmVpArmed,
savedSpace: this._savedNav()?.space,
defaultSpace: this._config?.default_floor,
});
}
/** Install one exact raw-space authority before any spatial candidate paints. */
private _adoptInitialSpace(models: SpaceModel[]): InitialSpaceSelection {
const selection = this._initialSpaceSelection(models);
if (!selection.id) return selection;
this._space = selection.id;
if (selection.source === 'hash') this._hashApplied = true;
if (selection.source === 'saved') this._navApplied = true;
return selection;
}
private _candidateBackdrop(config: ServerConfig | null, spaceId = this._space): string {
const models = spaceModels(config);
const preferred = this._initialSpaceSelection(models).id
|| (models.some((space) => space.id === spaceId) ? spaceId : models[0]?.id);
const hashSpace = this._hashSpace();
const savedSpace = this._savedNav()?.space || '';
const preferred = !this._hashApplied && models.some((space) => space.id === hashSpace) ? hashSpace
: !this._hashApplied && !this._navApplied && models.some((space) => space.id === savedSpace) ? savedSpace
: models.some((space) => space.id === spaceId) ? spaceId : models[0]?.id;
return models.find((space) => space.id === preferred)?.bg?.href || '';
}
@@ -3205,19 +3144,6 @@ class HouseplanCard extends LitElement {
this._cfgContentFingerprint = nextCfgFingerprint;
}
this._cfgRev = cfgResp?.rev ?? this._cfgRev;
if (cfgResp && ('virtual_lights' in cfgResp || 'config' in cfgResp)) {
const nextVirtualLights = adoptVirtualLightServerSnapshot(
this._virtualLights,
cfgResp.virtual_lights,
this._cfgRev,
'virtual_lights' in cfgResp,
);
if (virtualLightFingerprint(nextVirtualLights)
!== virtualLightFingerprint(this._virtualLights)) {
this._virtualLights = nextVirtualLights;
this._capturedSnapshotVirtual = '';
}
}
let layoutChanged = false;
if (layResp !== undefined || layoutOverride !== undefined) {
@@ -3282,14 +3208,61 @@ class HouseplanCard extends LitElement {
&& this._continuity.state === 'steady') {
this._beginContinuityCandidate('structural-response', true);
}
this._loadOk = true;
this._connectionWasLost = false;
this._serverStorage = true;
// absent can_write = older backend / demo stub → keep null (legacy admin fallback)
if (typeof cfgResp?.can_write === 'boolean') this._serverCanWrite = cfgResp.can_write;
this._canOptimizeUndo = !!(cfgResp?.can_optimize_undo || layResp?.can_optimize_undo);
this._adoptStructuralResponses(cfgResp, layResp);
this._adoptInitialSpace(this._model);
this._resumePendingNavMode();
this._adoptStructuralResponses(cfgResp, layResp);
// live sync: the config was changed in another window → re-read it
if (!this._unsubCfg) {
this._unsubCfg = await this.hass.connection.subscribeEvents((ev: any) => {
// Flush a pending local edit BEFORE adopting a remote revision:
// otherwise the debounced write reads a config that this reload has
// already replaced, and the user's edit vanishes (audit L2).
const observedRev = Number(ev?.data?.rev ?? -1);
if (observedRev !== this._cfgRev) this._reloadConfigOnly(false, observedRev);
}, 'houseplan_config_updated');
}
// server-side trails are additive: an older backend without the WS
// command just leaves the map empty and the card shows live-only trails
this.hass.callWS({ type: 'houseplan/trail/get' })
.then((r: any) => { this._vacSrvTrails = r?.trails || {}; this.requestUpdate(); })
.catch(() => undefined);
if (!this._unsubTrail) {
this._unsubTrail = await this.hass.connection.subscribeEvents(async () => {
try {
const r: any = await this.hass.callWS({ type: 'houseplan/trail/get' });
this._vacSrvTrails = r?.trails || {};
this.requestUpdate();
} catch { /* transient WS hiccup — the next event retries */ }
}, 'houseplan_trail_updated');
}
if (!this._unsubLayout) {
// Positions are separate state. The static card learned to follow them
// in v1.46.0 and the full one did not, so two full cards side by side
// stayed out of sync until a reload (HP-1460-03).
this._unsubLayout = await this.hass.connection.subscribeEvents(
(ev: any) => this._onLayoutEvent(Number(ev?.data?.rev ?? -1)),
'houseplan_layout_updated',
);
}
const hs = this._hashSpace();
const nav = this._savedNav();
if (!this._hashApplied && hs && this._model.find((s) => s.id === hs)) {
this._space = hs;
this._hashApplied = true;
} else if (nav?.space && !this._navApplied && !this._hashApplied
&& this._model.find((s) => s.id === nav.space)) {
// the cached config might have been stale (no such space) — retry once
// the live config is in
this._space = nav.space;
this._navApplied = true;
} else if (this._norm && !this._model.find((s) => s.id === this._space)) {
this._space = this._model[0]?.id || this._space;
}
this._cacheSnapshot();
// DEV-B703-03: a warm re-mount already holds the exact viewport of the
// instance that was thrown away; the centred restore here IS the
@@ -3297,14 +3270,6 @@ class HouseplanCard extends LitElement {
// another space) still needs it.
if (this._warmVpArmed && this._space === this._warmVp?.space) this._warmVpArmed = false;
else if (!hadViewport || this._space !== visibleSpace) this._restoreZoom();
this._loadOk = true;
// Trails and event subscriptions enrich an already complete snapshot.
// A read-only HA session may reject these; that must never roll the
// accepted config back into the mandatory load catch.
void this.hass.callWS({ type: 'houseplan/trail/get' })
.then((r: any) => { this._vacSrvTrails = r?.trails || {}; this.requestUpdate(); })
.catch(() => undefined);
this._ensureLiveSyncSubscriptions();
} catch (e) {
if (this._serverCfg) {
// DEV-B703-02: this instance already RENDERS a valid config (the LS
@@ -3338,94 +3303,6 @@ class HouseplanCard extends LitElement {
}
}
/** Best-effort live sync starts only after the initial snapshot is usable. */
private _ensureLiveSyncSubscriptions(): void {
const connection = this.hass?.connection;
if (!connection) return;
if (connection !== this._liveSyncConnection) {
this._unsubCfg?.();
this._unsubCfg = null;
this._unsubLayout?.();
this._unsubLayout = null;
this._unsubTrail?.();
this._unsubTrail = undefined;
this._unsubVirtual?.();
this._unsubVirtual = null;
this._liveSyncGeneration++;
this._liveSyncAttempt = null;
this._liveSyncConnection = connection;
}
if (this._liveSyncAttempt) return;
const generation = this._liveSyncGeneration;
const attempts: Array<() => Promise<void>> = [];
const subscribe = (
current: () => (() => void) | null | undefined,
adopt: (unsubscribe: () => void) => void,
event: string,
callback: (event: any) => void | Promise<void>,
): void => {
if (current()) return;
attempts.push(async () => {
const unsubscribe = await connection.subscribeEvents(callback, event);
const valid = generation === this._liveSyncGeneration
&& this.isConnected && this.hass?.connection === connection && !current();
if (valid) adopt(unsubscribe);
else unsubscribe?.();
});
};
subscribe(
() => this._unsubCfg,
(unsubscribe) => { this._unsubCfg = unsubscribe; },
'houseplan_config_updated',
(ev: any) => {
// Flush a pending local edit BEFORE adopting a remote revision:
// otherwise the debounced write reads a config that this reload has
// already replaced, and the user's edit vanishes (audit L2).
const observedRev = Number(ev?.data?.rev ?? -1);
if (observedRev !== this._cfgRev) void this._reloadConfigOnly(false, observedRev);
},
);
subscribe(
() => this._unsubTrail,
(unsubscribe) => { this._unsubTrail = unsubscribe; },
'houseplan_trail_updated',
async () => {
try {
const r: any = await this.hass.callWS({ type: 'houseplan/trail/get' });
this._vacSrvTrails = r?.trails || {};
this.requestUpdate();
} catch { /* transient WS hiccup — the next event retries */ }
},
);
subscribe(
() => this._unsubLayout,
(unsubscribe) => { this._unsubLayout = unsubscribe; },
'houseplan_layout_updated',
(ev: any) => this._onLayoutEvent(Number(ev?.data?.rev ?? -1)),
);
subscribe(
() => this._unsubVirtual,
(unsubscribe) => { this._unsubVirtual = unsubscribe; },
'houseplan_virtual_light_updated',
(ev: any) => {
const next = applyVirtualLightEvent(this._virtualLights, ev?.data);
if (next === this._virtualLights) return;
this._virtualLights = next;
this._capturedSnapshotVirtual = '';
this._cacheSnapshot();
this.requestUpdate();
},
);
if (!attempts.length) return;
const task = settleBestEffort(attempts).then(() => undefined);
this._liveSyncAttempt = task;
void task.finally(() => {
if (this._liveSyncAttempt === task) this._liveSyncAttempt = null;
});
}
/**
* Adopt the server config. Any pending local write is flushed first and, if a
* write is still in flight, the reload is deferred — adopting a revision on
@@ -3462,12 +3339,9 @@ class HouseplanCard extends LitElement {
this._scheduleLoadRetry(true);
return;
}
const visibleSpace = this._space;
this._adoptStructuralResponses(resp);
this._adoptInitialSpace(this._model);
this._resumePendingNavMode();
this._cacheSnapshot();
if (this._space !== visibleSpace) this._restoreZoom();
this._regSignature = '';
this._maybeRebuildDevices();
this.requestUpdate();
@@ -3560,12 +3434,10 @@ class HouseplanCard extends LitElement {
+ `${runtime.flashKind
&& (runtime.expiresAt || runtime.flashTs + ACTIVITY_WINDOW_MS) > now ? 1 : 0}`)
.join('|');
const virtualFingerprint = virtualLightFingerprint(this._virtualLights);
if (this._capturedSnapshotSequence === this._hassSequence
&& this._capturedSnapshotDevices === this._devices
&& this._capturedSnapshotLayout === this._layout
&& this._capturedSnapshotConfigEpoch === this._cfgEpoch
&& this._capturedSnapshotVirtual === virtualFingerprint
&& this._capturedSnapshotActivity === activity) return;
const planHass = this._planHass;
const presentations = new Map<string, ResolvedDevicePresentation>();
@@ -3614,9 +3486,7 @@ class HouseplanCard extends LitElement {
));
}
}
const planLightSources = resolvedLightSources(
planHass, this._devices, null, this._virtualLights,
);
const planLightSources = resolvedLightSources(planHass, this._devices);
for (const device of this._devices) {
for (const showLqi of [false, true]) {
presentations.set(presentationSnapshotKey(device.id, showLqi), resolveDevicePresentation(
@@ -3672,7 +3542,6 @@ class HouseplanCard extends LitElement {
this._capturedSnapshotLayout = this._layout;
this._capturedSnapshotActivity = activity;
this._capturedSnapshotConfigEpoch = this._cfgEpoch;
this._capturedSnapshotVirtual = virtualFingerprint;
if (!this._visibleDeviceSnapshot || this._continuity.state === 'steady') {
this._visibleDeviceSnapshot = snapshot;
this._candidateDeviceSnapshot = null;
@@ -3793,11 +3662,10 @@ class HouseplanCard extends LitElement {
this._continuityPaintToken = -1;
}
if (this._loading) return;
// Re-read config and layout as one mandatory candidate. Optional live-sync
// subscriptions are then retried independently for whichever channels are
// still missing; their rejection cannot invalidate this snapshot.
// `_loadFromServer` adopts each side by revision+fingerprint and preserves
// equal references.
// a subscribe lost mid-load leaves _loadOk=true without _unsubCfg — the
// full load path repairs both (every subscribe in it is guarded)
// Re-read config and layout as one candidate. `_loadFromServer` now adopts
// each side by revision+fingerprint and preserves equal references.
this._loadFromServer();
};
private _onConnLost = (): void => {
@@ -4355,27 +4223,10 @@ class HouseplanCard extends LitElement {
};
if (action === 'toggle') {
const initial = this._toggleIntent(actionDevice);
if (!initial || !toggleOperation(initial)) return; // configured no-target is an intentional, quiet no-op
if (!initial?.command) return; // configured no-target is an intentional, quiet no-op
const execute = (intent: ResolvedToggleIntent): void => {
const operation = toggleOperation(intent);
if (!operation) return;
if (operation.kind === 'virtual-light') {
this.hass.callWS({
type: 'houseplan/virtual_light/toggle',
marker_id: operation.markerId,
}).then((result: any) => {
const next = applyVirtualLightEvent(this._virtualLights, result);
if (next === this._virtualLights) return;
this._virtualLights = next;
this._capturedSnapshotVirtual = '';
this._cacheSnapshot();
this.requestUpdate();
}).catch((e: any) => this._showToast(this._t(
'toast.virtual_light_toggle_failed', { err: this._errText(e) },
)));
return;
}
const { command } = operation;
const command = intent.command;
if (!command) return;
this.hass.callService(command.domain, command.service, command.data)
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
};
@@ -4386,7 +4237,7 @@ class HouseplanCard extends LitElement {
exec: () => {
const currentDevice = this._devices.find((item) => item.id === actionDevice.id);
const current = currentDevice ? this._toggleIntent(currentDevice) : null;
if (!current || !sameToggleOperationTargets(initial, current)) {
if (!current?.command || !sameToggleCommandTargets(initial.command, current.command)) {
this._showToast(this._t('toast.tap_target_changed'));
return;
}
@@ -4567,31 +4418,21 @@ class HouseplanCard extends LitElement {
}
/** The rectangle "fit to screen" fits — always the content (docs/CANVAS.md). */
private _isoSource(): { key: string; build: () => {
walls: any;
floor: any;
openings: readonly IsoOpeningBasis[];
} } {
private _isoSource(): { key: string; build: () => any } {
const space = this._spaceModel();
const walls = this._spaceWalls;
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
const openings = this._openingsR.map((opening, sourceIndex) => ({
id: String(opening.id || sourceIndex),
sourceIndex,
type: opening.type,
x: opening.rx,
y: opening.ry,
const openings = (this._curSpaceCfg?.openings || []).map((opening: any) => ({
x: Number(opening.x) * NORM_W,
y: Number(opening.y) * NORM_W,
angle: Number(opening.angle) || 0,
length: opening.rlen > 0 ? opening.rlen : 0.9 * NORM_W,
flipH: !!opening.flip_h,
flipV: !!opening.flip_v,
length: (Number(opening.length) > 0 ? Number(opening.length) : 0.9) * NORM_W,
}));
const key = `${space.id}|${isoGeometryFingerprint({
rooms: space.rooms, walls, openCuts, openings,
partitions: space.partitions, roomDrafts: space.room_drafts, columns: space.wall_columns,
cellCm: this._cellCm, gridPitch: this._gridPitch, wallKeyPitch: this._wallKeyPitch,
camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT,
floorEdgeHeight: ISO_FLOOR_EDGE_HEIGHT, algorithm: 3,
camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT, algorithm: 2,
})}`;
return {
key,
@@ -4602,32 +4443,9 @@ class HouseplanCard extends LitElement {
space.rooms, walls, openCuts, openings,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
)
: null;
if ((walls.length || extras.length) && !united)
throw new Error('wall boolean geometry failed');
const floor = united?.paperGeom ?? floorFootprintGeometry(
space.rooms, walls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
if (!floor) throw new Error('floor boolean geometry failed');
const openingIndex = united?.openingIndex || buildOpeningWallIndex(
space.rooms, walls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
const openingBases = openings.map((opening) => {
const faceFlipV = opening.type === 'gate' ? !opening.flipV : opening.flipV;
const face = walls.length || opening.type === 'gate'
? openingInnerFaceOffsetFromIndex(openingIndex, {
x: opening.x,
y: opening.y,
angle: opening.angle,
length: opening.length,
flip_v: faceFlipV,
})
: { ox: 0, oy: 0, cm: 0, side: -1 as -1 | 1 };
return buildIsoOpeningBasis({ ...opening, face });
});
return { walls: united?.geom || [], floor, openings: Object.freeze(openingBases) };
: { geom: [] };
if (!united) throw new Error('wall boolean geometry failed');
return united.geom;
},
};
}
@@ -4637,29 +4455,14 @@ class HouseplanCard extends LitElement {
try { return this._isoSource().key; } catch { return `${this._space}|invalid`; }
}
private _isoScene(): {
key: string;
geometry: IsoWallGeometry;
floor: IsoFloorGeometry;
openings: readonly IsoOpeningBasis[];
frame: Rect;
} {
private _isoScene(): { key: string; geometry: IsoWallGeometry; frame: Rect } {
const source = this._isoSource();
const cached = this._isoGeometryCache.get(source.key);
if (cached) return { key: source.key, ...cached };
const flat = this._frameOf().rect;
const structural = source.build();
const openingFrame = isoOpeningBounds(structural.openings);
const structuralFrame = openingFrame ? unionRect(flat, openingFrame) : flat;
const frame = projectedFrame({
rect: structuralFrame,
wallHeight: ISO_WALL_HEIGHT,
openingHeight: ISO_WALL_HEIGHT,
floorDepth: ISO_FLOOR_EDGE_HEIGHT,
});
const geometry = buildIsoWallGeometry(structural.walls);
const floor = buildIsoFloorGeometry(structural.floor, ISO_FLOOR_EDGE_HEIGHT);
const value = { geometry, floor, openings: structural.openings, frame };
const frame = projectedFrame({ rect: flat, wallHeight: ISO_WALL_HEIGHT });
const geometry = buildIsoWallGeometry(source.build());
const value = { geometry, frame };
lruWrite(this._isoGeometryCache, source.key, value, 8);
return { key: source.key, ...value };
}
@@ -4694,14 +4497,6 @@ class HouseplanCard extends LitElement {
private _baseVb(): number[] {
if (this._effectiveProjection() === 'iso') {
// No-borders is the accepted Stage 1 no-volume scene. Stage 2 structure
// is absent there, so its opening/floor-edge bounds must be absent too;
// otherwise merely hiding volume reframes every live floor pixel.
if (!this._spaceDisplayForRender().showBorders) {
const flat = this._frameOf().rect;
const frame = projectedFrame({ rect: flat, wallHeight: ISO_WALL_HEIGHT });
return [frame.x, frame.y, frame.w, frame.h];
}
const frame = this._isoScene().frame;
return [frame.x, frame.y, frame.w, frame.h];
}
@@ -10041,9 +9836,7 @@ class HouseplanCard extends LitElement {
mode,
mode === 'lqi' && room.area ? this._roomLqi(room.area) : null,
mode === 'light'
? resolvedLightState(resolvedLightSources(
this._renderPlanHass, this._renderDevices, room, this._virtualLights,
))
? resolvedLightState(resolvedLightSources(this._renderPlanHass, this._renderDevices, room))
: 'none',
mode === 'temp' ? this._roomTemp(room) : null,
disp.tempMin,
@@ -10264,102 +10057,15 @@ class HouseplanCard extends LitElement {
</g>` as unknown as TemplateResult;
}
private _isoDecorationLayers(disp: SpaceDisplay): IsoDecorationLayers {
const filtersSupported = typeof CSS === 'undefined'
|| typeof CSS.supports !== 'function'
|| CSS.supports('filter', 'blur(1px)');
const forcedColors = typeof matchMedia === 'function'
&& matchMedia('(forced-colors: active)').matches;
return resolveIsoDecoration({
showBorders: disp.showBorders,
hideOpenings: disp.hideOpenings,
filtersSupported,
forcedColors,
});
}
private _isoOpeningPanels(layers: IsoDecorationLayers): IsoOpeningPanel[] {
if (!layers.panels) return [];
const scene = this._isoScene();
const panels = scene.openings.flatMap((basis) => {
const opening = this._openingsR[basis.sourceIndex];
return opening ? projectIsoOpening(basis, this._openingAmt(opening)) : [];
});
return panels.sort((a, b) => a.depth - b.depth
|| a.sourceIndex - b.sourceIndex || a.leaf - b.leaf);
}
private _renderIsoDefs(
layers: IsoDecorationLayers,
root: 'underlay' | 'shadows' | 'walls',
): TemplateResult {
return svg`<defs>
${root === 'walls' && layers.materialNuance ? svg`
<linearGradient id="hp-iso-wall-side" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" class="iso-side-hi"></stop><stop offset="1" class="iso-side-lo"></stop>
</linearGradient>
<linearGradient id="hp-iso-wall-top" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" class="iso-top-hi"></stop><stop offset="1" class="iso-top-lo"></stop>
</linearGradient>` : nothing}
${root === 'underlay' && layers.shadows ? svg`
<filter id="hp-iso-ambient-shadow" x="-12%" y="-12%" width="124%" height="130%">
<feGaussianBlur stdDeviation="7"></feGaussianBlur>
</filter>` : nothing}
${root === 'shadows' && layers.shadows ? svg`
<filter id="hp-iso-contact-shadow" x="-8%" y="-20%" width="116%" height="140%">
<feGaussianBlur stdDeviation="2.5"></feGaussianBlur>
</filter>
<filter id="hp-iso-leaf-shadow" x="-12%" y="-30%" width="124%" height="160%">
<feGaussianBlur stdDeviation="2"></feGaussianBlur>
</filter>` : nothing}
</defs>` as unknown as TemplateResult;
}
private _renderIsoUnderlay(layers: IsoDecorationLayers): TemplateResult {
if (!layers.structural) return svg`` as unknown as TemplateResult;
const floor = this._isoScene().floor;
return svg`<g class="iso-underlay" data-hp="iso-underlay" aria-hidden="true" pointer-events="none">
${this._renderIsoDefs(layers, 'underlay')}
${layers.shadows && floor.footprintPath
? svg`<path class="iso-ambient-shadow" d=${floor.footprintPath} transform="translate(0 8)"></path>`
: nothing}
<g class="iso-floor-edge">${floor.sides.map((face) =>
svg`<path class="iso-floor-side" d=${face.d} data-component=${face.component}
data-edge=${face.edge}></path>`)}</g>
</g>` as unknown as TemplateResult;
}
private _renderIsoShadows(
layers: IsoDecorationLayers,
panels: readonly IsoOpeningPanel[],
): TemplateResult {
if (!layers.shadows) return svg`` as unknown as TemplateResult;
const geometry = this._isoScene().geometry;
return svg`<g class="iso-shadows" data-hp="iso-shadows" aria-hidden="true" pointer-events="none">
${this._renderIsoDefs(layers, 'shadows')}
<path class="iso-contact-shadow" d=${geometry.contactPath}></path>
<g class="iso-leaf-shadows">${panels.map((panel) =>
svg`<path class="iso-leaf-shadow" d=${panel.shadowD}
data-id=${panel.id} data-leaf=${panel.leaf}></path>`)}</g>
</g>` as unknown as TemplateResult;
}
private _renderIsoWalls(
layers: IsoDecorationLayers,
panels: readonly IsoOpeningPanel[],
): TemplateResult {
private _renderIsoWalls(): TemplateResult {
if (this._renderProjection !== 'iso') return svg`` as unknown as TemplateResult;
if (!layers.structural) return svg`` as unknown as TemplateResult;
const disp = this._spaceDisplayForRender();
if (!disp.showBorders) return svg`` as unknown as TemplateResult;
const scene = this._isoScene();
return svg`<g class="iso-walls" data-hp="iso-walls" data-fingerprint=${scene.key}>
${this._renderIsoDefs(layers, 'walls')}
<g class="iso-wall-sides">${scene.geometry.sides.map((face) =>
svg`<path class="iso-wall-side" d=${face.d} data-edge=${face.edge}></path>`)}</g>
<path class="iso-wall-top" d=${scene.geometry.topPath} fill-rule="evenodd"></path>
${layers.panels ? svg`<g class="iso-openings" data-hp="iso-openings"
aria-hidden="true" pointer-events="none">${panels.map((panel) =>
svg`<path class="iso-opening-panel iso-${panel.type}" d=${panel.d}
data-id=${panel.id} data-kind=${panel.type} data-leaf=${panel.leaf}></path>`)}</g>` : nothing}
</g>` as unknown as TemplateResult;
}
@@ -13555,9 +13261,7 @@ class HouseplanCard extends LitElement {
} = this._lightBarriers(space, polys, physical);
// Resolve against the whole plan: a controller and its passive lamp may
// legitimately live in different spaces. Ownership is filtered afterwards.
const resolvedSources = resolvedLightSources(
this._renderPlanHass, this._renderDevices, null, this._virtualLights,
)
const resolvedSources = resolvedLightSources(this._renderPlanHass, this._renderDevices)
.filter((source) => source.device.space === space.id);
const sourcesByDevice = new Map<string, typeof resolvedSources>();
for (const source of resolvedSources) {
@@ -14154,10 +13858,6 @@ class HouseplanCard extends LitElement {
const devs = this._renderDevices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
const deviceSnapshot = this._renderDeviceSnapshot;
const disp = this._spaceDisplayForRender();
// Stage 2 capability work is deliberately absent from Flat/Labs-off
// renders. Decorative capability never enters the structural cache key.
const isoLayers = iso ? this._isoDecorationLayers(disp) : null;
const isoPanels = isoLayers ? this._isoOpeningPanels(isoLayers) : [];
const roomFills = this._resolvedRoomFills(space, disp);
const glowBase = this._resolvedGlowBase(space, disp, roomFills);
const showLqi = disp.showLqi ?? this._config.show_signal ?? true;
@@ -14314,18 +14014,8 @@ class HouseplanCard extends LitElement {
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}"
?inert=${this._continuity.overlayBlocksInteraction || this._modeTransitionBusy}
style="${transitionBrightness !== 1 ? `filter:brightness(${transitionBrightness.toFixed(3)})` : ''}">
${iso && isoLayers?.structural ? svg`<svg class="iso-underlay-svg"
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
${this._renderIsoUnderlay(isoLayers)}
</svg>` : nothing}
<svg class=${isoLayers?.structural ? 'plan-svg' : nothing}
viewBox=${isoLayers?.structural
? `${view.x} ${view.y} ${view.w} ${view.h}`
: `${floorView.x} ${floorView.y} ${floorView.w} ${floorView.h}`}
preserveAspectRatio=${isoLayers?.structural || !iso ? 'xMidYMid meet' : 'none'}>
<g class=${isoLayers?.structural ? 'iso-floor-scene' : nothing}
transform=${isoLayers?.structural ? isoFloorMatrixCss() : nothing}>
<svg viewBox="${floorView.x} ${floorView.y} ${floorView.w} ${floorView.h}"
preserveAspectRatio=${iso ? 'none' : 'xMidYMid meet'}>
${''/* THE PAPER IS THE ROOMS (docs/BACKDROP.md §3, owner
2026-08-04). Opaque shapes stop the scene background —
bg_color or the 'daynight' sky — from bleeding through the
@@ -14517,11 +14207,7 @@ class HouseplanCard extends LitElement {
preview deliberately paint AFTER real wall bodies. Their
full centreline geometry remains visible for editing. */}
${this._editing ? this._renderOpenWalls(disp) : nothing}
${disp.hideOpenings && !this._markup
? nothing
: isoLayers && !isoLayers.floorSymbols
? nothing
: this._renderOpenings(disp)}
${disp.hideOpenings && !this._markup ? nothing : this._renderOpenings(disp)}
${this._renderWallThickUi()}
${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing}
${''/* editor chrome, not plan content: the backdrop frame sits on
@@ -14530,16 +14216,10 @@ class HouseplanCard extends LitElement {
backdrop editor, where rooms and devices are pointer-inert. */}
${this._renderBackdropFrame(view)}
${this._renderTextFrame(view)}
</g>
</svg>
${iso && isoLayers?.structural ? svg`<svg class="iso-shadows-svg"
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
${iso ? svg`<svg class="iso-walls-svg" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
${this._renderIsoShadows(isoLayers, isoPanels)}
</svg>
<svg class="iso-walls-svg" viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
${this._renderIsoWalls(isoLayers, isoPanels)}
${this._renderIsoWalls()}
</svg>` : nothing}
${''/* docs/CANVAS.md §6: an icon is a percentage of the PLAN and
scales with it when you zoom — the behaviour the card always
@@ -14775,9 +14455,7 @@ class HouseplanCard extends LitElement {
private _activitySnapshot(
d: DevItem,
planLightSources = resolvedLightSources(
this._planHass, this._devices, null, this._virtualLights,
),
planLightSources = resolvedLightSources(this._planHass, this._devices),
): { samples: EntityVisualSample[]; sourceKey: string } {
const sources = resolvePresentationSources(
this._planHass, d, this._devices, planLightSources, this._fullRegistryHass,
@@ -14816,9 +14494,7 @@ class HouseplanCard extends LitElement {
return snapshots;
}
const live = new Set<string>();
const planLightSources = resolvedLightSources(
this._planHass, this._devices, null, this._virtualLights,
);
const planLightSources = resolvedLightSources(this._planHass, this._devices);
for (const d of this._devices) {
if (d.hidden) continue;
// Alarm/continuous semantics are resolved from the current samples and
@@ -15856,9 +15532,7 @@ class HouseplanCard extends LitElement {
if (l != null) rows.push(html`<span class="rlm"><ha-icon icon="mdi:zigbee"></ha-icon>${l}</span>`);
}
if (disp.labelLight) {
const ls = resolvedLightStats(resolvedLightSources(
this._renderPlanHass, this._renderDevices, r, this._virtualLights,
));
const ls = resolvedLightStats(resolvedLightSources(this._renderPlanHass, this._renderDevices, r));
if (ls) {
const txt = ls.on === 0
? this._t('roomcard.light_off')
@@ -16789,7 +16463,7 @@ class HouseplanCard extends LitElement {
else if (['sensor', 'binary_sensor', 'number', 'select'].includes(dom))
out.push({ eid, kind: 'value' });
};
for (const source of resolvedLightSources(h, this._devices, null, this._virtualLights)) {
for (const source of resolvedLightSources(h, this._devices)) {
if (source.device.id !== d.id) continue;
for (const eid of [...source.serviceEids, ...source.stateEids]) push(eid);
}
@@ -16994,7 +16668,6 @@ class HouseplanCard extends LitElement {
registryHass: this._fullRegistryHass,
devices,
device,
virtualLights: this._virtualLights,
});
}
@@ -17025,9 +16698,6 @@ class HouseplanCard extends LitElement {
this._t((`marker.toggle_skip_${value.replace('-', '_')}`) as any);
return formatToggleIntent(intent, {
single: (target) => {
if ('via' in target && target.via === 'virtual-light') {
return this._t('marker.virtual_light_target', { name: target.name });
}
const entityId = target.entityId || ('ref' in target ? target.ref : '');
const name = target.name || entityId;
return this._t('marker.toggle_hint_single', { name, id: entityId });
@@ -17036,16 +16706,10 @@ class HouseplanCard extends LitElement {
count: targets.length,
names: targets.map((target) => `${target.name} (${target.entityId})`).join(', '),
}),
currentNext: (target, next) => target.via === 'virtual-light'
? this._t('marker.virtual_light_current', {
state: this._t(target.state === 'on'
? 'marker.virtual_light_state_on' : 'marker.virtual_light_state_off'),
effect: effect(next),
})
: this._t('marker.toggle_hint_current', {
state: this._toggleStateText(target.entityId, target.state),
effect: effect(next),
}),
currentNext: (target, next) => this._t('marker.toggle_hint_current', {
state: this._toggleStateText(target.entityId, target.state),
effect: effect(next),
}),
groupCurrentNext: (targets, next) => this._t('marker.toggle_hint_group_current', {
on: targets.filter((target) => target.state === 'on').length,
count: targets.length,
@@ -17122,7 +16786,7 @@ class HouseplanCard extends LitElement {
const preview = { ...device, hidden: false };
return selectSpatialGlowSource(resolvedLightSources(this._planHass, [
...this._devices.filter((item) => item.id !== preview.id), preview,
], null, this._virtualLights).filter((source) => source.device.id === preview.id));
]).filter((source) => source.device.id === preview.id));
}
private _markerAutoHasSpatialSource(d: NonNullable<HouseplanCard['_markerDialog']>): boolean {
-5
View File
@@ -627,10 +627,6 @@
"marker.tap_confirm": "Ask for confirmation",
"marker.tap_confirm_tip": "Show a confirmation dialog before acting — a guard against accidental taps.",
"marker.toggle_hint_single": "Target: {name} ({id}).",
"marker.virtual_light_target": "Virtual light: {name}.",
"marker.virtual_light_current": "Manual state: {state} → {effect}.",
"marker.virtual_light_state_on": "on",
"marker.virtual_light_state_off": "off",
"marker.toggle_hint_group": "Will toggle {count} source(s): {names}.",
"marker.toggle_hint_current": "Now: {state} → {effect}.",
"marker.toggle_hint_group_current": "Currently on: {on} of {count} → {effect}.",
@@ -663,7 +659,6 @@
"toast.run_target_missing": "Run target not found — check the device settings",
"toast.run_target_required": "Pick an automation, script or scene",
"toast.tap_target_changed": "The action target changed. Try again.",
"toast.virtual_light_toggle_failed": "Could not toggle the virtual light: {err}",
"toast.value_badge_source_required": "Choose a value for the badge",
"btn.run": "Run",
"vac.section": "Robot vacuum: live position",
-5
View File
@@ -627,10 +627,6 @@
"marker.tap_confirm": "Спрашивать подтверждение",
"marker.tap_confirm_tip": "Перед выполнением показать диалог подтверждения — защита от случайных нажатий.",
"marker.toggle_hint_single": "Цель: {name} ({id}).",
"marker.virtual_light_target": "Виртуальный свет: {name}.",
"marker.virtual_light_current": "Ручное состояние: {state} → {effect}.",
"marker.virtual_light_state_on": "включён",
"marker.virtual_light_state_off": "выключен",
"marker.toggle_hint_group": "Переключатся источники ({count}): {names}.",
"marker.toggle_hint_current": "Сейчас: {state} → {effect}.",
"marker.toggle_hint_group_current": "Сейчас включено {on} из {count} → {effect}.",
@@ -663,7 +659,6 @@
"toast.run_target_missing": "Цель запуска не найдена — проверьте настройки устройства",
"toast.run_target_required": "Выберите автоматизацию, скрипт или сцену",
"toast.tap_target_changed": "Цель действия изменилась. Повторите попытку.",
"toast.virtual_light_toggle_failed": "Не удалось переключить виртуальный свет: {err}",
"toast.value_badge_source_required": "Выберите значение для бейджа",
"btn.run": "Выполнить",
"vac.section": "Робот-пылесос: живая позиция",
-49
View File
@@ -1,49 +0,0 @@
export type InitialSpaceSource = 'hash' | 'current' | 'saved' | 'default' | 'first' | 'none';
export interface InitialSpaceSelectionInput {
spaceIds: readonly string[];
hashSpace?: string | null;
acceptHash?: boolean;
currentSpace?: string | null;
preserveCurrent?: boolean;
savedSpace?: string | null;
defaultSpace?: string | null;
}
export interface InitialSpaceSelection {
id: string | null;
source: InitialSpaceSource;
}
/**
* Resolve the one exact space that may back a spatial frame.
*
* A same-route warm/hash/nav selection has already recorded newer intent and
* may be preserved by the caller. Otherwise the public cold-start precedence
* is hash -> saved -> default -> first. Every candidate is checked against the
* live model; the legacy `f1` field is never accepted merely because it is the
* class initializer.
*/
export function resolveInitialSpace(input: InitialSpaceSelectionInput): InitialSpaceSelection {
const ids = new Set(input.spaceIds.filter((id) => !!id));
if (!ids.size) return { id: null, source: 'none' };
const candidates: Array<[InitialSpaceSource, string | null | undefined]> = [
['hash', input.acceptHash === false ? null : input.hashSpace],
['current', input.preserveCurrent ? input.currentSpace : null],
['saved', input.savedSpace],
['default', input.defaultSpace],
['first', input.spaceIds[0]],
];
for (const [source, id] of candidates) {
if (id && ids.has(id)) return { id, source };
}
return { id: null, source: 'none' };
}
/** Start every optional operation and wait only for their individual result. */
export function settleBestEffort<T>(
attempts: ReadonlyArray<() => Promise<T>>,
): Promise<PromiseSettledResult<T>[]> {
return Promise.allSettled(attempts.map((attempt) => Promise.resolve().then(attempt)));
}
-214
View File
@@ -1,214 +0,0 @@
import {
ISO_CAMERA, ISO_WALL_HEIGHT, projectPlanPoint,
type IsoCamera, type PlanPoint, type ScenePoint,
} from './iso-projection';
export type IsoOpeningType = 'door' | 'window' | 'gate';
export interface IsoOpeningFace {
ox: number;
oy: number;
side: -1 | 1;
}
export interface IsoOpeningInput {
id: string;
sourceIndex: number;
type: IsoOpeningType;
x: number;
y: number;
angle: number;
length: number;
flipH: boolean;
flipV: boolean;
face: IsoOpeningFace;
}
export interface IsoOpeningLeafBasis {
leaf: number;
hinge: PlanPoint;
closedVector: PlanPoint;
quarterVector: PlanPoint;
turnDeg: number;
bottom: number;
top: number;
}
/** Immutable jamb/axis topology stored in the structural Iso LRU. */
export interface IsoOpeningBasis {
id: string;
sourceIndex: number;
type: IsoOpeningType;
leaves: readonly IsoOpeningLeafBasis[];
}
export interface IsoOpeningPanel {
id: string;
sourceIndex: number;
type: IsoOpeningType;
leaf: number;
d: string;
shadowD: string;
depth: number;
}
export interface IsoOpeningBounds { x: number; y: number; w: number; h: number; }
const finite = (values: readonly number[]): boolean => values.every(Number.isFinite);
function add(a: PlanPoint, b: PlanPoint): PlanPoint {
return [a[0] + b[0], a[1] + b[1]];
}
function transformVector(
point: PlanPoint, angleDeg: number, sx: number, sy: number,
): PlanPoint {
const x = point[0] * sx, y = point[1] * sy;
const angle = angleDeg * Math.PI / 180;
return [x * Math.cos(angle) - y * Math.sin(angle),
x * Math.sin(angle) + y * Math.cos(angle)];
}
function leafBasis(
input: IsoOpeningInput,
leaf: number,
localHinge: PlanPoint,
localVector: PlanPoint,
turnDeg: number,
bottom: number,
top: number,
): IsoOpeningLeafBasis {
const sx = input.flipH ? -1 : 1;
const sy = input.flipV ? -1 : 1;
const origin: PlanPoint = [input.x + input.face.ox, input.y + input.face.oy];
const hinge = add(origin, transformVector(localHinge, input.angle, sx, sy));
const closedVector = transformVector(localVector, input.angle, sx, sy);
const quarterVector = transformVector(
[-localVector[1], localVector[0]], input.angle, sx, sy,
);
return { leaf, hinge, closedVector, quarterVector, turnDeg, bottom, top };
}
/**
* Build only stable opening topology. The transform is algebraically the same
* as the floor symbol's translate/rotate/flip nesting, including the gate's
* 0..10 degree exterior-face convention.
*/
export function buildIsoOpeningBasis(
input: IsoOpeningInput,
wallHeight = ISO_WALL_HEIGHT,
): IsoOpeningBasis {
if (!finite([
input.x, input.y, input.angle, input.length,
input.face.ox, input.face.oy, wallHeight,
]) || !(input.length > 0) || !(wallHeight > 0)) {
throw new Error('invalid isometric opening input');
}
const half = input.length / 2;
let leaves: IsoOpeningLeafBasis[];
if (input.type === 'gate') {
const sy = input.flipV ? -1 : 1;
const turn = input.face.side * sy * 10;
leaves = [
leafBasis(input, 0, [-half, 0], [half, 0], turn, 0, wallHeight * 0.88),
leafBasis(input, 1, [half, 0], [-half, 0], -turn, 0, wallHeight * 0.88),
];
} else if (input.type === 'window') {
leaves = [
leafBasis(input, 0, [-half, 0], [half, 0], -90, wallHeight * 0.27, wallHeight * 0.78),
leafBasis(input, 1, [half, 0], [-half, 0], 90, wallHeight * 0.27, wallHeight * 0.78),
];
} else {
leaves = [leafBasis(input, 0, [-half, 0], [input.length, 0], -90, 0, wallHeight * 0.92)];
}
return {
id: input.id,
sourceIndex: input.sourceIndex,
type: input.type,
leaves: Object.freeze(leaves.map((leaf) => Object.freeze(leaf))),
};
}
const pointText = (point: ScenePoint): string =>
`${Number(point[0].toFixed(4))} ${Number(point[1].toFixed(4))}`;
function liveTip(leaf: IsoOpeningLeafBasis, amount: number): PlanPoint {
const angle = leaf.turnDeg * amount * Math.PI / 180;
return [
leaf.hinge[0] + leaf.closedVector[0] * Math.cos(angle)
+ leaf.quarterVector[0] * Math.sin(angle),
leaf.hinge[1] + leaf.closedVector[1] * Math.cos(angle)
+ leaf.quarterVector[1] * Math.sin(angle),
];
}
/** Apply live state after the structural cache: O(leaves), no topology work. */
export function projectIsoOpening(
basis: IsoOpeningBasis,
amount: number,
camera: IsoCamera = ISO_CAMERA,
): IsoOpeningPanel[] {
const liveAmount = Math.max(0, Math.min(1, Number.isFinite(amount) ? amount : 0));
return basis.leaves.map((leaf) => {
const tip = liveTip(leaf, liveAmount);
const floorHinge = projectPlanPoint(leaf.hinge, leaf.bottom, camera);
const floorTip = projectPlanPoint(tip, leaf.bottom, camera);
const topTip = projectPlanPoint(tip, leaf.top, camera);
const topHinge = projectPlanPoint(leaf.hinge, leaf.top, camera);
return {
id: basis.id,
sourceIndex: basis.sourceIndex,
type: basis.type,
leaf: leaf.leaf,
d: `M ${pointText(floorHinge)} L ${pointText(floorTip)} L ${pointText(topTip)} L ${pointText(topHinge)} Z`,
shadowD: `M ${pointText(projectPlanPoint(leaf.hinge, 0, camera))} L ${pointText(projectPlanPoint(tip, 0, camera))}`,
depth: Math.max(floorHinge[1], floorTip[1]),
};
});
}
/** State-independent plan envelope for fit/home; blur is intentionally absent. */
export function isoOpeningBounds(
bases: readonly IsoOpeningBasis[],
): IsoOpeningBounds | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const basis of bases) {
for (const leaf of basis.leaves) {
const radius = Math.hypot(leaf.closedVector[0], leaf.closedVector[1]);
minX = Math.min(minX, leaf.hinge[0] - radius);
minY = Math.min(minY, leaf.hinge[1] - radius);
maxX = Math.max(maxX, leaf.hinge[0] + radius);
maxY = Math.max(maxY, leaf.hinge[1] + radius);
}
}
return finite([minX, minY, maxX, maxY])
? { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
: null;
}
export interface IsoDecorationInput {
showBorders: boolean;
hideOpenings: boolean;
filtersSupported: boolean;
forcedColors: boolean;
}
export interface IsoDecorationLayers {
structural: boolean;
panels: boolean;
shadows: boolean;
materialNuance: boolean;
floorSymbols: boolean;
}
/** Display/capability policy is pure so decoration can never trigger Flat fallback. */
export function resolveIsoDecoration(input: IsoDecorationInput): IsoDecorationLayers {
const structural = !!input.showBorders;
return {
structural,
panels: structural && !input.hideOpenings,
shadows: structural && input.filtersSupported && !input.forcedColors,
materialNuance: structural && !input.forcedColors,
floorSymbols: !input.hideOpenings && !structural,
};
}
+3 -10
View File
@@ -14,8 +14,6 @@ export interface ViewRect { x: number; y: number; w: number; h: number; }
export interface IsoFrameInput {
rect: ViewRect;
wallHeight: number;
openingHeight?: number;
floorDepth?: number;
}
export const ISO_CAMERA: Readonly<IsoCamera> = Object.freeze({
@@ -27,7 +25,6 @@ export const ISO_CAMERA: Readonly<IsoCamera> = Object.freeze({
});
export const ISO_WALL_HEIGHT = 64;
export const ISO_FLOOR_EDGE_HEIGHT = 10;
function finiteCamera(camera: IsoCamera): boolean {
return [camera.rotDeg, camera.tiltDeg, camera.xyScale, camera.zScale,
@@ -100,21 +97,17 @@ export function projectedFrame(
input: IsoFrameInput, camera: IsoCamera = ISO_CAMERA,
): ViewRect {
const { rect, wallHeight } = input;
const openingHeight = input.openingHeight ?? wallHeight;
const floorDepth = input.floorDepth ?? 0;
if (!(rect.w >= 0) || !(rect.h >= 0) || !Number.isFinite(wallHeight)
|| !Number.isFinite(openingHeight) || !Number.isFinite(floorDepth)
|| wallHeight < 0 || openingHeight < 0 || floorDepth < 0)
if (!(rect.w >= 0) || !(rect.h >= 0) || !Number.isFinite(wallHeight))
throw new Error('invalid isometric frame');
const corners: PlanPoint[] = [
[rect.x, rect.y], [rect.x + rect.w, rect.y],
[rect.x + rect.w, rect.y + rect.h], [rect.x, rect.y + rect.h],
];
const top = Math.max(wallHeight, openingHeight);
const points = corners.flatMap((point) => [
projectPlanPoint(point, -floorDepth, camera), projectPlanPoint(point, top, camera),
projectPlanPoint(point, 0, camera), projectPlanPoint(point, wallHeight, camera),
]);
const xs = points.map((point) => point[0]), ys = points.map((point) => point[1]);
const x = Math.min(...xs), y = Math.min(...ys);
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
}
+2 -88
View File
@@ -14,22 +14,6 @@ export interface IsoWallFace {
export interface IsoWallGeometry {
topPath: string;
sides: readonly IsoWallFace[];
contactPath: string;
edgeCount: number;
}
export interface IsoFloorFace {
d: string;
depth: number;
component: number;
edge: number;
planEdge: readonly [PlanPoint, PlanPoint];
}
export interface IsoFloorGeometry {
footprintPath: string;
sides: readonly IsoFloorFace[];
componentCount: number;
edgeCount: number;
}
@@ -69,19 +53,6 @@ function normalizedRing(raw: any, hole: boolean): PlanPoint[] {
return (signedArea(points) > 0) === wantPositive ? points : [...points].reverse();
}
function canonicalRing(raw: any, hole: boolean): PlanPoint[] {
const points = normalizedRing(raw, hole);
if (!points.length) return points;
let start = 0;
for (let index = 1; index < points.length; index++) {
if (points[index][0] < points[start][0]
|| (points[index][0] === points[start][0] && points[index][1] < points[start][1])) {
start = index;
}
}
return [...points.slice(start), ...points.slice(0, start)];
}
function topRingPath(points: readonly PlanPoint[], camera: IsoCamera, height: number): string {
return points.length
? `M ${points.map((point) => pointText(projectPlanPoint(point, height, camera))).join(' L ')} Z`
@@ -104,7 +75,6 @@ export function buildIsoWallGeometry(
if (!Number.isFinite(height) || height < 0) throw new Error('invalid wall height');
const tops: string[] = [];
const sides: IsoWallFace[] = [];
const contacts: string[] = [];
let edgeCount = 0;
for (let polygon = 0; polygon < (geometry || []).length; polygon++) {
const source = geometry[polygon];
@@ -115,10 +85,9 @@ export function buildIsoWallGeometry(
edgeCount += points.length;
for (let edge = 0; edge < points.length; edge++) {
const a = points[edge], b = points[(edge + 1) % points.length];
if (visibleNormalY(a, b, camera) <= 1e-9) continue;
const floorA = projectPlanPoint(a, 0, camera);
const floorB = projectPlanPoint(b, 0, camera);
contacts.push(`M ${pointText(floorA)} L ${pointText(floorB)}`);
if (visibleNormalY(a, b, camera) <= 1e-9) continue;
const topB = projectPlanPoint(b, height, camera);
const topA = projectPlanPoint(a, height, camera);
sides.push({
@@ -130,62 +99,7 @@ export function buildIsoWallGeometry(
}
sides.sort((a, b) => a.depth - b.depth || a.polygon - b.polygon
|| a.ring - b.ring || a.edge - b.edge);
return { topPath: tops.join(' '), sides, contactPath: contacts.join(' '), edgeCount };
}
/**
* Project the already-unioned canonical room/exterior footprint. Only outer
* rings receive a low edge; holes and nested rooms therefore cannot become
* decorative steps. Polygon order and ring start points are canonicalised.
*/
export function buildIsoFloorGeometry(
geometry: any,
edgeHeight: number,
camera: IsoCamera = ISO_CAMERA,
): IsoFloorGeometry {
if (!Number.isFinite(edgeHeight) || edgeHeight < 0) throw new Error('invalid floor edge height');
const components = (geometry || []).map((source: any) => {
const rings = (source || []).map((ring: any, index: number) => canonicalRing(ring, index > 0))
.filter((ring: PlanPoint[]) => ring.length >= 3 && Math.abs(signedArea(ring)) >= 1e-9);
return rings;
}).filter((rings: PlanPoint[][]) => rings.length && signedArea(rings[0]) > 0);
components.sort((a: PlanPoint[][], b: PlanPoint[][]) => {
const aa = a[0][0], bb = b[0][0];
return aa[0] - bb[0] || aa[1] - bb[1]
|| Math.abs(signedArea(b[0])) - Math.abs(signedArea(a[0]));
});
const footprints: string[] = [];
const sides: IsoFloorFace[] = [];
let edgeCount = 0;
for (let component = 0; component < components.length; component++) {
const rings = components[component];
for (const ring of rings) footprints.push(topRingPath(ring, camera, 0));
const outer = rings[0];
edgeCount += outer.length;
for (let edge = 0; edge < outer.length; edge++) {
const a = outer[edge], b = outer[(edge + 1) % outer.length];
if (visibleNormalY(a, b, camera) <= 1e-9) continue;
const floorA = projectPlanPoint(a, 0, camera);
const floorB = projectPlanPoint(b, 0, camera);
const lowB = projectPlanPoint(b, -edgeHeight, camera);
const lowA = projectPlanPoint(a, -edgeHeight, camera);
sides.push({
d: `M ${pointText(floorA)} L ${pointText(floorB)} L ${pointText(lowB)} L ${pointText(lowA)} Z`,
depth: Math.max(lowA[1], lowB[1]),
component,
edge,
planEdge: [a, b],
});
}
}
sides.sort((a, b) => a.depth - b.depth || a.component - b.component || a.edge - b.edge);
return {
footprintPath: footprints.join(' '),
sides,
componentCount: components.length,
edgeCount,
};
return { topPath: tops.join(' '), sides, edgeCount };
}
function mixHash(hash: number, value: number): number {
+4 -18
View File
@@ -93,7 +93,6 @@ class HouseplanSpaceCard extends LitElement {
private _capturedSnapshotSequence = -1;
private _capturedSnapshotDevices: DevItem[] | null = null;
private _capturedSnapshotActivity = '';
private _capturedSnapshotVirtual = '';
private _activityRuntime = new Map<string, FiniteActivityRuntime>();
private _reducedMotion = false;
private _motionMedia?: MediaQueryList;
@@ -305,9 +304,7 @@ class HouseplanSpaceCard extends LitElement {
return;
}
const live = new Set<string>();
const planLightSources = resolvedLightSources(
planHass, devices, null, this._snap?.virtualLights,
);
const planLightSources = resolvedLightSources(planHass, devices);
for (const device of devices) {
if (device.hidden) continue;
// Finite edge history belongs only to the one mode that can present it.
@@ -357,12 +354,9 @@ class HouseplanSpaceCard extends LitElement {
.map(([id, runtime]) => `${id}:${runtime.gen}:${runtime.flashTs}:`
+ `${runtime.flashKind && (runtime.expiresAt || runtime.flashTs + 3300) > now ? 1 : 0}`)
.join('|');
const virtualFingerprint = this._snap?.virtualLights
? `${this._snap.virtualLights.configRev}:${this._snap.virtualLights.rev}` : '';
if (this._capturedSnapshotSequence === this._hassSequence
&& this._capturedSnapshotDevices === this._devices
&& this._capturedSnapshotActivity === activity
&& this._capturedSnapshotVirtual === virtualFingerprint) return;
&& this._capturedSnapshotActivity === activity) return;
const planHass = activeRegistryHass(this.hass, haRegistrySnapshot(this.hass));
const presentations = new Map<string, ReturnType<typeof resolveDevicePresentation>>();
const entityIds = new Set<string>(['sun.sun']);
@@ -388,9 +382,7 @@ class HouseplanSpaceCard extends LitElement {
if (opening.contact) entityIds.add(opening.contact);
if (opening.lock) entityIds.add(opening.lock);
}
const planLightSources = resolvedLightSources(
planHass, this._devices, null, this._snap?.virtualLights,
);
const planLightSources = resolvedLightSources(planHass, this._devices);
for (const device of this._devices) {
for (const showLqi of [false, true]) {
presentations.set(presentationSnapshotKey(device.id, showLqi), resolveDevicePresentation(
@@ -415,7 +407,6 @@ class HouseplanSpaceCard extends LitElement {
this._capturedSnapshotSequence = this._hassSequence;
this._capturedSnapshotDevices = this._devices;
this._capturedSnapshotActivity = activity;
this._capturedSnapshotVirtual = virtualFingerprint;
if (!this._visibleDeviceSnapshot || this._continuity.state === 'steady') {
this._visibleDeviceSnapshot = snapshot;
this._candidateDeviceSnapshot = null;
@@ -463,7 +454,6 @@ class HouseplanSpaceCard extends LitElement {
snap?.configFingerprint || contentFingerprint(snap?.config),
snap?.layoutRev || 0,
snap?.layoutFingerprint || contentFingerprint(snap?.layout),
snap?.virtualLights ? `${snap.virtualLights.configRev}:${snap.virtualLights.rev}` : '',
this._config?.space || '',
this._stageWidth,
this.hass?.themes?.darkMode ?? this.hass?.themes?.default_theme ?? '',
@@ -587,8 +577,6 @@ class HouseplanSpaceCard extends LitElement {
|| this._snap.configFingerprint !== snap.configFingerprint;
const layoutChanged = !this._snap
|| this._snap.layoutFingerprint !== snap.layoutFingerprint;
const virtualLightsChanged = !this._snap
|| this._snap.virtualLights !== snap.virtualLights;
if (configChanged && !await this._signer.prepareImage(
this.hass, this._candidateBackdrop(snap.config),
)) {
@@ -603,7 +591,7 @@ class HouseplanSpaceCard extends LitElement {
&& this._continuity.state === 'steady') {
this._beginContinuityCandidate('structural-response', true);
}
if (configChanged || layoutChanged || virtualLightsChanged) {
if (configChanged || layoutChanged) {
this._snap = snap;
} else if (this._snap) {
// Revision-only echoes are metadata, not a new visual candidate.
@@ -612,7 +600,6 @@ class HouseplanSpaceCard extends LitElement {
}
if (configChanged) this._continuity.note('config-candidate', { configRev: snap.rev });
if (layoutChanged) this._continuity.note('layout-candidate', { layoutRev: snap.layoutRev });
if (virtualLightsChanged) this._capturedSnapshotSequence = -1;
this._loadedOnce = true;
this._connectionWasLost = false;
this._continuityDataReady = true;
@@ -733,7 +720,6 @@ class HouseplanSpaceCard extends LitElement {
presentations: deviceSnapshot?.presentations,
activityRuntime: this._activityRuntime,
reducedMotion: this._reducedMotion,
virtualLights: this._snap?.virtualLights,
liveStates: this._config.live_states !== false,
showTemperature: this._config.show_temperature !== false,
showSignal: this._config.show_signal !== false,
+2 -4
View File
@@ -26,7 +26,6 @@ import { presentationSnapshotKey } from './render-device-snapshot';
import { deviceFaceStyle, renderDeviceFace } from './device-face';
import { valueBadgeTitle } from './device-value-badge';
import { contentFingerprint } from './visual-continuity';
import type { VirtualLightSnapshot } from './virtual-light-state';
import {
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,
GRID_STEP_N, GRID_PITCH,
@@ -76,7 +75,6 @@ export interface StaticRenderOpts {
showTemperature?: boolean;
showSignal?: boolean;
reducedMotion?: boolean;
virtualLights?: VirtualLightSnapshot | null;
/**
* Resolve a stored content url to what the DOM may actually request — the
* plan lives behind `requires_auth`, so it needs an `authSig` signature.
@@ -193,7 +191,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
fill,
fill === 'lqi' && room.area ? areaLqi(planHass, spaceDevs, room.area) : null,
fill === 'light'
? resolvedLightState(resolvedLightSources(planHass, spaceDevs, room, o.virtualLights))
? resolvedLightState(resolvedLightSources(planHass, spaceDevs, room))
: 'none',
fill === 'temp' && room.area ? areaTemp(planHass, spaceDevs, room.area) : null,
disp.tempMin,
@@ -262,7 +260,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
})
: [];
const planLightSources = resolvedLightSources(planHass, devs, null, o.virtualLights);
const planLightSources = resolvedLightSources(planHass, devs);
const markers = devs.map((d) => {
const p = markerPos(d, o.layout, o.cfg, defPos, space);
const left = ((p.x - vb[0]) / vb[2]) * 100;
+4 -76
View File
@@ -469,97 +469,25 @@ export const cardStyles = css`
height: 100%;
display: block;
}
.plan-svg { z-index: 1; }
.iso-underlay-svg { z-index: 0; overflow: visible; }
.iso-shadows-svg { z-index: 3; overflow: visible; }
.iso-walls-svg {
z-index: 4;
overflow: visible;
}
.iso-underlay-svg,
.iso-shadows-svg,
.iso-walls-svg,
.iso-underlay,
.iso-shadows,
.iso-walls,
.iso-openings {
pointer-events: none;
}
.iso-side-hi { stop-color: #b9bdbe; }
.iso-side-lo { stop-color: #969c9f; }
.iso-top-hi { stop-color: #fafaf7; }
.iso-top-lo { stop-color: #e2e4e2; }
.iso-wall-side {
fill: url(#hp-iso-wall-side) #a8acae;
fill: #a8acae;
stroke: #92989b;
stroke-width: 0.7;
vector-effect: non-scaling-stroke;
}
.iso-wall-top {
fill: url(#hp-iso-wall-top) #f3f3f1;
fill: #f3f3f1;
stroke: #d7d9d8;
stroke-width: 0.8;
vector-effect: non-scaling-stroke;
}
.iso-floor-side {
fill: #858b8d;
stroke: #71787b;
stroke-width: 0.7;
vector-effect: non-scaling-stroke;
}
.iso-opening-panel {
fill: #d7d9d7;
fill-opacity: 0.96;
stroke: #7f878b;
stroke-width: 0.9;
vector-effect: non-scaling-stroke;
}
.iso-opening-panel.iso-window {
fill: #dfeff4;
fill-opacity: 0.72;
stroke: #8aa7b1;
}
.iso-ambient-shadow {
fill: rgba(15, 21, 25, 0.22);
filter: url(#hp-iso-ambient-shadow);
}
.iso-contact-shadow {
fill: none;
stroke: rgba(22, 28, 31, 0.25);
stroke-width: 3;
filter: url(#hp-iso-contact-shadow);
vector-effect: non-scaling-stroke;
}
.iso-leaf-shadow {
fill: none;
stroke: rgba(18, 23, 27, 0.24);
stroke-width: 4;
filter: url(#hp-iso-leaf-shadow);
vector-effect: non-scaling-stroke;
}
@media (prefers-color-scheme: dark) {
.iso-side-hi { stop-color: #4c555a; }
.iso-side-lo { stop-color: #343c40; }
.iso-top-hi { stop-color: #687176; }
.iso-top-lo { stop-color: #50585d; }
.iso-wall-side { stroke: #30373b; }
.iso-wall-top { stroke: #7b858a; }
.iso-floor-side { fill: #2d3438; stroke: #20272a; }
.iso-opening-panel { fill: #626b70; stroke: #899399; }
.iso-opening-panel.iso-window { fill: #75919b; stroke: #abc6ce; }
.iso-ambient-shadow { fill: rgba(0, 0, 0, 0.34); }
.iso-contact-shadow, .iso-leaf-shadow { stroke: rgba(0, 0, 0, 0.38); }
}
@media (forced-colors: active) {
.iso-wall-side, .iso-wall-top, .iso-floor-side, .iso-opening-panel {
fill: Canvas;
stroke: CanvasText;
forced-color-adjust: auto;
}
.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow { display: none; }
}
@supports not (filter: blur(1px)) {
.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow { display: none; }
.iso-wall-side { fill: #3f474c; stroke: #333a3e; }
.iso-wall-top { fill: #596166; stroke: #747d82; }
}
/* Opaque plan paper (owner 2026-08-03): the scene bg_color / daynight sky
shows ONLY around the plan, never through it. The colour is the
-135
View File
@@ -1,135 +0,0 @@
/** Runtime-only persistent state for manually toggled virtual lights (#107). */
export interface VirtualLightSnapshot {
rev: number;
configRev: number;
off: ReadonlySet<string>;
}
export interface VirtualLightWireSnapshot {
rev?: unknown;
config_rev?: unknown;
off?: unknown;
}
export interface VirtualLightEvent {
marker_id?: unknown;
on?: unknown;
rev?: unknown;
}
const nonNegative = (value: unknown, fallback = 0): number => {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
};
const normalizedSnapshot = (
raw: VirtualLightWireSnapshot | null | undefined,
configRev: number,
): { snapshot: VirtualLightSnapshot; valid: boolean } => {
const fallback: VirtualLightSnapshot = {
rev: 0,
configRev: nonNegative(configRev),
off: new Set(),
};
if (raw == null) return { snapshot: fallback, valid: false };
const off = raw.off;
if (!Number.isInteger(raw.rev) || Number(raw.rev) < 0
|| !Number.isInteger(raw.config_rev) || Number(raw.config_rev) < 0
|| !Array.isArray(off)
|| off.some((id) => typeof id !== 'string' || !id)) {
return { snapshot: fallback, valid: false };
}
return {
snapshot: {
rev: Number(raw.rev),
configRev: Number(raw.config_rev),
off: new Set(off as string[]),
},
valid: true,
};
};
export function virtualLightSnapshot(
raw: VirtualLightWireSnapshot | null | undefined,
configRev = 0,
): VirtualLightSnapshot {
return normalizedSnapshot(raw, configRev).snapshot;
}
/** Adopt a config/get result without letting an older response undo a live event. */
export function adoptVirtualLightServerSnapshot(
current: VirtualLightSnapshot,
raw: VirtualLightWireSnapshot | null | undefined,
configRev: number,
fieldPresent: boolean,
): VirtualLightSnapshot {
const incoming = normalizedSnapshot(raw, configRev);
if (!fieldPresent || !incoming.valid || incoming.snapshot.configRev !== configRev) {
return virtualLightSnapshot(null, configRev);
}
if (current.configRev === configRev && incoming.snapshot.rev < current.rev) return current;
return incoming.snapshot;
}
export function virtualLightWire(snapshot: VirtualLightSnapshot): Required<VirtualLightWireSnapshot> {
return {
rev: snapshot.rev,
config_rev: snapshot.configRev,
off: [...snapshot.off].sort(),
};
}
export function virtualLightFingerprint(snapshot: VirtualLightSnapshot | null | undefined): string {
if (!snapshot) return '0:0:';
return `${snapshot.configRev}:${snapshot.rev}:${[...snapshot.off].sort().join(',')}`;
}
export function isManualVirtualLightMarker(marker: any): boolean {
return !!marker
&& typeof marker.id === 'string'
&& !!marker.id
&& marker.binding === 'virtual'
&& marker.is_light === true
&& marker.tap_action === 'toggle'
&& marker.removed !== true;
}
export function virtualLightIsOn(
marker: any,
snapshot: VirtualLightSnapshot | null | undefined,
): boolean {
return !isManualVirtualLightMarker(marker) || !snapshot?.off.has(marker.id);
}
/** Apply an event/reply only when it advances the current durable revision. */
export function applyVirtualLightEvent(
current: VirtualLightSnapshot,
event: VirtualLightEvent | null | undefined,
): VirtualLightSnapshot {
const markerId = typeof event?.marker_id === 'string' ? event.marker_id : '';
const rev = nonNegative(event?.rev, -1);
if (!markerId || typeof event?.on !== 'boolean' || rev <= current.rev) return current;
const off = new Set(current.off);
if (event.on) off.delete(markerId);
else off.add(markerId);
return { ...current, rev, off };
}
/** Carry state across a configuration revision accepted by this client. */
export function reconcileVirtualLightSnapshot(
current: VirtualLightSnapshot,
config: any,
configRev: number,
): VirtualLightSnapshot {
if (current.configRev === configRev) return current;
const eligible = new Set(
(Array.isArray(config?.markers) ? config.markers : [])
.filter(isManualVirtualLightMarker)
.map((marker: any) => marker.id),
);
return {
...current,
configRev,
off: new Set([...current.off].filter((id) => eligible.has(id))),
};
}
+2 -30
View File
@@ -1313,34 +1313,6 @@ function exteriorEnvelopeGeometry(
return { centre, shell: shell || [] };
}
/**
* Canonical Stage floor footprint: room union plus derived exterior masonry.
* Independent partitions/columns are deliberately not accepted here, so they
* can never enlarge the slab perimeter. Null is a boolean failure; an empty
* array is a valid space without room geometry.
*/
export function floorFootprintGeometry(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): any | null {
try {
const exterior = exteriorEnvelopeGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
if (!exterior) return [];
return exterior.shell?.length
? union(exterior.centre, exterior.shell)
: exterior.centre;
} catch {
return null;
}
}
function polyclipToPathD(geom: any): string {
if (!geom) return '';
let d = '';
@@ -1503,7 +1475,7 @@ export function wallBodiesGeometry(
gridPitch: number,
coordScale = 1,
extraBodies: number[][][] = [],
): { geom: any; paperGeom: any; depthUnits: number; openingIndex: OpeningWallIndex | null } | null {
): { geom: any; paperGeom: any; depthUnits: number } | null {
if (!walls?.length && !extraBodies.length) return null;
const roomRings: { outset: number[][]; inset: number[][] | null }[] = [];
let maxDepth = 0;
@@ -1611,7 +1583,7 @@ export function wallBodiesGeometry(
if (extra.length < 3) continue;
body = body ? union(body, closedRing(extra) as any) : [closedRing(extra)];
}
return { geom: body || [], paperGeom, depthUnits: maxDepth, openingIndex };
return { geom: body || [], paperGeom, depthUnits: maxDepth };
} catch {
return null;
}
-44
View File
@@ -81,50 +81,6 @@ test('passive source and its controller share the plan-wide derived state', () =
assert.equal(switchPresentation.visualSources[0].sample.status, 'working');
});
test('issue 107 manual virtual source owns its face despite saved outgoing controls', () => {
const h = hass({
'light.ceiling': state('light.ceiling', 'on'),
});
const lamp = device({
id: 'manual-lamp',
name: 'Manual lamp',
entities: [],
primary: null,
bindingKind: 'virtual',
bindingRef: 'manual-lamp',
marker: {
id: 'manual-lamp',
binding: 'virtual',
is_light: true,
tap_action: 'toggle',
controls: ['light.ceiling'],
},
});
const offState = { rev: 2, configRev: 1, off: new Set(['manual-lamp']) };
const offSources = resolvedLightSources(h, [lamp], null, offState);
const off = resolveDevicePresentation(h, lamp, {
...options,
lightDevices: [lamp],
lightSources: offSources,
});
assert.equal(off.sourceKind, 'light');
assert.deepEqual(off.visualSources.map((source) => source.eid), ['marker:manual-lamp']);
assert.equal(off.visual.status, 'neutral');
assert.ok(!off.classes.includes('on'));
const onSources = resolvedLightSources(
h, [lamp], null, { rev: 3, configRev: 1, off: new Set() },
);
const on = resolveDevicePresentation(h, lamp, {
...options,
lightDevices: [lamp],
lightSources: onSources,
});
assert.equal(on.sourceKind, 'light');
assert.equal(on.visual.status, 'working');
assert.ok(on.classes.includes('on'));
});
test('passive sensor source keeps its normal scalar value and never probes marker ids', () => {
const hits = [];
const h = hass(new Proxy({
-37
View File
@@ -4,8 +4,6 @@ import {
projectedTapAction,
resolveToggleIntent,
sameToggleCommandTargets,
sameToggleOperationTargets,
toggleOperation,
toggleCommandEntityIds,
toggleCoverEntity,
toggleOriginOf,
@@ -70,41 +68,6 @@ test('virtual marker without controls is a saved, quiet no-op', () => {
assert.equal(intent.command, null);
});
test('issue 107: exact manual virtual light wins over saved HA controls', () => {
const d = device({
marker: {
id: 'marker', binding: 'virtual', is_light: true, tap_action: 'toggle',
controls: ['light.saved'],
},
controls: ['light.saved'],
});
const h = hass({ 'light.saved': state('light.saved', 'on') });
const on = resolveToggleIntent({
hass: h, devices: [d], device: d,
virtualLights: { rev: 4, configRev: 9, off: new Set() },
});
assert.equal(on.command, null, 'manual mode must not call the saved HA target');
assert.deepEqual(toggleOperation(on), { kind: 'virtual-light', markerId: 'marker' });
assert.equal(on.targets[0].via, 'virtual-light');
assert.equal(on.targets[0].state, 'on');
assert.equal(on.nextEffect, 'turn-off');
const off = resolveToggleIntent({
hass: h, devices: [d], device: d,
virtualLights: { rev: 5, configRev: 9, off: new Set(['marker']) },
});
assert.equal(off.targets[0].state, 'off');
assert.equal(off.nextEffect, 'turn-on');
assert.equal(sameToggleOperationTargets(on, off), true, 'direction may change, target may not');
const resumed = resolveToggleIntent({
hass: h,
devices: [{ ...d, marker: { ...d.marker, is_light: false } }],
device: { ...d, marker: { ...d.marker, is_light: false } },
});
assert.equal(toggleOperation(resumed).kind, 'ha-service', 'saved controls resume outside the triple');
});
test('exact entity binding never retargets to a controllable sibling', () => {
const h = hass({
'sensor.room': state('sensor.room', '21'),
-30
View File
@@ -1113,36 +1113,6 @@ test('issue 84: Always without an own entity is a constant passive source', () =
assert.deepEqual(resolvedLightStats([source]), { on: 1, total: 1 });
});
test('issue 107: manual virtual state is canonical and invalidates the light cache', () => {
const target = {
id: 'dumb', area: 'bedroom', entities: [],
marker: {
id: 'dumb', binding: 'virtual', is_light: true, tap_action: 'toggle', room_id: 'bed',
},
};
const controller = {
id: 'controller', area: 'hall', primary: 'switch.wall', entities: ['switch.wall'],
marker: {
id: 'controller', binding: 'entity:switch.wall', controls: ['marker:dumb'],
},
};
const devices = [controller, target];
const hass = { states: { 'switch.wall': { state: 'on' } } };
const onSnapshot = { rev: 1, configRev: 3, off: new Set() };
const offSnapshot = { rev: 2, configRev: 3, off: new Set(['dumb']) };
const first = resolvedLightSources(hass, devices, { id: 'bed', area: null }, onSnapshot);
assert.equal(first[0].on, true);
const second = resolvedLightSources(hass, devices, { id: 'bed', area: null }, offSnapshot);
assert.equal(second[0].on, false, 'manual off overrides an incoming on controller');
assert.notEqual(second, first, 'operational revision participates in resolver caching');
const legacy = resolvedLightSources(hass, [{
...target, marker: { ...target.marker, tap_action: 'info' },
}, controller], { id: 'bed', area: null }, offSnapshot);
assert.equal(legacy[0].on, true, 'leaving the exact triple restores issue #84 semantics');
});
test('issue 84: passive target state is OR of controller drivers and remains target-owned', () => {
const target = {
id: 'dumb', area: 'bedroom', entities: [],
-62
View File
@@ -1,62 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveInitialSpace, settleBestEffort } from '../test-build/initial-load.js';
test('initial space follows hash, saved, default and first live precedence', () => {
const base = { spaceIds: ['home', 'upstairs'] };
assert.deepEqual(resolveInitialSpace({
...base, hashSpace: 'upstairs', savedSpace: 'home', defaultSpace: 'home',
}), { id: 'upstairs', source: 'hash' });
assert.deepEqual(resolveInitialSpace({
...base, hashSpace: 'stale', savedSpace: 'upstairs', defaultSpace: 'home',
}), { id: 'upstairs', source: 'saved' });
assert.deepEqual(resolveInitialSpace({
...base, hashSpace: 'stale', savedSpace: 'gone', defaultSpace: 'upstairs',
}), { id: 'upstairs', source: 'default' });
assert.deepEqual(resolveInitialSpace({
...base, hashSpace: 'stale', savedSpace: 'gone', defaultSpace: 'missing',
}), { id: 'home', source: 'first' });
});
test('legacy current id is not a cold-start persistence source', () => {
assert.deepEqual(resolveInitialSpace({
spaceIds: ['home', 'f1'], currentSpace: 'f1', preserveCurrent: false,
}), { id: 'home', source: 'first' });
assert.deepEqual(resolveInitialSpace({
spaceIds: ['home', 'upstairs'], currentSpace: 'f1', preserveCurrent: true,
}), { id: 'home', source: 'first' });
});
test('a new explicit hash wins once, then adopted navigation is preserved', () => {
assert.deepEqual(resolveInitialSpace({
spaceIds: ['home', 'upstairs'], currentSpace: 'upstairs', preserveCurrent: true,
savedSpace: 'home', defaultSpace: 'home',
}), { id: 'upstairs', source: 'current' });
assert.deepEqual(resolveInitialSpace({
spaceIds: ['home', 'upstairs'], hashSpace: 'home', currentSpace: 'upstairs',
preserveCurrent: true, savedSpace: 'upstairs',
}), { id: 'home', source: 'hash' });
assert.deepEqual(resolveInitialSpace({
spaceIds: ['home', 'upstairs'], hashSpace: 'home', acceptHash: false,
currentSpace: 'upstairs', preserveCurrent: true, savedSpace: 'home',
}), { id: 'upstairs', source: 'current' });
});
test('empty model has no invented selection', () => {
assert.deepEqual(resolveInitialSpace({
spaceIds: [], hashSpace: 'home', savedSpace: 'home', defaultSpace: 'home',
}), { id: null, source: 'none' });
});
test('best-effort optional work starts every attempt and contains rejections', async () => {
const calls = [];
const results = await settleBestEffort([
async () => { calls.push('config'); throw new Error('unauthorized'); },
() => { calls.push('trail'); throw new Error('sync rejection'); },
async () => { calls.push('layout'); return 'subscribed'; },
]);
assert.deepEqual(calls, ['config', 'trail', 'layout']);
assert.deepEqual(results.map((result) => result.status), ['rejected', 'rejected', 'fulfilled']);
assert.equal(results[2].status === 'fulfilled' ? results[2].value : null, 'subscribed');
});
-87
View File
@@ -1,87 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildIsoOpeningBasis, isoOpeningBounds, projectIsoOpening, resolveIsoDecoration,
} from '../test-build/iso-openings.js';
import { openingAmount } from '../test-build/logic.js';
import { projectPlanPoint } from '../test-build/iso-projection.js';
const opening = (patch = {}) => ({
id: 'op-1', sourceIndex: 0, type: 'door', x: 100, y: 80,
angle: 0, length: 60, flipH: false, flipV: false,
face: { ox: 0, oy: 5, side: 1 }, ...patch,
});
test('door basis is immutable and live amount preserves the exact jamb anchor', () => {
const basis = buildIsoOpeningBasis(opening());
assert.equal(Object.isFrozen(basis.leaves), true);
const closed = projectIsoOpening(basis, 0)[0];
const open = projectIsoOpening(basis, 1)[0];
const hinge = projectPlanPoint(basis.leaves[0].hinge, basis.leaves[0].bottom)
.map((value) => Number(value.toFixed(4))).join(' ');
assert.match(closed.d, new RegExp(`^M ${hinge.replace('.', '\\.')}`));
assert.match(open.d, new RegExp(`^M ${hinge.replace('.', '\\.')}`));
assert.notEqual(open.d, closed.d);
assert.equal(basis.leaves[0].top, 64 * 0.92);
});
test('window and gate retain two leaves, fixed height bounds and gate 10 degree turn', () => {
const windowBasis = buildIsoOpeningBasis(opening({ type: 'window' }));
const gateBasis = buildIsoOpeningBasis(opening({ type: 'gate', flipV: true }));
assert.equal(windowBasis.leaves.length, 2);
assert.equal(windowBasis.leaves.every((leaf) => leaf.bottom === 64 * 0.27
&& leaf.top === 64 * 0.78), true);
assert.deepEqual(gateBasis.leaves.map((leaf) => Math.abs(leaf.turnDeg)), [10, 10]);
assert.equal(gateBasis.leaves.every((leaf) => leaf.top === 64 * 0.88), true);
assert.equal(projectIsoOpening(gateBasis, 1).length, 2);
});
test('state-independent opening bounds contain closed and open leaf tips', () => {
const basis = buildIsoOpeningBasis(opening());
const bounds = isoOpeningBounds([basis]);
assert.ok(bounds);
for (const amount of [0, 0.5, 1]) {
const panel = projectIsoOpening(basis, amount)[0];
assert.match(panel.d, /^M /);
}
const leaf = basis.leaves[0];
assert.ok(bounds.x <= leaf.hinge[0] - 60 && bounds.x + bounds.w >= leaf.hinge[0] + 60);
assert.ok(bounds.y <= leaf.hinge[1] - 60 && bounds.y + bounds.h >= leaf.hinge[1] + 60);
assert.equal(isoOpeningBounds([]), null);
});
test('flips move structural jamb/basis while HA state changes only projected leaves', () => {
const normal = buildIsoOpeningBasis(opening());
const flipped = buildIsoOpeningBasis(opening({ flipH: true, flipV: true }));
assert.notDeepEqual(flipped.leaves.map((leaf) => leaf.hinge),
normal.leaves.map((leaf) => leaf.hinge));
const basisSnapshot = structuredClone(normal);
const noContact = projectIsoOpening(normal, openingAmount('door', null));
const unavailable = projectIsoOpening(normal, openingAmount('door', 'unavailable'));
const closed = projectIsoOpening(normal, openingAmount('door', 'off'));
const inverted = projectIsoOpening(normal, openingAmount('door', 'off', true));
assert.deepEqual(unavailable, noContact);
assert.notDeepEqual(closed, inverted);
assert.deepEqual(normal, basisSnapshot);
});
test('decoration degradation never removes structure or creates floating panels', () => {
assert.deepEqual(resolveIsoDecoration({
showBorders: true, hideOpenings: false, filtersSupported: true, forcedColors: false,
}), {
structural: true, panels: true, shadows: true, materialNuance: true, floorSymbols: false,
});
assert.deepEqual(resolveIsoDecoration({
showBorders: true, hideOpenings: false, filtersSupported: false, forcedColors: false,
}), {
structural: true, panels: true, shadows: false, materialNuance: true, floorSymbols: false,
});
assert.deepEqual(resolveIsoDecoration({
showBorders: false, hideOpenings: false, filtersSupported: true, forcedColors: false,
}), {
structural: false, panels: false, shadows: false, materialNuance: false, floorSymbols: true,
});
assert.equal(resolveIsoDecoration({
showBorders: true, hideOpenings: true, filtersSupported: true, forcedColors: false,
}).panels, false);
});
+1 -11
View File
@@ -46,17 +46,6 @@ test('projected frame includes raised wall tops and is view-state independent',
assert.deepEqual(projectedFrame({ rect, wallHeight: ISO_WALL_HEIGHT }), frame);
});
test('Stage 2 frame includes opening tops and the structural floor edge, not blur', () => {
const rect = { x: 100, y: 200, w: 400, h: 300 };
const stage1 = projectedFrame({ rect, wallHeight: ISO_WALL_HEIGHT });
const stage2 = projectedFrame({
rect, wallHeight: ISO_WALL_HEIGHT, openingHeight: ISO_WALL_HEIGHT + 5, floorDepth: 10,
});
assert.ok(stage2.y < stage1.y);
assert.ok(stage2.h > stage1.h);
assert.deepEqual(Object.keys(stage2).sort(), ['h', 'w', 'x', 'y']);
});
test('client coordinates map through the current scene view', () => {
const scene = clientToScenePoint([250, 175], { left: 50, top: 25, width: 400, height: 300 },
{ x: 100, y: 200, w: 800, h: 600 });
@@ -69,3 +58,4 @@ test('degenerate cameras and frames throw instead of mixing projections', () =>
assert.throws(() => clientToScenePoint([0, 0], { left: 0, top: 0, width: 0, height: 1 },
{ x: 0, y: 0, w: 1, h: 1 }));
});
+1 -42
View File
@@ -1,9 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildIsoFloorGeometry, buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint,
buildIsoWallGeometry, isoEffectiveView, isoGeometryFingerprint,
} from '../test-build/iso-walls.js';
import { floorFootprintGeometry } from '../test-build/wall-thickness.js';
const closed = (points) => [...points, points[0]];
const square = closed([[0, 0], [100, 0], [100, 100], [0, 100]]);
@@ -15,49 +14,9 @@ test('one top and only O(E) visible sides are built deterministically', () => {
assert.equal(first.edgeCount, 4);
assert.ok(first.sides.length > 0 && first.sides.length <= first.edgeCount);
assert.match(first.topPath, /^M /);
assert.match(first.contactPath, /^M /);
assert.equal(first.sides.every((face) => /^M .* Z$/.test(face.d)), true);
});
test('floor edge follows outer components without internal or nested steps', () => {
const second = closed([[160, 0], [220, 0], [220, 60], [160, 60]]);
const hole = closed([[30, 30], [30, 70], [70, 70], [70, 30]]);
const floor = buildIsoFloorGeometry([[square, hole], [second]], 10);
assert.equal(floor.componentCount, 2);
assert.equal(floor.edgeCount, 8);
assert.equal(floor.sides.every((face) => face.planEdge.every((point) => (
!(point[0] >= 30 && point[0] <= 70 && point[1] >= 30 && point[1] <= 70)
))), true);
assert.ok(floor.sides.length > 0 && floor.sides.length <= floor.edgeCount);
});
test('canonical adjacent room union has no edge on its shared boundary', () => {
const rooms = [
{ id: 'left', poly: [[0, 0], [100, 0], [100, 100], [0, 100]] },
{ id: 'right', poly: [[100, 0], [200, 0], [200, 100], [100, 100]] },
];
const geometry = floorFootprintGeometry(rooms, [], [], 20, 250, 40, 1);
const floor = buildIsoFloorGeometry(geometry, 10);
assert.equal(floor.componentCount, 1);
assert.equal(floor.edgeCount, 4);
assert.equal(floor.sides.some((face) => face.planEdge.every((point) => point[0] === 100)), false);
});
test('floor edge output is stable under component order, winding and ring start', () => {
const a = closed([[0, 0], [80, 0], [80, 60], [0, 60]]);
const b = closed([[160, 20], [210, 20], [210, 80], [160, 80]]);
const rotatedA = closed([[80, 60], [80, 0], [0, 0], [0, 60]]);
const rotatedB = closed([[210, 80], [210, 20], [160, 20], [160, 80]]);
assert.deepEqual(buildIsoFloorGeometry([[rotatedB], [rotatedA]], 10),
buildIsoFloorGeometry([[a], [b]], 10));
});
test('empty room footprint emits no inferred slab', () => {
assert.deepEqual(buildIsoFloorGeometry([], 10), {
footprintPath: '', sides: [], componentCount: 0, edgeCount: 0,
});
});
test('holes preserve an evenodd top and add visible inner/jamb edges', () => {
const hole = closed([[40, 40], [40, 60], [60, 60], [60, 40]]);
const geometry = buildIsoWallGeometry([[square, hole]]);
+2 -29
View File
@@ -4,8 +4,6 @@ import { readFileSync } from 'node:fs';
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const styles = readFileSync(new URL('../src/styles.ts', import.meta.url), 'utf8');
const openings = readFileSync(new URL('../src/iso-openings.ts', import.meta.url), 'utf8');
const packageJson = readFileSync(new URL('../package.json', import.meta.url), 'utf8');
const spaceCard = readFileSync(new URL('../src/space-card.ts', import.meta.url), 'utf8');
const spaceRender = readFileSync(new URL('../src/space-render.ts', import.meta.url), 'utf8');
@@ -17,16 +15,12 @@ test('Labs iso is presentation-only and absent from the secondary space card', (
assert.doesNotMatch(spaceRender, /hp-labs|iso-walls|projection-toggle|view\.volumetric/);
});
test('Stage 2 uses inert shared-projection SVG geometry without a second light model', () => {
assert.match(card, /<svg class="iso-underlay-svg"/);
assert.match(card, /<svg class="iso-shadows-svg"/);
test('renderer uses SVG geometry without a CSS 3D context or new window light', () => {
assert.match(card, /<svg class="iso-walls-svg"/);
assert.match(card, /class="iso-wall-top"/);
assert.match(card, /class="iso-wall-side"/);
assert.match(card, /class="iso-opening-panel iso-\$\{panel\.type\}"/);
assert.match(card, /aria-hidden="true" pointer-events="none"/);
assert.doesNotMatch(styles, /perspective\s*:|preserve-3d|rotateX\(|rotateZ\(/);
assert.doesNotMatch(card, /window-light|iso-window-light|iso-glow|iso-sun/);
assert.doesNotMatch(card, /iso-window|window-light|vertical-door/);
});
test('all current floor/live layers remain in the one main scene', () => {
@@ -37,24 +31,3 @@ test('all current floor/live layers remain in the one main scene', () => {
assert.match(card, /const point = this\._scenePoint\(\[pos\.x, pos\.y\]\)/);
assert.match(card, /const point = this\._scenePoint\(\[p\.x, p\.y\]\)/);
});
test('structural cache includes opening flips and excludes live HA amount', () => {
assert.match(card, /flipH: !!opening\.flip_h/);
assert.match(card, /flipV: !!opening\.flip_v/);
assert.match(card, /floorEdgeHeight: ISO_FLOOR_EDGE_HEIGHT, algorithm: 3/);
const source = card.slice(card.indexOf('private _isoSource()'), card.indexOf('private _isoSceneKey()'));
assert.doesNotMatch(source, /_openingAmt|openingAmount|\.hass|matchMedia|CSS\.supports|theme|hover/);
assert.match(card, /projectIsoOpening\(basis, this\._openingAmt\(opening\)\)/);
assert.match(card, /isoLayers && !isoLayers\.floorSymbols/);
assert.match(card, /if \(!this\._spaceDisplayForRender\(\)\.showBorders\)/);
assert.match(card, /projectedFrame\(\{ rect: flat, wallHeight: ISO_WALL_HEIGHT \}\)/);
assert.match(card, /viewBox=\$\{isoLayers\?\.structural[\s\S]*?: `\$\{floorView\.x\}/);
assert.match(card, /preserveAspectRatio=\$\{isoLayers\?\.structural \|\| !iso \? 'xMidYMid meet' : 'none'\}/);
assert.match(card, /transform=\$\{isoLayers\?\.structural \? isoFloorMatrixCss\(\) : nothing\}/);
});
test('Stage 2 adds no schema, dependency, storage, network or HA action surface', () => {
assert.doesNotMatch(openings, /localStorage|fetch\(|XMLHttpRequest|WebSocket|callService|config|schema/i);
assert.doesNotMatch(packageJson, /three|babylon|webgl/i);
assert.doesNotMatch(card, /iso2|isometric_stage|stage2_enabled/);
});
-77
View File
@@ -1,77 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { MUTANTS, applyPatches } from '../scripts/mutation-gate.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
// Дешёвая половина гейта, идёт с обычными юнитами на каждом прогоне. Полный
// прогон с пересборкой бандла на мутанта — предрелизный, он в
// .github/workflows/mutation-gate.yml.
//
// Реестр, отставший от кода, хуже отсутствующего: он выглядит защитой. Поэтому
// дрейф якорей ловится здесь, а не при редком полном прогоне.
test('every mutant patch anchors exactly once in the current source', () => {
for (const mutant of MUTANTS) {
for (const patch of mutant.patches) {
const path = join(repoRoot, patch.file);
assert.ok(existsSync(path), `${mutant.id}: файла ${patch.file} больше нет`);
const source = readFileSync(path, 'utf8');
const hits = source.split(patch.find).length - 1;
assert.equal(hits, 1,
`${mutant.id}: якорь в ${patch.file} найден ${hits} раз(а) — реестр отстал от кода`);
assert.notEqual(patch.find, patch.replace, `${mutant.id}: патч ничего не меняет`);
}
}
});
test('every guard command points at a file that exists', () => {
for (const mutant of MUTANTS) {
const script = mutant.guard.split(' ').find((part) => part.endsWith('.mjs'));
assert.ok(script, `${mutant.id}: guard не называет исполняемый файл`);
assert.ok(existsSync(join(repoRoot, script)),
`${mutant.id}: guard-файла ${script} не существует`);
}
});
test('every mutant explains itself', () => {
const ids = new Set();
for (const mutant of MUTANTS) {
assert.ok(mutant.because && mutant.because.length > 40,
`${mutant.id}: без объяснения мутант превратится в карго-культ`);
assert.ok(!ids.has(mutant.id), `дубль id: ${mutant.id}`);
ids.add(mutant.id);
}
assert.ok(MUTANTS.length >= 6, 'стартовый набор — шесть мутантов по дырам из #85');
});
test('applyPatches rewrites the anchor and refuses a stale one', () => {
const dir = mkdtempSync(join(tmpdir(), 'hp-mg-'));
try {
mkdirSync(join(dir, 'src'), { recursive: true });
writeFileSync(join(dir, 'src', 'a.ts'), 'const KEEP = 1;\nconst FEATHER = 2;\n');
applyPatches(dir, [{ file: 'src/a.ts', find: 'const FEATHER = 2;', replace: 'const FEATHER = 20;' }]);
assert.match(readFileSync(join(dir, 'src', 'a.ts'), 'utf8'), /FEATHER = 20/);
// Якоря нет — отказ, а не тихий пропуск: патч «в никуда» выглядит защитой.
assert.throws(
() => applyPatches(dir, [{ file: 'src/a.ts', find: 'no such anchor', replace: 'x' }]),
/0 раз/,
);
// Якорь двоится — тоже отказ: патч лёг бы «куда попало».
writeFileSync(join(dir, 'src', 'a.ts'), 'twice\ntwice\n');
assert.throws(
() => applyPatches(dir, [{ file: 'src/a.ts', find: 'twice', replace: 'x' }]),
/2 раз/,
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+1 -33
View File
@@ -14,7 +14,6 @@ import {
checkReviewDocLimit,
checkSpecs,
classify,
commitsNeedingIssueStatus,
commitsUnderRuleOne,
evaluateCommit,
makeCommit,
@@ -135,9 +134,8 @@ test('a class A commit without a spec warns offline and fails with labels', () =
assert.equal(offline[0].level, 'warn');
assert.equal(offline[0].rule, 3);
// С метками: small и trivial оправдывают отсутствие файла, их отсутствие — нет.
// С метками: small оправдывает отсутствие файла, его отсутствие — нет.
assert.deepEqual(checkSpecs([c], [], () => ['small', 'S5-ready']), []);
assert.deepEqual(checkSpecs([c], [], () => ['trivial', 'S5-ready']), []);
const strict = checkSpecs([c], [], () => ['S5-ready']);
assert.equal(strict.length, 1);
assert.equal(strict[0].level, 'fail');
@@ -171,36 +169,6 @@ test('only class A/B commits are held to the issue status', () => {
assert.deepEqual(commitsUnderRuleOne([reviewDoc]), []);
});
test('stable promotion skips status recheck only for commits already published in a prerelease', () => {
const published = makeCommit({
sha: 'a'.repeat(40), subject: 'Fix shipped in beta', body: 'Issue: #123', files: ['src/a.ts'],
});
const postBeta = makeCommit({
sha: 'b'.repeat(40), subject: 'New promotion work', body: 'Issue: #130', files: ['scripts/a.mjs'],
});
const publishedShas = new Set([published.sha]);
assert.deepEqual(
commitsNeedingIssueStatus([published, postBeta], {
targetRef: 'refs/heads/main',
isPublishedPrereleaseCommit: (sha) => publishedShas.has(sha),
}).map((commit) => commit.sha),
[postBeta.sha],
);
assert.deepEqual(
rules(checkIssueStatuses(['130'], () => ({
ok: true, json: { state: 'CLOSED', labels: [] },
}))),
[8],
);
assert.deepEqual(
commitsNeedingIssueStatus([published, postBeta], {
targetRef: 'refs/heads/dev',
isPublishedPrereleaseCommit: (sha) => publishedShas.has(sha),
}).map((commit) => commit.sha),
[published.sha, postBeta.sha],
);
});
test('issue status check is fail closed when the source of truth is unreachable', () => {
// AC3: недоступный gh должен давать отказ, а не молчаливый пропуск.
const broken = () => ({ ok: false, error: 'gh: could not resolve to a Repository' });
-66
View File
@@ -1,66 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
adoptVirtualLightServerSnapshot,
applyVirtualLightEvent,
reconcileVirtualLightSnapshot,
virtualLightSnapshot,
virtualLightWire,
} from '../test-build/virtual-light-state.js';
test('issue 107 runtime wire data is normalized and missing data defaults on', () => {
const missing = virtualLightSnapshot(undefined, 7);
assert.deepEqual(virtualLightWire(missing), { rev: 0, config_rev: 7, off: [] });
const parsed = virtualLightSnapshot({
rev: 4, config_rev: 7, off: ['b', 'a', 'a'],
});
assert.deepEqual(virtualLightWire(parsed), { rev: 4, config_rev: 7, off: ['a', 'b'] });
const corrupt = virtualLightSnapshot({
rev: 4, config_rev: 7, off: ['valid', '', 1],
}, 7);
assert.deepEqual(virtualLightWire(corrupt), { rev: 0, config_rev: 7, off: [] });
});
test('issue 107 events are monotonic and never optimistic', () => {
const initial = virtualLightSnapshot({ rev: 2, config_rev: 7, off: [] });
assert.equal(applyVirtualLightEvent(initial, { marker_id: 'lamp', on: false, rev: 2 }), initial);
const off = applyVirtualLightEvent(initial, { marker_id: 'lamp', on: false, rev: 3 });
assert.deepEqual(virtualLightWire(off), { rev: 3, config_rev: 7, off: ['lamp'] });
const on = applyVirtualLightEvent(off, { marker_id: 'lamp', on: true, rev: 4 });
assert.deepEqual(virtualLightWire(on), { rev: 4, config_rev: 7, off: [] });
});
test('issue 107 event-before-response ordering never rolls state back', () => {
const afterEvent = virtualLightSnapshot({ rev: 5, config_rev: 7, off: ['lamp'] });
const staleResponse = adoptVirtualLightServerSnapshot(
afterEvent,
{ rev: 4, config_rev: 7, off: [] },
7,
true,
);
assert.equal(staleResponse, afterEvent);
const currentResponse = adoptVirtualLightServerSnapshot(
afterEvent,
{ rev: 5, config_rev: 7, off: ['lamp'] },
7,
true,
);
assert.deepEqual(virtualLightWire(currentResponse), {
rev: 5, config_rev: 7, off: ['lamp'],
});
const oldBackend = adoptVirtualLightServerSnapshot(afterEvent, undefined, 7, false);
assert.deepEqual(virtualLightWire(oldBackend), { rev: 0, config_rev: 7, off: [] });
});
test('issue 107 known config transitions preserve only still-eligible ids', () => {
const current = virtualLightSnapshot({ rev: 5, config_rev: 10, off: ['keep', 'drop'] });
const next = reconcileVirtualLightSnapshot(current, {
markers: [
{ id: 'keep', binding: 'virtual', is_light: true, tap_action: 'toggle', hidden: true },
{ id: 'drop', binding: 'virtual', is_light: false, tap_action: 'toggle' },
],
}, 11);
assert.deepEqual(virtualLightWire(next), { rev: 5, config_rev: 11, off: ['keep'] });
});
+1 -13
View File
@@ -6,8 +6,7 @@ import {
setWallThickness, setWallThicknessForRoom, applyWallThicknessToNewRoom,
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, clampWallCm, cmToField, fieldToCm,
wallCmToUnits, insetContour, inwardNormal, edgeKinds, wallEdgeBodies,
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, floorFootprintGeometry,
innerContourForRoom,
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, innerContourForRoom,
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
atomicPolyForRoom, insetOffsetsForRoom, wallIntervals, materializeWallIntervals,
normalizeWallIntervals,
@@ -41,17 +40,6 @@ const geometryBounds = (geom) => {
];
};
test('Stage floor footprint excludes detached independent physical bodies', () => {
const rooms = [{ id: 'room', poly: [[0, 0], [100, 0], [100, 100], [0, 100]] }];
const detached = [[[200, 20], [220, 20], [220, 80], [200, 80]]];
const footprint = floorFootprintGeometry(rooms, [], [], 20, 250, 40, 1);
const withBody = wallBodiesGeometry(rooms, [], [], [], 20, 250, 40, 1, detached);
assert.ok(footprint && withBody);
assert.deepEqual(geometryBounds(footprint), [0, 0, 100, 100]);
assert.deepEqual(geometryBounds(withBody.paperGeom), [0, 0, 100, 100]);
assert.deepEqual(geometryBounds(withBody.geom), [200, 20, 220, 80]);
});
const geometryDifferenceArea = (a, b) => geometryArea(difference(a, b));
function cornerSplitFixture({
-19
View File
@@ -27,25 +27,6 @@ async def test_setup_creates_runtime_data(hass: HomeAssistant) -> None:
assert entry.runtime_data is not None
assert entry.runtime_data.store is not None
assert entry.runtime_data.config_store is not None
assert entry.runtime_data.virtual_light_store is not None
async def test_setup_tolerates_unreadable_virtual_light_state(
hass: HomeAssistant, monkeypatch
) -> None:
"""Operational light state must not take the whole integration offline."""
from custom_components.houseplan.store import HouseplanStore
real_load = HouseplanStore.async_load
async def failing_load(self):
if self.key == "houseplan.virtual_lights":
raise OSError("corrupt operational store")
return await real_load(self)
monkeypatch.setattr(HouseplanStore, "async_load", failing_load)
entry = await _setup(hass)
assert entry.state.value == "loaded"
async def test_unload(hass: HomeAssistant) -> None:

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