Compare commits

..
Author SHA1 Message Date
claude[bot] 1312eb14e7 docs: review document for #107
Issue: #107
User-Visible: no
2026-08-14 01:03:48 +00:00
951 changed files with 17356 additions and 165322 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
steps:
- name: Check out release notes for a reusable call
if: ${{ inputs.reusable == true }}
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- name: Send to Telegram
-84
View File
@@ -1,84 +0,0 @@
# Скриншоты документации снимаются здесь и только здесь (#246).
#
# Съёмка на машине исполнителя даёт байтово разный PNG при одинаковом кадре:
# сглаживание и хинтинг зависят от окружения. Измерено на истории — пересъёмка
# в #231 изменила два файла из девяти на 7–8 байт, набор с беты все девять
# целиком. Одно окружение убирает этот шум насовсем.
#
# Джоба ничего не коммитит: она публикует артефакт, который человек принимает
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
# попадают в репозиторий через явное решение, а не через бота.
name: Docs screenshots
on:
workflow_dispatch:
inputs:
ref:
description: Ветка или SHA, с которого снимать
required: false
default: dev
permissions:
contents: read
jobs:
capture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
# Тот же кэш и тот же отказ от --with-deps, что в smoke/golden (#175, #206):
# системные библиотеки Chromium уже в образе раннера.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install pinned Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
- name: Build the bundle the screenshots must come from
run: npm run build
- name: Capture
run: node demo/docs/capture.mjs
# Вердикт до всякой приёмки. Само число изменившихся файлов ничего не
# говорит: набор, снятый другим браузером, меняет их все, и это нормально
# ровно один раз — при переходе на канонический прогон. Сравнивать надо
# браузер: тот же Chromium и десять изменившихся картинок означают, что
# изменился продукт (или что-то не так), другой Chromium — ожидаемую
# разницу рендеринга.
- name: Вердикт
run: |
git status --porcelain docs/images
changed=$(git diff --name-only docs/images | grep -c png || true)
before=$(git show HEAD:docs/images/screenshots.json | node -e \
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).chromium||'')}catch{console.log('')}})")
after=$(node -e "console.log(require('./docs/images/screenshots.json').chromium)")
echo "--- изменившихся PNG: $changed"
echo "--- Chromium: было «${before:-не записан}», стало «$after»"
if [ "$before" = "$after" ] && [ "$changed" -gt 0 ]; then
echo "ВЕРДИКТ: тот же браузер, а картинки изменились — изменился продукт."
echo "Смотрите на кадры: если изменение ожидаемое, принимайте."
elif [ "$before" != "$after" ]; then
echo "ВЕРДИКТ: браузер другой, поэтому переписаны все кадры сразу."
echo "Это ожидаемо один раз — при переходе на канонический прогон."
echo "Если Chromium сменился неожиданно, сверьте закреплённую версию в package-lock."
else
echo "ВЕРДИКТ: ничего не изменилось, принимать нечего."
fi
- name: Upload candidate
uses: actions/upload-artifact@v7
with:
name: docs-screenshots
path: |
docs/images/*.png
docs/images/screenshots.json
if-no-files-found: error
+6 -18
View File
@@ -13,11 +13,6 @@ name: Mutation gate
on:
workflow_dispatch:
inputs:
ref:
description: Git ref whose mutation guards must be proved
required: false
default: dev
schedule:
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
- cron: '20 5 * * 1'
@@ -32,32 +27,25 @@ concurrency:
jobs:
mutants:
runs-on: ubuntu-latest
# Все мутанты × (сборка + свой guard) — это десятки минут, и это нормально:
# гейт предрелизный. Час — потолок против зависшего Chromium.
# Шесть мутантов × (сборка + браузерный смок) — это десятки минут, и это
# нормально: гейт предрелизный. Час — потолок против зависшего Chromium.
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
ref: dev
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Установить backend test dependencies
run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
+7 -18
View File
@@ -30,7 +30,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Check out candidate
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
path: candidate
fetch-depth: 2
@@ -116,12 +116,12 @@ jobs:
echo "Comparison base: $sha ($source)" >> "$GITHUB_STEP_SUMMARY"
- name: Check out base SHA
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
ref: ${{ steps.base.outputs.sha }}
path: baseline
- uses: actions/setup-node@v7
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
@@ -132,24 +132,16 @@ jobs:
- name: Install candidate and baseline dependencies
run: npm ci --prefix candidate && npm ci --prefix baseline
# То же, что в validate.yml: кэш браузеров, apt не трогаем (#206).
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('candidate/package-lock.json') }}
- name: Install pinned Chromium
if: steps.pw.outputs.cache-hit != 'true'
working-directory: candidate
run: npx playwright install chromium
run: npx playwright install --with-deps chromium
- name: Build both exact source trees
run: |
npm --prefix candidate run build
(cd candidate && node scripts/bundle-sync.mjs)
cp candidate/dist/houseplan-card.js candidate/demo/srv/assets/houseplan-card.js
npm --prefix baseline run build
(cd baseline && node scripts/bundle-sync.mjs)
cp baseline/dist/houseplan-card.js baseline/demo/srv/assets/houseplan-card.js
- name: Capture base and candidate profiles
working-directory: candidate
@@ -158,8 +150,6 @@ jobs:
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
@@ -174,13 +164,12 @@ jobs:
run: |
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
- name: Upload full performance reports
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: full-performance
path: artifacts/performance
+50 -359
View File
@@ -39,7 +39,6 @@ jobs:
outputs:
stage: ${{ steps.decide.outputs.stage }}
cycle: ${{ steps.decide.outputs.cycle }}
spent: ${{ steps.decide.outputs.spent }}
limit: ${{ steps.decide.outputs.limit }}
steps:
- id: decide
@@ -64,41 +63,19 @@ jobs:
limit=4
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
# Считаются ДВЕ РАЗНЫЕ величины, и это не педантизм (#227).
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
# первое код-ревью получило r2/4. На задаче с двумя циклами ТЗ второе
# код-ревью упиралось бы в review-4 после одной правки.
#
# `attempt` — сколько раз ревью уже отработало на этом этапе. Он нужен
# только для имени документа и метки: два захода с одинаковым номером
# означают, что второй документ перезапишет первый и артефакт ревью
# исчезнет.
#
# `spent` — сколько циклов израсходовано из бюджета §4. Цикл — это
# «отправка на ревью → вердикт с блокирующими находками → возврат
# автору», поэтому бюджет тратят ТОЛЬКО жёлтые и красные вердикты.
# Зелёный ничего на правки не вернул и цикла не образует.
#
# Раньше обе роли исполнял один счётчик всех вердиктов, и конвейер
# наказывал за то, что предписывал сам: при неудавшемся слиянии он
# велит вернуть S7-code-review после ребейза, и этот заход добивал
# бюджет. На #225 (лёгкий трек, лимит 2) последовательность
# жёлтый → зелёный → ребейз дала review-4 на задаче с зелёным ревью и
# зелёным CI: работа встала, хотя после вердикта не было ни одной
# правки продуктового кода.
#
# Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал
# цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по
# имени документа в теле комментария; документа нет — вердикт не
# посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы
# работу досрочно: из двух ошибок выбрана обратимая.
attempt=1; spent=0; spent_list=""
# Этап опознаётся по имени документа в теле комментария. Если документа
# нет, вердикт не посчитается — недосчёт даёт лишний цикл, а перерасчёт
# остановил бы работу досрочно; из двух ошибок выбрана обратимая.
done_cycles=0
if [ -n "$stage" ]; then
comments=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json comments)
of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))]"
# Блокирующим считается вердикт, у которого в строке вердикта стоит
# «жёлтый» или «красный». Регистр и окружение слова не важны.
blocking="$of_stage | map(select(.body | test(\"Вердикт:[^\\n]*(жёлт|красн)\"; \"i\")))"
attempt=$(( $(printf '%s' "$comments" | jq -r "$of_stage | length") + 1 ))
spent=$(printf '%s' "$comments" | jq -r "$blocking | length")
spent_list=$(printf '%s' "$comments" | jq -r "$blocking | map(\"- \" + .url) | join(\"\\n\")")
done_cycles=$(gh issue view "$NUM" --repo "${{ github.repository }}" \
--json comments \
-q "[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))] | length")
fi
# Отказ обязан быть виден в issue, а не только в логе прогона.
@@ -132,33 +109,19 @@ jobs:
refuse "стоит blocked — конвейер не запускается" \
"на issue стоит \`blocked\` — задача ждёт внешнего решения. Снять метку, когда решение принято."
elif [ "$EXHAUSTED" = "true" ]; then
# Метку снимает владелец, а не конвейер: автоматика, отменяющая
# остановку работы, дороже ручного снятия. Но пересчёт печатается —
# метка могла остаться от прежнего правила, когда бюджет тратил и
# зелёный вердикт (#227).
stale=""
if [ "$spent" -lt "$limit" ]; then
stale=" Пересчёт по действующему правилу: блокирующих циклов $spent из $limit — метка могла остаться от прежнего правила, когда бюджет тратил любой вердикт. Снять её может владелец."
fi
refuse "стоит review-4 — решение за владельцем" \
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4).$stale"
elif [ "$spent" -ge "$limit" ]; then
echo "блокирующих циклов этапа $stage: $spent из $limit — лимит исчерпан"
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
elif [ "$done_cycles" -ge "$limit" ]; then
echo "циклов этапа $stage пройдено $done_cycles из $limit — лимит исчерпан"
gh issue edit "$NUM" --repo "${{ github.repository }}" --add-label review-4
# Перечень учтённого обязателен: иначе владельцу приходится читать
# всю ленту, чтобы понять, из чего сложился счёт.
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
"Лимит циклов ревью исчерпан: блокирующих циклов $spent из $limit на этапе \`$stage\` (заход $attempt). Следующего захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4).
Учтены вердикты с блокирующими находками — зелёные бюджет не тратят:
$spent_list"
"Лимит циклов ревью исчерпан ($done_cycles из $limit на этапе \`$stage\`). Пятого захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
stage=""
else
echo "этап $stage, заход $attempt, блокирующих циклов $spent из $limit"
echo "этап $stage, цикл $((done_cycles + 1)) из $limit"
fi
echo "stage=$stage" >> "$GITHUB_OUTPUT"
echo "cycle=$attempt" >> "$GITHUB_OUTPUT"
echo "spent=$spent" >> "$GITHUB_OUTPUT"
echo "cycle=$((done_cycles + 1))" >> "$GITHUB_OUTPUT"
echo "limit=$limit" >> "$GITHUB_OUTPUT"
review:
@@ -168,37 +131,17 @@ jobs:
# Время — единственный настоящий ограничитель зациклившегося прогона.
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: dev
# Иначе в конфиге git остаётся креденшел GITHUB_TOKEN, и push с
# мёртвым PAT молча уходит от github-actions[bot] — 403 при
# contents: read. Отказ обязан быть громким и правильным.
persist-credentials: false
# Живость PAT проверяется ДО ревью. На #150 истёкший токен обнаружился
# только на публикации документа — после сорока минут работы ревьюера.
- name: Секрет HP_PROCESS_TOKEN жив
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::HP_PROCESS_TOKEN пуст — секрет удалён или недоступен"
exit 1
fi
if ! login=$(gh api user -q .login 2>/dev/null); then
echo "::error::HP_PROCESS_TOKEN не аутентифицируется — истёк или отозван. Обновить: Settings -> Secrets and variables -> Actions -> HP_PROCESS_TOKEN"
exit 1
fi
echo "токен жив, действует от: $login"
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
# тяжёлых job, здесь его не было.
- uses: actions/setup-node@v7
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
@@ -211,15 +154,8 @@ jobs:
env:
NUM: ${{ github.event.issue.number }}
run: |
# Свежая по последнему коммиту, а не первая по алфавиту: на #150 рядом
# жили ветка ТЗ и ветка реализации, и head -1 выбрал устаревшую.
git fetch -q origin "+refs/heads/issue/${NUM}-*:refs/remotes/origin/issue/${NUM}-*" || true
branches=$(git for-each-ref --sort=-committerdate \
--format='%(refname:lstrip=3)' "refs/remotes/origin/issue/${NUM}-*")
branch=$(printf '%s\n' "$branches" | head -1)
if [ "$(printf '%s\n' "$branches" | grep -c .)" -gt 1 ]; then
echo "::warning::веток issue/${NUM}-* несколько ($(echo $branches | tr '\n' ' ')) — выбрана свежая по коммиту: $branch. Устаревшую следует удалить."
fi
branch=$(git ls-remote --heads origin "issue/${NUM}-*" \
| head -1 | sed 's|.*refs/heads/||')
if [ -n "$branch" ]; then
git checkout -q "origin/$branch"
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
@@ -229,125 +165,27 @@ jobs:
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
fi
# Ревьюер обязан смотреть тот же код, который уедет в dev (#257). Раньше
# ревью шло по ветке как есть, а слияние делало ребейз — проверенный SHA и
# слитый SHA были разными коммитами. Пока расхождение с dev текстовое,
# ребейз упирается в конфликт и это видно; смысловое расхождение git
# склеивает молча, и в dev уезжает комбинация, которую никто не читал.
# Именно так пришёл регресс #234.
#
# Заодно снимается плата за конфликт: он обнаруживался ПОСЛЕ сорока минут
# ревью и потраченных лимитов подписки, хотя виден за пять секунд до них.
#
# Этап spec не затрагивается: ветку ТЗ в dev никто не сливает, и трогать
# чужую ветку без нужды — лишний риск.
- name: Привести ветку к dev
id: rebase
if: needs.guard.outputs.stage == 'code' && steps.branch.outputs.name != ''
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
# rebase, в отличие от commit, не принимает -c user.*: он запускает
# свои процессы и требует личность в окружении, иначе падает с
# «unable to auto-detect email address».
GIT_AUTHOR_NAME: claude[bot]
GIT_AUTHOR_EMAIL: 209825114+claude[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: claude[bot]
GIT_COMMITTER_EMAIL: 209825114+claude[bot]@users.noreply.github.com
run: |
git fetch -q origin dev
if git merge-base --is-ancestor origin/dev HEAD; then
echo "ветка содержит весь dev — ребейз не нужен"
exit 0
fi
behind=$(git rev-list --count "HEAD..origin/dev")
before=$(git rev-parse "origin/$BRANCH")
echo "dev впереди на $behind коммит(ов) — привожу ветку"
if ! git rebase origin/dev; then
git rebase --abort || true
echo "conflict=true" >> "$GITHUB_OUTPUT"
echo "::warning::ветка $BRANCH не ребейзится на dev без конфликта — ревью не запускается"
exit 0
fi
# --force-with-lease с явным ожидаемым значением обязателен: между
# fetch и push автор мог запушить коммит, и слепой --force потерял бы
# его молча. Расхождение lease — падение прогона, а не предупреждение:
# ревью пошло бы по коду, которого на ветке уже нет.
if ! git push -q --force-with-lease="refs/heads/$BRANCH:$before" \
"https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:refs/heads/$BRANCH"; then
echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора"
exit 1
fi
# Локальная ссылка обновляется тоже: шаг слияния берёт origin/$BRANCH,
# и без этого он ребейзил бы заново уже приведённое.
git fetch -q origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"
short_before=$(git rev-parse --short "$before")
short_after=$(git rev-parse --short HEAD)
echo "note=Ветка приведена к dev конвейером до ревью: поверх легло $behind коммит(ов) dev, $short_before -> $short_after. После ребейза это другой код (§7.2) — разбор полный, а не по дельте." >> "$GITHUB_OUTPUT"
echo "ветка $BRANCH приведена к dev: $short_before -> $short_after"
# Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона
# метка меняется всегда» при этом держится: возврат в S6-in-progress —
# тоже смена метки, и автор не ждёт впустую.
- name: Конфликт с dev — вернуть автору без ревью
if: steps.rebase.outputs.conflict == 'true'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
BRANCH: ${{ steps.branch.outputs.name }}
run: |
cat > /tmp/stale.md <<EOF
**Ревью не запускалось:** ветка \`$BRANCH\` не ребейзится на \`dev\` без конфликта. Код никто не читал, вердикта нет, цикл ревью не израсходован.
Проверка стоит до ревью намеренно: конфликт всё равно вернул бы задачу, но уже после сорока минут работы ревьюера и потраченных лимитов.
Задача переведена в \`S6-in-progress\`. Осталось:
1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;
2. запушить ветку;
3. вернуть метку \`S7-code-review\`.
[Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
EOF
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale.md
gh issue edit "$NUM" --repo "${{ github.repository }}" \
--add-label S6-in-progress --remove-label S7-code-review
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
- name: Установить зависимости
if: steps.rebase.outputs.conflict != 'true'
run: npm ci
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
# но когда нужен — качать его заново дороже, чем держать в кэше.
- name: Кэш браузеров Playwright
id: pw
if: steps.rebase.outputs.conflict != 'true'
uses: actions/cache@v6
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.rebase.outputs.conflict != 'true' && steps.pw.outputs.cache-hit != 'true'
# Без --with-deps: системные библиотеки Chromium предустановлены в
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
# библиотека когда-нибудь пропадёт из образа, Chromium не запустится с
# внятной ошибкой — тогда флаг вернуть.
run: npx playwright install chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Review
id: review
if: steps.rebase.outputs.conflict != 'true'
uses: anthropics/claude-code-action@v1
env:
# Вне рабочей копии: восстановление дерева ревьюером не должно
# уничтожать его собственный артефакт (#220).
REVIEW_DOC: ${{ runner.temp }}/review-document.md
with:
# Подписка, а не отдельный счёт API: токен выпускается через
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
@@ -360,44 +198,6 @@ jobs:
Этап: ${{ needs.guard.outputs.stage }}
spec — ревью ТЗ (PROCESS.md §2.4)
code — код-ревью (PROCESS.md §2.7)
Заход: r${{ needs.guard.outputs.cycle }} · блокирующих циклов израсходовано ${{ needs.guard.outputs.spent }} из ${{ needs.guard.outputs.limit }}
Бюджет §4 тратят только жёлтые и красные вердикты: зелёный
ничего не вернул на правки и цикла не образует (#227).
Номер захода нужен для имени документа — два документа с
одинаковым номером затёрли бы друг друга.
${{ steps.rebase.outputs.note }}
**Если цикл не первый — объём разбора по дельте, а не заново**
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
всех раундов, и повторный цикл заново выводил продуктовую рамку и
перепроверял AC, которых правка не касалась: r2 по #150 стоил
полного прогона ради одной строки в тестовой фикстуре.
Порядок для r2 и дальше:
1. найди вердикт предыдущего раунда в комментариях issue и SHA,
на котором он получен. SHA в вердикте не назван — это находка;
2. объяви дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла
ТЗ или тела issue для spec. Дельта — предмет этого раунда;
3. по каждой находке предыдущего раунда покажи, чем именно она
закрыта: строка кода или текста, а не заявление автора;
4. заново проверяй только те AC, чьё доказательство дельта
задевает. Остальные наследуй;
5. в документе обязателен раздел «Унаследовано из r<N-1>»: что
принято без повторной проверки, со ссылкой на документ того
раунда и SHA, на котором вывод получен. Без этого перечня
сокращение — молчаливое доверие, а такой тихий успех уже
дважды стоил дня (#171, #207).
Разбор остаётся ПОЛНЫМ, если дельта не локальна: ребейз на ушедший
вперёд dev (после ребейза это другой код, §7.2), смена контракта
поведения, задета новая подсистема, либо объём дельты сопоставим с
исходной задачей. Сомневаешься — разбирай полностью и скажи почему.
Сокращается объём РАЗБОРА, а не строгость: правка по замечанию
способна сломать AC, который предыдущий раунд признал выполненным —
так появилась регрессия #102. Поэтому граница не «только находки», а
«находки плюс всё, до чего дотягивается дельта».
Прочитай в этом порядке, прежде чем судить:
1. docs/SCOPE.md — зачем продукт существует и для кого. Он
@@ -441,44 +241,14 @@ jobs:
правке — не тщательность, а потеря времени: полные наборы это
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
Всегда, они дешёвые, и в повторном раунде тоже: код изменился,
а стоят они минуты:
Всегда, они дешёвые:
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
копий бандла. Плюс `node scripts/check-docs.mjs`, если diff трогает
`src/**`: отпечаток скриншотов документации считается по всему
`src/**`, поэтому любая правка фронтенда делает его устаревшим —
выбирать тут нечего. Пропуск этого шага в #230 и #234 оставил `dev`
с красным job `docs` до следующей задачи (#237).
Если diff трогает геометрию или ссылки на неё — рёбра комнат,
записи толщины, `layout`, `marker.space`, `open_spans` — обязательны
инварианты модели (#254): `npm test` уже гоняет их на всех моделях
проекта, а на конкретной конфигурации они проверяются командой
`npm run invariants -- --config <экспорт или ответ config/get>`.
Три вопроса, на которые они отвечают, и все три уже стоили
продукту дефектов: не исчезла ли запись толщины (#253), разрешима ли
каждая ссылка (#244, #252) и равен ли ключ записи толщины ключу
решёточного ребра (#258, #259). Последний сравнивает строки без
допусков: сдвиг ключа на один шаг решётки равен допуску первых двух,
поэтому они на нём промахиваются. Если задача меняет геометрию, а
инварианты в отчёте не названы — это непрогнанный гейт, а не мелочь.
копий бандла.
По необходимости, и «необходимость» определяется diff'ом и AC:
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс те,
что печатает `node scripts/smoke-select.mjs --base <base> --head <head>`.
Сколько их всего — считает `ls demo/smoke_*.mjs | wc -l`; вшитое
в этот текст число трижды расходилось с деревом, поэтому его
здесь больше нет. Прогон всех уместен только когда задача
действительно задевает всё. Выбирать по теме недостаточно: регресс #234 поймал
`smoke_wall_junctions`, который по названию про стыки стен, а не
про толщину отрезка. Инструмент печатает три вида ответа, и они
разные: «прямое совпадение» — смок называет изменённый символ,
«зарегистрированная связь» — смок проверяет следствие контракта,
не называя его, «НЕОПРЕДЕЛЁННОСТЬ» — связь не доказана, и это не
разрешение ничего не прогонять. Вывод инструмента прикладывается
к комментарию ревью вместе с решением по каждой строке: прогнал
либо не прогнал и почему. Слабые связи (одно распространённое
имя) — повод посмотреть, а не обязанность прогонять;
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс
относящиеся к тронутым поверхностям. Их 127; прогон всех уместен
только когда задача действительно задевает всё;
- `npm run golden:verify` — если diff может изменить видимый
результат: рендер, геометрия, стили, слои;
- `python -m pytest tests_backend -q` — если тронут
@@ -486,14 +256,6 @@ jobs:
- performance-профили — если названы в AC либо тронуты
чувствительные к перфу пути.
**Одно число — один источник.** Если дифф добавляет или меняет
величину, видимую пользователю, назови в отчёте прямо: какое число
видно дважды (превью против записи, подпись против площади,
подсветка инструмента против сохранённого значения) и один ли у него
источник. Три дефекта подряд имели именно эту причину — #234, #233 и
способ, которым #234 обнаружили. Механическая часть закреплена
тестом `test/single-source-numbers.test.mjs`, смысловая — твоя.
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
тем тестам, которые ты прогонял.
@@ -505,46 +267,27 @@ jobs:
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней
же — без High это жёлтый вердикт и возврат автору, отдельный issue
НЕ заводится (решение владельца 2026-08-19, #202: заведение и
обслуживание issue дороже правки на месте); Low либо правится,
либо снимается с записью. Жёлтый вердикт допустим и при полностью
выполненных AC, если изменение не решает заявленный сценарий или
ухудшает смежный. Продуктовое рассуждение расширяет вопросы, но не
отменяет AC и не даёт права менять скоуп.
Серьёзность: High блокирует; Medium обязан стать отдельным issue;
Low либо правится, либо снимается с записью. Жёлтый вердикт
допустим при полностью выполненных AC, если изменение не решает
заявленный сценарий или ухудшает смежный. Продуктовое рассуждение
расширяет вопросы, но не отменяет AC и не даёт права менять скоуп.
Только Medium-находку ВНЕ скоупа задачи (попутный дефект соседнего
поведения, который в этой ветке чинить нельзя) заведи отдельным
issue со ссылкой на #${{ github.event.issue.number }} и метками:
тип, приоритет, S1-new. «Оставили в тексте ревью» закрытием не
считается и прямо запрещено §12.
Каждую Medium-находку заведи отдельным issue со ссылкой на
#${{ github.event.issue.number }} и метками: тип, приоритет,
S1-new. «Оставили в тексте ревью» закрытием не считается и прямо
запрещено §12.
Напиши полный документ ревью в файл, путь которого лежит в
переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория).
Почему не в docs/reviews: документ там был некоммитнутым файлом того
же дерева, которое ты мутируешь, проверяя «умеет ли тест падать». На
#220 три раунда подряд документ исчезал — восстановление дерева
(`git checkout -- .`, `git clean -fd`) сносит собственный артефакт
ревью, потому что он untracked. В репозиторий его положит шаг
публикации, взяв из REVIEW_DOC; тебе трогать docs/reviews не нужно.
В самом репозитории не создавай файлов вообще: любые изменения в
рабочей копии будут отброшены. Имя документа в docs/reviews шаг
публикации соберёт сам — SPEC-REVIEW для этапа spec, CODE-REVIEW для
code, с номером issue и заходом.
Содержание документа: скоуп, как проверялось, находки с
воспроизведением, что проверено и корректно, чего не проверял. Для
r2 и дальше добавь два раздела: «Закрытие раунда r<N-1>» — таблица
«находка | чем закрыта | где это видно», и «Унаследовано из r<N-1>» —
что принято без повторной проверки, с документом и SHA.
Напиши полный документ ревью в файл
docs/reviews/<SPEC|CODE>-REVIEW-${{ github.event.issue.number }}-r${{ needs.guard.outputs.cycle }}.md
(SPEC для этапа spec, CODE для code): скоуп, как проверялось,
находки с воспроизведением, что проверено и корректно, чего не
проверял. Каталог docs/reviews/ создай, если его нет. Больше не
пиши ничего: любой файл вне docs/reviews/ опубликован не будет.
Затем оставь в issue краткий комментарий: вердикт, ключевые находки
и ссылка на документ. Первой строкой — вердикт в формате §7.2:
`Вердикт: зелёный/жёлтый/красный · заход r${{ needs.guard.outputs.cycle }} · блокирующих циклов ${{ needs.guard.outputs.spent }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…`
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору жёлтым)
`Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → #…`
Затем верни JSON по схеме. Это последнее действие и оно обязательно:
без него метка не переставится и конвейер встанет.
@@ -556,14 +299,10 @@ jobs:
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
# решает этот шаг, а не модель: всё остальное откатывается.
- name: Опубликовать документ ревью
if: steps.rebase.outputs.conflict != 'true'
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
SOURCE: ${{ runner.temp }}/review-document.md
run: |
# Ветки задачи может не быть: у задач, размеченных до появления
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
@@ -574,49 +313,12 @@ jobs:
if [ -z "$BRANCH" ]; then
echo "::warning::ветки задачи нет — документ ревью ляжет в dev"
fi
marker=CODE-REVIEW
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
# Рабочая копия отбрасывается ДО того, как документ попадёт в дерево:
# ревьюер правит код, проверяя «умеет ли тест падать», и его правки
# публиковаться не должны.
git checkout -- . 2>/dev/null || true
# docs/reviews исключён из уборки: ревьюер мог написать документ по
# старому пути, и клин не должен его съесть до `git add` — ровно так
# оба пути остаются работоспособными.
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
# Документ приезжает извне репозитория (#220). Три раунда подряд он
# терялся, пока лежал некоммитнутым файлом в том же дереве, которое
# ревьюер мутирует и затем восстанавливает: `git checkout -- .` плюс
# `git clean -fd` сносят собственный артефакт ревью, потому что он
# untracked. Теперь его место — RUNNER_TEMP, и уборка дерева ему не
# страшна.
if [ -f "$SOURCE" ]; then
mkdir -p docs/reviews
cp "$SOURCE" "$doc"
echo "документ взят из $SOURCE ($(wc -c < "$doc") байт)"
else
# Совместимость: ревьюер мог написать по старому пути, если промпт
# ещё не обновился в этой ветке.
echo "::warning::$SOURCE не найден — ищу документ в рабочей копии"
fi
git add docs/reviews 2>/dev/null || true
if git diff --cached --quiet; then
# Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит
# документ сам, своим app-токеном мимо этого шага (CODE-REVIEW-150-r1,
# коммиттер GitHub). Провал — когда файла нет и на ветке.
git fetch -q origin "$target"
if git cat-file -e "origin/$target:$doc" 2>/dev/null; then
echo "документ уже опубликован ревьюером: $doc"
exit 0
fi
# Ревью без артефакта запрещено (PROCESS.md §2.4/§10.4/§12). Раньше
# здесь стоял warning с exit 0: на #150 оба вердикта ревью ТЗ
# остались только комментариями, метки переставились, и пропажу
# заметило лишь следующее ревью — issue #171. Падение ДО шага с
# меткой сохраняет инвариант «метка не сменилась = прогон упал».
echo "::error::вердикт есть, а документа нет: ни $SOURCE, ни $doc в рабочей копии, ни $doc в $target — ревью без артефакта (#171, #220)"
exit 1
echo "::warning::документ ревью не создан"
exit 0
fi
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
@@ -636,26 +338,16 @@ jobs:
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
rebase "origin/$target"; then
git rebase --abort || true
# Тоже вердикт без артефакта: раньше exit 0 переставил бы метку.
echo "::error::документ ревью не удалось опубликовать в $target: конфликт (#171)"
exit 1
echo "::error::документ ревью не удалось опубликовать в $target: конфликт"
exit 0
fi
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"
fi
# Постусловие: до ветки дошёл именно ожидаемый файл. Коммит с
# документом, названным не по формату, — тот же вердикт без
# артефакта, только дороже в обнаружении.
git fetch -q origin "$target"
if ! git cat-file -e "origin/$target:$doc" 2>/dev/null; then
echo "::error::коммит в $target опубликован, но ожидаемого $doc в нём нет — файл назван не по формату (#171)"
exit 1
fi
echo "документ опубликован в $target: $doc"
echo "документ опубликован в $target"
- name: Решение по вердикту
id: decide
if: steps.rebase.outputs.conflict != 'true'
env:
OUT: ${{ steps.review.outputs.structured_output }}
STAGE: ${{ needs.guard.outputs.stage }}
@@ -734,7 +426,6 @@ jobs:
echo "слито в dev: $(git rev-parse --short HEAD)"
- name: Переставить метку
if: steps.rebase.outputs.conflict != 'true'
env:
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
+6 -5
View File
@@ -24,11 +24,11 @@ jobs:
sha: ${{ steps.candidate.outputs.sha }}
tag: ${{ steps.candidate.outputs.tag }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Pin the current dev candidate
id: candidate
@@ -67,11 +67,11 @@ jobs:
url: ${{ steps.verify.outputs.url }}
newly_published: ${{ steps.release.outputs.newly_published }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: ${{ needs.gate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Build and verify both release assets before publication
env:
@@ -81,6 +81,7 @@ jobs:
npm ci
npm run build
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
VERSION=${TAG#v}
grep -Fq "$VERSION" dist/houseplan-card.js
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
@@ -172,7 +173,7 @@ jobs:
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n- assets: `houseplan-card.js`, `houseplan.zip`\n' \
"$TAG" "$SHA" "$URL" >> "$GITHUB_STEP_SUMMARY"
- name: Verify HACS prerelease discovery order
uses: actions/github-script@v9
uses: actions/github-script@v7
env:
EXPECTED_TAG: ${{ needs.gate.outputs.tag }}
with:
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ github.event.inputs.tag }}
run: echo "tag=${EVENT_TAG:-$INPUT_TAG}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
+8 -8
View File
@@ -16,11 +16,11 @@ jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Require a green Validate for this exact commit
env:
@@ -47,27 +47,27 @@ jobs:
needs: gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
- uses: actions/setup-node@v7
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci && npm run build
- name: Verify compositor frame continuity for a stable release
if: ${{ !github.event.release.prerelease }}
run: |
npx playwright install --with-deps chromium
node scripts/bundle-sync.mjs
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
npm run continuity:screencast
- name: Upload failed continuity frames
if: ${{ failure() && !github.event.release.prerelease }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: continuity-screencast
path: artifacts/continuity-screencast
- run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
- name: Attach card to release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
files: dist/houseplan-card.js
hacs-discovery:
@@ -80,7 +80,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Verify the published tag is the prerelease HACS will discover
uses: actions/github-script@v9
uses: actions/github-script@v7
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
+38 -356
View File
@@ -6,12 +6,6 @@ on:
# to the same SHA and must not duplicate the browser validation jobs.
branches:
- '**'
# Документы ревью конвейер пишет пачками — 340 коммитов за месяц, и каждый
# гонял лёгкую половину Validate впустую (≈15 часов раннера в месяц).
# Релизного кандидата это не затрагивает: тег всегда стоит на коммите,
# который меняет версию и бандл, а не только `docs/reviews/**`.
paths-ignore:
- 'docs/reviews/**'
pull_request:
# A new push supersedes an unfinished validation for the same branch or PR.
@@ -21,43 +15,12 @@ concurrency:
cancel-in-progress: true
jobs:
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Validate public documentation
run: node scripts/check-docs.mjs --external
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
process-workflow-sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: process.yml идентичен в main и dev
run: |
git fetch --quiet origin main dev
if diff <(git show origin/main:.github/workflows/process.yml) \
<(git show origin/dev:.github/workflows/process.yml); then
echo "main и dev идентичны"
else
echo "РАСХОЖДЕНИЕ: process.yml в main и dev различаются."
echo "Конвейер исполняет версию из ветки по умолчанию, поэтому"
echo "правку нужно отправить в обе ветки."
exit 1
fi
provenance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v7
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Validate commit trailers and hook mode
env:
@@ -65,9 +28,8 @@ jobs:
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEVELOPMENT_BRANCH: dev
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range
# Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на
@@ -78,9 +40,9 @@ jobs:
process-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v7
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Process gate
env:
@@ -88,181 +50,34 @@ jobs:
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEVELOPMENT_BRANCH: dev
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TARGET_REF: ${{ github.ref }}
# Публичный репозиторий: штатного токена хватает на чтение issue.
GH_TOKEN: ${{ github.token }}
run: |
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
node scripts/process-gate.mjs --github-range --issues
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
# относящееся к ним. НА DEV ФИЛЬТРОВ НЕТ: гейт беты принимает «зелёный Validate
# на точном SHA», и если объём прогона зависит от diff, «зелёный» перестаёт
# значить одно и то же — кандидат релиза (манифесты + changelog) пропустил бы
# браузерные тесты, а прогон с пропущенными job всё равно success. Фильтры
# экономят на ветках задач, где Validate — ранний сигнал: настоящую приёмку
# там делает код-ревью, которое гоняет гейты само (#127).
changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.classify.outputs.frontend }}
backend: ${{ steps.classify.outputs.backend }}
integration: ${{ steps.classify.outputs.integration }}
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- id: classify
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
REF: ${{ github.ref }}
run: |
if [ "$REF" = "refs/heads/dev" ]; then
echo "dev: без фильтров, всё true"
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
exit 0
fi
zero=$(printf '%040d' 0)
base="$BEFORE_SHA"
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
# иначе классифицировалась бы вся история.
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|| ! git cat-file -e "$base" 2>/dev/null; then
git fetch -q origin dev
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1")
fi
files=$(git diff --name-only "$base" "$HEAD_SHA")
printf '%s\n' "$files" | head -50
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
{
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|pytest\.ini$)')"
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
} >> "$GITHUB_OUTPUT"
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
# сборки) ПЛЮС оснастка именно этой job. Маркер в кэше пишет только успешный
# прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми
# же входами уже завершилась успешно.
#
# Это НЕ фильтр путей из job `changes` (на dev они отключены намеренно): там
# объём прогона угадывается по путям и «зелёный» начинает значить разное,
# здесь эквивалентность входов доказана хешем.
#
# Свойство, снимающее главный риск: релизный кандидат бампает версию, а
# CARD_VERSION и package.json входят в фингерпринт, поэтому ключи кандидата
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
reuse:
runs-on: ubuntu-latest
outputs:
smoke: ${{ steps.probe.outputs.smoke }}
golden: ${{ steps.probe.outputs.golden }}
performance_smoke: ${{ steps.probe.outputs.performance_smoke }}
backend: ${{ steps.probe.outputs.backend }}
smoke_key: ${{ steps.keys.outputs.smoke }}
golden_key: ${{ steps.keys.outputs.golden }}
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
backend_key: ${{ steps.keys.outputs.backend }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Ключи переиспользования
id: keys
run: |
for job in smoke golden performance_smoke backend; do
key=$(node scripts/gate-reuse.mjs --job="$job")
echo "$job=$key" >> "$GITHUB_OUTPUT"
echo "$job: $key"
done
# lookup-only: маркер только проверяется, но не восстанавливается —
# сохранять его в этой job нечего, она ничего не прогоняла.
- name: Маркер smoke
id: m_smoke
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-smoke-${{ steps.keys.outputs.smoke }}
lookup-only: true
- name: Маркер golden
id: m_golden
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-golden-${{ steps.keys.outputs.golden }}
lookup-only: true
- name: Маркер performance_smoke
id: m_perf
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
lookup-only: true
- name: Маркер backend
id: m_backend
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-backend-${{ steps.keys.outputs.backend }}
lookup-only: true
- name: Что переиспользуем
id: probe
env:
SMOKE: ${{ steps.m_smoke.outputs.cache-hit }}
GOLDEN: ${{ steps.m_golden.outputs.cache-hit }}
PERF: ${{ steps.m_perf.outputs.cache-hit }}
BACKEND: ${{ steps.m_backend.outputs.cache-hit }}
run: |
# Пропуск обязан быть громким: молчаливый skip — тот самый тихий
# успех, который уже дважды стоил нам дня (#171, #207).
waive() {
if [ "$2" = "true" ]; then
echo "$1=true" >> "$GITHUB_OUTPUT"
echo "::notice::$1 не прогоняется: входы побайтово те же, что в предыдущем успешном прогоне (#208)"
echo "- **$1** переиспользована: входы не менялись" >> "$GITHUB_STEP_SUMMARY"
else
echo "$1=false" >> "$GITHUB_OUTPUT"
echo "- $1: прогоняется" >> "$GITHUB_STEP_SUMMARY"
fi
}
echo "### Переиспользование гейтов (#208)" >> "$GITHUB_STEP_SUMMARY"
waive smoke "$SMOKE"
waive golden "$GOLDEN"
waive performance_smoke "$PERF"
waive backend "$BACKEND"
hacs:
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: HACS validation
uses: hacs/action@main
with:
category: integration
hassfest:
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Hassfest validation
uses: home-assistant/actions/hassfest@master
frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
@@ -273,60 +88,31 @@ jobs:
run: npm test
- name: Build
run: npm run build
# Копия стенда больше не коммитится (#255): сверяются две обязательные.
- name: Card bundle snapshots in sync
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
run: |
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
smoke:
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
needs: [frontend, reuse]
if: needs.reuse.outputs.smoke != 'true'
needs: frontend
runs-on: ubuntu-latest
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
# критическим путём всего Validate. Три шарда режут его примерно вдвое;
# цена — трижды `npm ci` и сборка, около двух оплаченных минут раннера.
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
# внятной ошибкой — тогда флаг вернуть.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install pinned Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
- name: Install Chromium for Playwright
run: npx playwright install --with-deps chromium
- name: Build a fresh bundle for the smokes
run: npm run bundle:sync
- name: Smoke suite (шард ${{ matrix.shard }} из 3)
env:
SHARD: ${{ matrix.shard }}
SHARDS: '3'
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Smoke suite
run: |
fail=0
index=0
ran=0
mkdir -p /tmp/smoke-logs
# Деление по порядковому номеру файла: список отсортирован, поэтому
# разбиение детерминировано и не зависит от времени прогона.
for f in demo/smoke_*.mjs; do
index=$((index + 1))
if [ $(( (index - 1) % SHARDS + 1 )) -ne "$SHARD" ]; then continue; fi
ran=$((ran + 1))
name=$(basename "$f" .mjs)
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
echo "ok $name"
@@ -336,68 +122,30 @@ jobs:
fail=1
fi
done
echo "--- шард ${SHARD}/${SHARDS}: прогнано ${ran} из ${index}"
# Пустой шард — признак, что деление сломалось, а не что работы нет.
if [ "$ran" -eq 0 ]; then echo "шард пуст: проверьте деление"; exit 1; fi
exit $fail
- name: Upload smoke logs
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: smoke-logs-${{ matrix.shard }}
name: smoke-logs
path: /tmp/smoke-logs
# Маркер переиспользования пишется ОДИН раз и только когда прошли все шарды:
# частично прогнанная матрица не имеет права выглядеть как выполненная работа.
smoke_done:
needs: [smoke, reuse]
if: needs.reuse.outputs.smoke != 'true'
runs-on: ubuntu-latest
steps:
- name: Записать маркер успеха
run: |
printf '%s\n' "smoke прогнана успешно (3 шарда)" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
- uses: actions/cache/save@v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-smoke-${{ needs.reuse.outputs.smoke_key }}
golden:
# Deterministic visual correctness stays in every prerelease gate: it is
# inexpensive and catches a different class of regressions than timings.
needs: [frontend, reuse]
if: needs.reuse.outputs.golden != 'true'
needs: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
# внятной ошибкой — тогда флаг вернуть.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install pinned Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
run: npx playwright install --with-deps chromium
- name: Build the exact source under review
run: npm run bundle:sync
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Capture or verify golden matrix
id: golden
run: |
@@ -410,60 +158,28 @@ jobs:
fi
- name: Upload golden candidates/diffs
if: failure() || steps.golden.outputs.has_baselines == 'false'
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: golden-images
path: artifacts/golden
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
run: |
printf '%s\n' "golden прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
- uses: actions/cache/save@v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-golden-${{ needs.reuse.outputs.golden_key }}
performance_smoke:
# Candidate-only catastrophic-regression guard for ordinary pushes and
# prereleases. The expensive same-runner comparison lives in performance.yml.
needs: [frontend, reuse]
if: needs.reuse.outputs.performance_smoke != 'true'
needs: frontend
runs-on: ubuntu-latest
# 15 минут не хватало, когда установка браузера шла через apt: замер
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
# job укладывается в те же минуты, что и раньше.
timeout-minutes: 20
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
# внятной ошибкой — тогда флаг вернуть.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install pinned Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
run: npx playwright install --with-deps chromium
- name: Build the exact candidate source
run: npm run bundle:sync
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Capture the heaviest Glow state
run: |
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
@@ -472,55 +188,21 @@ jobs:
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
- name: Upload performance smoke report
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: performance-smoke
path: artifacts/performance-smoke
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
run: |
printf '%s\n' "performance_smoke прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
- uses: actions/cache/save@v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
backend:
needs: [changes, reuse]
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
# Browser fixtures are generated by their real ESM factories and then
# validated through the Python CONFIG_SCHEMA/LAYOUT_SCHEMA in the same test.
- uses: actions/setup-node@v7
- uses: actions/setup-node@v4
with: { node-version: 22 }
- uses: actions/setup-python@v7
- uses: actions/setup-python@v5
with: { python-version: "3.13" }
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
- name: Backend unit tests (pure + HA harness)
run: python -m pytest tests_backend/ -q
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
run: |
printf '%s\n' "backend прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
- uses: actions/cache/save@v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-backend-${{ needs.reuse.outputs.backend_key }}
-5
View File
@@ -7,8 +7,3 @@ __pycache__/
.venv-backend/
artifacts/
.agents/
# Копия бандла для стенда: её собирает `npm run bundle:sync`, а в репозитории
# она только росла — 364 версии по 1.16 МБ за семь недель (#255). Обязательных
# копий две: `dist/` (артефакт сборки) и `custom_components/` (её ставит HACS).
demo/srv/assets/houseplan-card.js
+14 -58
View File
@@ -38,7 +38,8 @@ task records: problem, scope, acceptance criteria and discussion.
**Status lives in labels:** `S1-new`, `S2-analysis`, `S3-spec`, `S4-spec-review`,
`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`, plus `blocked` on top
of a status and `rejected` on a closed issue. Exactly one `S*` label per open
issue. Labels are the whole of it: GitHub Projects is no longer used.
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
@@ -87,7 +88,7 @@ Start with the spec?" is the correct answer, not a smaller patch.
| **A — product** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, i18n, `custom_components/**/translations/**` | yes |
| **B — gates and tooling** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, `.github/workflows/**`, `rollup.config.mjs`, `tsconfig*.json` | yes; may reuse the issue it covers |
| **C — documentation** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md` | not if it is part of its issue's DoD |
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` | never changes on its own. The stand copy `demo/srv/assets/houseplan-card.js` is no longer committed (#255): build it with `npm run bundle:sync` |
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**` | never changes on its own |
The table above is a summary; `PROCESS.md` §1 is the authority and now covers the
configuration files this one omits — `package.json`, `package-lock.json`,
@@ -154,28 +155,6 @@ arrive. Cycles are counted per stage, so a code review spends its own budget.
Everything else still requires the owner's explicit command: pushing `main`,
creating tags, publishing betas and releases, closing issues.
## Working trees (#115)
One checkout, one `HEAD`: two agents sharing a directory inherit each other's
branch, and twice in one hour a commit landed on someone else's task branch that
way. The layout is therefore fixed:
- **`houseplan-card-src/houseplan-card`** — the author's tree. Task branches live
here; nobody else commits in it. Unfamiliar local changes belong to the author
or the owner — never reset or clean them away.
- **`houseplan-card-src/hp-dev`** — the owner's worktree, permanently on `dev`. For owner-side operations that must not disturb the
author's tree: pushing `dev`, restoring a hook's executable bit, emergencies.
- **The reviewer and the infrastructure agent own no local tree.** The reviewer
runs in CI on a fresh checkout. The infrastructure agent reads via `git show`
and publishes through the GitHub API; it makes no local commits at all, so it
needs no `HEAD` of its own. Its scratch worktrees live outside the repo and are
pruned after use.
A worktree is only usable on the machine that created it: the `.git` file records
an absolute path in that machine's format. One created from a Linux sandbox is
dead on Windows and vice versa — create worktrees on the machine that will use
them, which for `hp-dev` means the owner's.
## Two-agent workflow
**Codex** writes analysis, specs and all product code. **Claude** reviews specs and
@@ -255,15 +234,10 @@ The exchange happens in **issue comments** — there is no local message bus. Ve
format:
```text
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → in-task | #… · Document: …
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → #… · Document: …
```
High blocks. A Medium finding INSIDE the task's scope is fixed within the task:
with no High findings the verdict is yellow, the author fixes it and the fix
passes another review cycle — no separate issue (owner's decision 2026-08-19,
#202: filing and servicing an issue costs far more than fixing in place). Only
a Medium finding OUTSIDE the scope becomes its own issue — foreign scope is
never patched from this branch. Low is fixed or waived with a note
High blocks. Medium must become its own issue. Low is fixed or waived with a note
in the review document. A yellow verdict is legitimate even when every acceptance
criterion passes, if the change does not solve the stated scenario or degrades a
neighbouring one.
@@ -322,22 +296,12 @@ byte-for-byte:
```
cp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
npm run bundle:sync # dist → custom_components + demo/srv/assets (#255)
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
```
During the implementation cycle the fast gates always run. Since 2026-08-14 the
owner's machine also carries Playwright with Chromium (Windows) and a full WSL
environment, which changes one thing (#151): **before moving an issue to
`S7-code-review`, run the smokes named in its AC locally** — `node
demo/smoke_<name>.mjs`. A red smoke that reaches the review costs a cycle; run
locally it costs a minute. Precedent: on #89 a fixture error lived through a
whole review round that a local run would have caught immediately.
The full smoke set, `golden` and `performance_smoke` still belong to the
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
During the implementation cycle only the fast gates run. `smoke`, `golden` and
`performance_smoke` spin up Chromium and belong to the pre-beta run — which is then
mandatory and complete.
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
@@ -358,22 +322,14 @@ complete Linux CI artifact; never accept a partial scenario or images merely to
CI green. See `demo/golden/README.md`.
**Freshness contract**: the embedded fingerprint covers `src/` plus Rollup,
TypeScript and package-lock build inputs. Every browser check must verify it
before trusting a result — benchmarks, golden runs and documentation captures
call `assertFreshDemoBundle` themselves, and smokes get it from `launch()` in
`demo/serve.mjs` (#236). A missing or mismatched fingerprint is a hard failure,
not a warning; `HP_ALLOW_STALE_BUNDLE=1` skips the check for debugging and says
so out loud. A smoke against a stale bundle does not fail cleanly: part of its
assertions go red and part stay green, which reads as a logic defect.
TypeScript and package-lock build inputs. Benchmark and golden tooling must call
`assertFreshDemoBundle` before recording any result; a missing or mismatched
fingerprint is a hard failure, not a warning.
**CI is pinned to an exact SHA.** The release gate accepts only a `completed
success` run for the candidate's SHA, not "the last green one"; a new push cancels
an unfinished Validate for the same branch. Gate jobs, matching the actual
`validate.yml` (#191): `docs`, `provenance`, `process-gate`, `hacs`, `hassfest`,
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
went red after the #113 merge.
an unfinished Validate for the same branch. Jobs: `provenance`, `hacs`, `hassfest`,
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`.
**"Verified" without a named command and its result is not evidence.**
+7 -6
View File
@@ -18,12 +18,13 @@ requests still belong in [issues](https://github.com/Matysh/houseplan-card/issue
## Backlog and work status
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the only
active backlog. An issue owns scope and acceptance criteria; its **labels** own
priority and workflow status — `PROCESS.md` §9 holds the vocabulary. Before
starting planned work, link it to an existing issue or create one, and keep it
current until the verified result is closed. Design specs and ADRs may support an
issue, but they do not replace it or maintain a separate checklist.
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and the linked
[GitHub Project v2](https://github.com/users/Matysh/projects/1) are the
only active project backlog. Issues own scope and acceptance criteria; Project
v2 owns prioritization and workflow status. Before starting planned work, link
it to an existing issue or create one, add it to the Project, and keep both
surfaces current until the verified result is closed. Design specs and ADRs may
support an issue, but they do not replace it or maintain a separate checklist.
## Five-minute setup
+29 -172
View File
@@ -11,7 +11,7 @@
> его не содержал вовсе.
>
> **Приоритет источников.** Канонический бэклог — GitHub Issues; статус живёт в
> метках и больше нигде: Project v2 не используется. При расхождении
> метках, Project v2 остаётся человеческим представлением. При расхождении
> документации с GitHub побеждает GitHub. При расхождении этого документа с
> `.github/workflows/*.yml` и `scripts/*` побеждает **фактическая автоматизация**:
> она исполняется, а описание — нет. Расхождение при этом не игнорируется, а
@@ -35,7 +35,7 @@
| **A. Продукт** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, `src/i18n/*.json`, `custom_components/**/translations/*` | **Да, обязательно.** Только из «Готово к разработке» или дальше |
| **B. Гейты и инструменты** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, весь `.github/**`, `.githooks/**`, `rollup.config.mjs`, `tsconfig*.json`, `package.json`, `package-lock.json`, `pytest.ini`, `.gitignore`, `.gitattributes` | **Да.** Может использовать issue того изменения, которое покрывает; самостоятельная работа над гейтом получает свой issue (тип `tech-debt`) |
| **C. Документация** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md`, `CONTRIBUTING.md`, `PROCESS*.md`, `LICENSE`, `(CODE\|SPEC)-REVIEW-*.md` | Документирование A/B в том же коммите — часть DoD своего issue. Самостоятельная работа над документацией — свой issue |
| **D. Сгенерированное** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` (копия стенда `demo/srv/assets/houseplan-card.js` с #255 не коммитится вовсе) | Никогда не меняется само по себе. Коммит **только** класса D допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью |
| **D. Сгенерированное** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**` | Никогда не меняется само по себе. Коммит **только** класса D допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью |
Практический смысл таблицы: «я только поправил тест» и «я только пересобрал
бандл» перестают быть лазейками.
@@ -86,12 +86,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
### 2.2 Аналитика и оценка
Задача разбирается — и разобранная **сама идёт дальше**. Умолчание изменено
решением владельца 2026-08-14: раньше аналитика ждала подтверждения по каждому
пункту, и большинство ожиданий ничего не меняло — issue в основном описаны
однозначно.
Задача разбирается, продуктовое «да» ещё не дано.
- **Кто:** агент-аналитик. Владелец не утверждает переход — он правит асинхронно.
- **Кто:** агент-аналитик готовит, владелец решает.
- **Чек-лист**, результат — комментарием в issue:
1. дубликаты проверены (ссылки на похожие issue);
2. в скоупе по `docs/SCOPE.md` и `docs/TOUCH-SUPPORT.md`;
@@ -101,21 +98,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
5. приоритет **P1/P2/P3**;
6. тип: баг / фича / техдолг;
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
8. трек: обычный / `small` / `trivial` по критериям §5 и §5.1.
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
согласие**, несогласие он выражает правкой меток или комментарием, и это не
останавливает работу. Право отклонить задачу (`rejected`) остаётся за
владельцем на любой стадии.
- **Вопросов владельцу на этом этапе нет.** Единственный класс вопросов, который
вообще задаётся владельцу, — продуктовые (§7.1: что человек видит или делает,
объём видимых изменений), и их место — этап ТЗ, пачкой, с вариантами по
умолчанию и `blocked`. Вопрос, который можно отложить до ТЗ, не задаётся в
аналитике; вопрос, не блокирующий написание ТЗ, не задаётся вовсе — вместо
него в ТЗ пишется блок принятых предположений.
- **Выход:** `S3-spec` — переход выполняет сам аналитик, не дожидаясь ответа.
Либо, при явном конфликте со `SCOPE.md`, — предложение отклонить с причиной:
это единственный случай, когда аналитика останавливается и ждёт владельца.
8. лёгкий трек — да/нет по критериям §5.
- **Приоритет и ценность — поля владельца.** Агент предлагает, владелец
утверждает; иначе агенты приоритизируют сами и P1 разрастается.
- **Выход:** «ТЗ в работе» либо «Отклонено» с записанной причиной.
### 2.3 ТЗ в работе — написание ТЗ
@@ -131,14 +117,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
Его задача — не согласиться, а найти, где ТЗ не выполнимо или не проверяемо.
- **Артефакт:** `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, вердикт
зелёный / жёлтый / красный. Лёгкий трек — комментарий в issue.
- **High-находки блокируют.** Medium **в скоупе задачи** чинится в текущем
issue: без High это жёлтый вердикт, автор правит ТЗ, фикс проходит повторный
цикл. Medium **вне скоупа** — отдельный issue: чужой скоуп в этой задаче не
правится. «Оставили в тексте ревью» не считается закрытием ни для одной
(решение владельца 2026-08-19, #202: отдельный issue дороже правки на месте).
Low либо правится, либо снимается решением ревьюера с записью.
- **High-находки блокируют.** Medium/Low — либо правятся, либо становятся
отдельными issue со ссылкой; «оставили в тексте ревью» не считается закрытием.
- **Выход:** «Готово к разработке» либо возврат в «ТЗ в работе» — не более
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
4 циклов (§4).
### 2.5 Готово к разработке (DoR)
@@ -191,11 +173,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
коду с явной записью «проверено чтением, не исполнением».
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
Medium **вне скоупа** — отдельный issue (#202).
- **High блокируют.** Medium **обязаны** превратиться в issue.
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
4 циклов (§4).
### 2.8 Закрытие после выпуска беты
@@ -216,42 +196,6 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
- **Отклонено:** закрытие с записанной причиной (вне скоупа, дубликат, цена не
оправдана). Тихое закрытие без причины запрещено.
### 2.10 Повторный раунд ревью — объём по дельте
Решение владельца 2026-08-19 (issue #214). Относится и к ревью ТЗ, и к
код-ревью, начиная со второго цикла.
**Предмет повторного раунда — дельта, а не задача целиком.** Раньше объём
разбора не был оговорён, промпт ревьюера для всех раундов был одинаковым, и
повторный цикл заново выводил продуктовую рамку и перепроверял AC, которых
правка не касалась: r2 по #150 стоил полного прогона конвейера ради одной
строки в тестовой фикстуре.
Порядок:
1. найти вердикт предыдущего раунда и **SHA, на котором он получен**; SHA в
вердикте не назван — это находка;
2. объявить дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла ТЗ либо тела
issue для этапа ТЗ;
3. по каждой находке предыдущего раунда показать, **чем именно она закрыта** —
строкой кода или текста, а не заявлением автора;
4. заново проверять только те AC, чьё доказательство дельта задевает;
5. **раздел «Унаследовано из r<N−1>»** обязателен: что принято без повторной
проверки, со ссылкой на документ того раунда и SHA. Без перечня сокращение
превращается в молчаливое доверие.
Дешёвые гейты (`typecheck`, `test`, `build` со сверкой копий бандла) гоняются в
каждом раунде: код изменился, а стоят они минуты. Тяжёлые — по дельте (§10.2).
**Разбор остаётся полным**, если дельта не локальна: ребейз на ушедший вперёд
`dev` (после ребейза это другой код, §7.2), смена контракта поведения, задета
новая подсистема, либо объём дельты сопоставим с исходной задачей.
Сокращается объём **разбора, а не строгость**: правка по замечанию способна
сломать AC, который предыдущий раунд признал выполненным — так появилась
регрессия #102. Граница не «только находки», а «находки плюс всё, до чего
дотягивается дельта».
---
## 3. Правила
@@ -273,10 +217,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
ревью-гейт.
7. **Ревью возвращает не более 4 раз.** Пятый заход — решение владельца: разделить,
отклонить или арбитраж (§4).
8. **High блокирует. Medium в скоупе чинится в текущем issue** (без High —
жёлтый вердикт и повторный цикл); Medium вне скоупа становится отдельным
issue (#202). Low либо правится, либо снимается решением ревьюера с записью
в документе.
8. **High блокирует. Medium становится issue.** Low либо правится, либо снимается
решением ревьюера с записью в документе.
9. **Скоуп не расширяется.** Всё найденное вне ТЗ — новый issue, а не попутная
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
@@ -307,24 +249,11 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
## 4. Лимит циклов ревью: 4
Оба ревью-гейта возвращают задачу на правки не более **4 раз**.
Оба ревью-гейта возвращают задачу на правки не более **4 раз**. Счётчик виден в
имени документа: `-r1` … `-r4`; на четвёртом заходе ставится метка `review-4`.
- **Что считается циклом:** отправка на ревью → вердикт с блокирующими находками
→ возврат. Уточняющий вопрос без вердикта циклом не считается.
- **Зелёный вердикт цикла не образует** и бюджет не тратит (решение владельца
2026-08-20, issue #227): он ничего не вернул на правки. Практический случай —
зелёное ревью, слияние которого не удалось: конвейер сам предписывает ребейз и
возврат метки, и этот заход не должен наказываться. Раньше счётчик считал все
вердикты подряд, и на #225 последовательность жёлтый → зелёный → ребейз дала
`review-4` на задаче с зелёным ревью и зелёным CI.
- **Заход и цикл — разные величины.** Заход — сколько раз ревью отработало; он
виден в имени документа (`-r1`, `-r2`, …) и нужен, чтобы два документа не
затёрли друг друга. Цикл — единица бюджета §4. Заходов законно бывает больше,
чем циклов, поэтому порог проверки 7 в `scripts/process-gate.mjs` выше лимита
циклов (шесть документов = четыре цикла плюс два ребейза).
- Метка `review-4` ставится, когда исчерпан **бюджет циклов**; конвейер снимать
её не вправе — это решение владельца. Если бюджет пересчитан и оказался ниже
лимита, конвейер сообщает пересчёт, но метку не трогает.
- **Исчерпание лимита — не «пятая попытка», а разбор.** Задача уходит владельцу,
решение одно из трёх:
1. **разделить** — issue закрывается как «заменён», вместо него 2–3 меньших с
@@ -501,12 +430,8 @@ issue #NN
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · заход r<N> ·
блокирующих циклов K/<лимит> · High: N · Medium: N → в задаче | #… ·
Документ: docs/reviews/…`
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору.
Заход — номер прогона ревью, K — израсходованный бюджет §4: зелёные вердикты
его не тратят, поэтому заход и K расходятся, #227)
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · цикл r<N>/<лимит> ·
High: N · Medium: N → #… · Документ: docs/reviews/…`
- **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>`
**Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору;
@@ -537,58 +462,23 @@ issue #NN
npx tsc --noEmit
npm test
npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js \
# копия стенда собирается `npm run bundle:sync`, в репозитории её нет (#255)
node scripts/smoke-select.mjs --base origin/dev --head HEAD # какие смоки относятся к диффу
&& cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
node demo/smoke_<целевые>.mjs
npm run golden:verify # если менялся визуал
node scripts/check-docs.mjs # если менялся src/**
node scripts/model-invariants.mjs --config <экспорт> # если правилась геометрия или ссылки
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
```
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла, а при
любом diff'е по `src/**` — ещё и `node scripts/check-docs.mjs`. По
необходимости, определяемой diff'ом и AC: браузерные смоки (сколько их —
считает `ls demo/smoke_*.mjs | wc -l`, вшитое число здесь трижды отставало от
дерева; прогон всех уместен только когда задача задевает всё; какие относятся к
диффу, печатает
`node scripts/smoke-select.mjs --base origin/dev --head HEAD`, и его вывод
прикладывается к ревью вместе с решением по каждой строке), `golden:verify` при изменении видимого
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла. По
необходимости, определяемой diff'ом и AC: браузерные смоки (их 127 — прогон всех
уместен только когда задача задевает всё), `golden:verify` при изменении видимого
результата, `pytest tests_backend` при правках в Python, performance-профили при
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
Скриншоты снимаются **только** джобой `Docs screenshots` (`workflow_dispatch`) и
принимаются локально: `npm run docs:accept -- --reviewed --from=<распакованный
артефакт>` (#246). Съёмка на своей машине даёт байтово другой PNG при том же
кадре, и набор из «не того» браузера переписывает все десять файлов без единого
содержательного изменения. Приёмка отказывает, если кандидат снят не с этого
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит делает
человек.
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
всегда попадает, и решать нечего. Цена пропуска измерена: скриншоты не
пересняли в #230 и #234, и `dev` стоял с красным job `docs`, пока это не нашли
при следующей задаче (#237). Пересъёмка — `npm run build && node
demo/docs/capture.mjs`, коммит вместе с задачей.
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
пропуском.
**Одно число — один источник.** Любая величина, которую пользователь видит
дважды — превью против записи, подпись против площади, подсветка инструмента
против сохранённого значения, — обязана считаться в одном месте. Три дефекта
подряд имели ровно эту причину: #234 (резинка показывала 12 см, запись хранила
24), #233 (подпись мерила по осевым линиям, площадь рядом — по полу) и способ,
которым #234 нашли (подсветка «Толщины» врала согласованно с записью). Ревьюер
отвечает на вопрос прямо: какое число в этом диффе видно дважды и один ли у него
источник. Механическая часть правила закреплена тестом
`test/single-source-numbers.test.mjs` — строку с единицей измерения собирает
только канонический форматтер; смысловая часть остаётся за ревью.
**Гейт беты** (условие закрытия issue): CI Validate зелёный на точном SHA тега.
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
@@ -602,12 +492,9 @@ Performance зелёные на точном SHA; статусов issue не к
## 9. Метки — канонический статус
Статус читается из меток: их видно в списке issue, их читает любой токен с
доступом к Issues, и по ним же работает конвейер — смена метки порождает событие
(§10.4). **Project v2 не используется** (решение владельца 2026-08-14): второе
представление статуса рядом с метками требовало отдельного скоупа токена,
синхронизации и внимания, а давало вид доски. Два источника одного факта
расходятся — это уже случалось с колонкой «Статус ТЗ» в `docs/specs/README.md`.
Статус читается из меток: их видно в списке issue и их читает любой токен с
доступом к Issues, в отличие от Project v2, который требует отдельного скоупа.
Project v2 остаётся человеческим представлением и синхронизируется по меткам.
**Имена меток английские** (решение владельца 2026-08-12). Русские имена в этом
документе были только на бумаге; репозиторий с самого начала жил на английских.
@@ -684,13 +571,6 @@ Performance зелёные на точном SHA; статусов issue не к
считается от `merge-base` с `origin/dev`, а не от начала истории — иначе в него
попали бы все нарушения, совершённые до появления гейта.
При возврате `main` в `dev` диапазон merge-коммита содержит второй родитель —
уже опубликованные в `main` коммиты с закрытыми issue. Для destination `dev`
общий скрипт pre-push/CI исключает только SHA, доказанно достижимые из
`origin/main`; сам merge и новые post-merge коммиты остаются под всеми
проверками. На `main`, beta/issue-ветки и обычный push в `dev` это исключение
не распространяется (issue #155).
Проверка статуса issue требует `gh`, поэтому при его отсутствии хук печатает
предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук,
который не работает в самолёте, отключают целиком, а строгий проход всё равно
@@ -782,8 +662,8 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
```
Ревьюер — `anthropics/claude-code-action`. Он читает `docs/SCOPE.md`, `AGENTS.md`,
этот документ и тело issue, публикует разбор комментарием, заводит issue на Medium-находки
вне скоупа задачи (#202), кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
этот документ и тело issue, публикует разбор комментарием, заводит issue на каждую
Medium-находку, кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
структурированным JSON. **Метку переставляет отдельный детерминированный шаг по
вердикту, а не модель.**
@@ -813,28 +693,6 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
никто не может выйти и о котором никто не узнает, для конвейера хуже громкой
ошибки.
**Ветка приводится к `dev` до ревью, а не после** (#257). Раньше ревью читало ветку
как есть, а слияние делало ребейз — проверенный SHA и слитый SHA были разными
коммитами. Пока расхождение с `dev` текстовое, ребейз упирается в конфликт и это
видно; смысловое расхождение git склеивает молча, и в `dev` уезжает комбинация,
которую ревьюер не читал. Именно так пришёл регресс #234. Шаг перед ревью делает
одно из трёх:
- ветка уже содержит весь `dev` — ничего;
- отстала и ребейзится чисто — ребейз, `push --force-with-lease`, ревью по
приведённому состоянию. Факт ребейза передаётся в промпт, чтобы сработало
правило §7.2 о полном разборе вместо дельты;
- конфликт — возврат в `S6-in-progress` **до** запуска ревью. Цикл при этом не
расходуется: код никто не читал, вердикта нет.
Проверка стоит до ревью не только ради совпадения SHA. Конфликт всё равно вернул бы
задачу, но обнаруживался он после сорока пяти минут работы ревьюера и потраченных
лимитов подписки, хотя виден за пять секунд до них.
`--force-with-lease` здесь обязателен с явным ожидаемым значением: между чтением
ветки и пушем автор мог запушить коммит, и слепой `--force` потерял бы его молча.
Расхождение lease — падение прогона, а не предупреждение.
Поэтому зелёное код-ревью с неудавшимся слиянием ведёт не в `S8-merged`, а в
`S6-in-progress`: работа действительно вернулась к автору, только осталась не
правка кода, а ребейз. Вердикт при этом в силе, переделывать нечего. После ребейза
@@ -929,8 +787,7 @@ Golden, браузерные смоки, performance и полный HA-харн
- принятие golden-эталонов ради зелёного CI или по частичному артефакту;
- закрытие issue до выпуска беты с зелёным CI;
- переоткрытие закрытого issue вместо нового бага;
- Medium-находки, оставленные как TODO в документе ревью: в скоупе — чинятся
в текущем issue, вне скоупа — становятся отдельным (#202);
- Medium-находки, оставленные как TODO в документе ревью;
- **параллельные бэклоги** в файлах (`BACKLOG-*.md`, «планы» в docs);
- ревью-документы вне репозитория;
- попутные правки «раз уж я здесь»;
+325 -103
View File
@@ -1,157 +1,379 @@
# 🏠 House Plan — a live home map for Home Assistant
# 🏠 House Plan — interactive floor plan card for Home Assistant
[![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases)
[![GitHub stars](https://img.shields.io/github/stars/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/stargazers)
[![CI](https://github.com/Matysh/houseplan-card/actions/workflows/validate.yml/badge.svg)](https://github.com/Matysh/houseplan-card/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Live demo](https://img.shields.io/badge/demo-try_it_live-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
[![Telegram chat](https://img.shields.io/badge/Telegram-chat-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
📘 **[Full user guide](docs/USER-GUIDE.md)** · 🇷🇺 **[Русский](README.ru.md)** · 🗂 **[Project issues](https://github.com/Matysh/houseplan-card/issues)**
**Turn Home Assistant into a live, interactive map of your home.** Upload or draw
a floor plan, outline the rooms with your mouse — and every smart device appears
in its real place: live states, tap-to-toggle lights, temperature and humidity per
room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wall
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
right on your Lovelace dashboard.
<!-- docs-section: overview -->
> **Use a desktop computer to edit plans.** View and kiosk are fully supported
> on phones and tablets. The editors are designed primarily for a desktop
> browser with a mouse and keyboard; individual editing operations on touch
> devices may be awkward, limited, or unavailable.
## Your whole home at a glance
![Interactive Home Assistant floor plan: live rooms, devices, lights and climate on a real floorplan card](docs/images/demo.gif)
House Plan turns Home Assistant into a live map of your home. Upload a plan or
draw rooms directly on the dashboard, bind them to Home Assistant areas, and
the area's devices appear automatically. You can immediately see where a light
is on, a door is open, a room is too cold, Zigbee signal is weak, or a leak
sensor has fired.
> ### 🚀 Try it live — no install needed
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — a real Home Assistant
> with a ready-made plan. Log in as **`demo`** / **`demo`** and click anything:
> toggle lights, open the editors, break things. The stand resets itself to a
> pristine state every hour.
![Synthetic home in View mode with rooms, devices, light and climate](docs/images/01-view-desktop.png)
🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan)
Setup is entirely graphical: no floor-plan YAML, Inkscape, or external editor.
Plan data and device positions live on the Home Assistant server and stay in
sync across screens.
**Feature highlights**
> **Edit on a desktop computer.** View and kiosk are fully supported on phones
> and tablets. The editors are designed primarily for a mouse and keyboard;
> individual touch editing operations may be awkward or unavailable. See the
> exact [touch support contract](docs/TOUCH-SUPPORT.md).
- ♾️ **An infinite canvas** — there is no "plan size" and no edge to run
past: draw and place devices anywhere, pan at any zoom, zoom out to see
everything, and let one tap fit the whole plan back on screen.
- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
virtual walls and a visual decor layer, all drawn with clicks; room resize
by dragging walls, with live lengths and areas as you drag; smart
alignment guides and a live ruler in real meters/feet.
- 🖼 **A backdrop you can move and scale** — drag the floor-plan picture into
place and pull a corner to size it, with its real size in metres shown as
you drag, so the drawing and the photo of your plan finally line up.
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
whole groups of lights (works for dumb switches and stateless remotes too).
- 🌒 **“Light sources” fill** — a dark house where every lit lamp lights exactly
the floor it can see: through doorways and open boundaries, stopped by walls,
columns and partitions, which cast real shadows.
- ☀️ **The sun on the plan** — set the compass and the backdrop lives with
the day (white noon → golden hour → deep night), while windows on exterior
walls cast real wedges of sunlight into the rooms; optional cloud cover
from a weather entity.
- 🪟 **Curtains and blinds open on a tap** — one action opens, closes or
stops a cover, and the icon itself morphs between open and closed while a
soft ring pulses as it travels.
- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
comfort-range temperature fills, per-room signal heatmap.
- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
explicit button, never an accidental tap.
- 📺 **Kiosk mode** for wall tablets and TVs: fullscreen, swipe between floors,
auto-carousel, per-screen icon sizes.
- 🤖 **Live robot vacuums** — the dock marker stays put while a round puck
drives the plan in real time, pouring its path out from under itself;
current and previous cleanup runs are recorded server-side. Calibration is
one click (rooms matched by name) or a drag-and-stretch overlay. A diagnostic
source picker also covers registry-less map cameras without silently
rebinding broken sources. Works with Xiaomi Cloud Map Extractor, Tasshack
dreame-vacuum and Valetudo.
- 🔔 New devices appear automatically with a red “new” dot; the layout is stored
**server-side** — one shared plan for every user and screen, synced live.
<!-- docs-section: features -->
---
## What House Plan provides
## What it is and why
- **Live state and safe actions.** Lights and other safe devices can toggle from
the plan; a lock cannot be opened by an accidental plan tap.
- **Three built-in editors.** Plan creates rooms, walls and openings; Device
places and configures markers; Background adds lines, labels and furniture.
- **Area-aware rooms.** New devices appear automatically, while room cards can
show temperature, humidity, light state and average LQI.
- **Light and environment.** Room fills, lamp Glow, wall shadows, a day-cycle
backdrop and sunlight through windows.
- **Doors, windows, gates and vacuums.** Openings follow real contacts and locks;
a robot can show its position, dock and travelled path.
- **Several floors and screens.** Space tabs, swipe navigation, local viewport,
and a separate initial floor for each card.
- **Wall-display kiosk.** A plan-only view with fullscreen navigation and icon
sizes saved for that display.
House Plan shows your smart home the way it actually looks — on a floor plan. Instead of long lists of entities, you see rooms and devices in their real places: where the leak is, what the temperature is in the kids' room, whether the light is on in the hallway, whether the gate is open.
![The same synthetic home in touch View mode](docs/images/02-view-touch.png)
This is convenient when:
<!-- docs-section: first-run -->
- you have many devices and lists are awkward to use;
- you need to grasp the state of the house "at a glance";
- you want to give access to family members — anyone can figure out a picture;
- you want a beautiful overview screen for a wall-mounted tablet.
## Your first working room
The integration consists of two parts that are installed together:
1. Install the integration and add the card to a dashboard.
2. Create the first **space**: upload SVG/PNG/JPG/WebP, reuse an uploaded image,
or choose no image and draw the plan by hand.
3. In Plan, select **Room outline**, place vertices, and click the first point to
close the outline.
4. Name the room and bind it to a Home Assistant area. Use “No area” for a room
that has no devices.
5. Open Device: devices from the bound area are already placed; drag their
markers to the correct positions.
6. Optionally use Background for lines, text and furniture.
7. Return to View. The plan now displays live state and accepts safe actions.
- **the Lovelace card** `houseplan-card` — the interactive plan itself;
- **the server-side component** — stores the room markup and icon positions in Home Assistant, so the plan is identical in all browsers and on all devices.
![Creating the first space](docs/images/03-space-create.png)
---
![Closing a room outline on its first point](docs/images/04-room-contour-close.png)
## How it differs from alternatives
![A selected partition and the Plan context tray](docs/images/05-plan-context-tray.png)
A house plan in Home Assistant is usually built with `picture-elements`,
`ha-floorplan`, or newer GUI cards that draw walls and furniture in the
dashboard. Those either lock you into YAML/SVG, or store the plan in the
Lovelace card config. House Plan is a **shared live map** backed by a Home
Assistant integration:
![Device settings with binding provenance and the exact action result](docs/images/06-device-editor.png)
| | House Plan | picture-elements / ha-floorplan | GUI draw cards (e.g. easy-floorplan) |
|---|---|---|---|
| **Setup** | Entirely through the UI, with the mouse | Manual YAML / Inkscape SVG | In-card drawing of walls & furniture |
| **Adding devices** | Automatic, by HA **area** | You type every entity by hand | Place entities by hand on the drawing |
| **Icon coordinates** | Drag with the mouse | Count pixels into YAML | Drag on the canvas |
| **Room markup** | Built-in outline editor bound to areas | External SVG editor | Draw walls yourself (furniture CAD) |
| **Storage** | On the HA server (`.storage`, shared, multi-client) | In the dashboard YAML | In the card / dashboard YAML |
| **Overlays** | Glow, climate, LQI, sun, vacuums, kiosk | Whatever you script in SVG/CSS | Varies by card |
| **Zoom** | Smooth vector zoom | Usually a fixed image | SVG / virtual canvas |
![Live presentation preview for the same device](docs/images/06-device-display-preview.png)
**One sentence:** House Plan is the shared, area-aware live map of your home —
not a general-purpose CAD package. Its Background editor covers practical
decor, labels and furniture; if you need unrestricted architectural drafting,
a draw-centric tool may fit better. With a plan and HA areas, House Plan keeps
every tablet on the same live layout.
Every workflow and edge case is in the [full user guide](docs/USER-GUIDE.md).
The [Background editor contract](docs/DECOR-EDITOR.md) and
[vacuum guide](docs/VACUUM.md) are the authorities for those subsystems.
Key advantages in short:
<!-- docs-section: installation -->
- **No code at all.** Everything — spaces, rooms, devices — is configured with clicks.
- **Automatic device placement.** Outline a room and bind it to a Home Assistant area — the devices of that area appear on the plan by themselves.
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
a light is shining, a socket is powering, a fan is spinning, a vacuum is
cleaning, a radiator valve is actually heating (not merely enabled). For climate integrations,
a reported work action is authoritative; when an integration exposes only its enabled HVAC mode,
that mode is the best available fallback. Orange = open / unlocked.
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
A translucent icon = unavailable. Dark = idle.
- **A coherent visual Background editor.** Draw lines/shapes, place labels and
furniture, edit physical styles and transform every object with the same
selection model. The plan image has its own move/resize/rotate tool, numeric
properties and shared Undo/Redo.
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
---
## Wall tablet / TV (kiosk mode)
Add the card to a dedicated dashboard with a **panel view** and set `kiosk: true`
(or tick "Wall device (kiosk) mode" in the card editor):
```yaml
type: custom:houseplan-card
kiosk: true
cycle: 0 # seconds between auto space switches, 0 = off (nice for TVs)
```
No header, no editors — just the live plan. Swipe to change floors (at 1:1),
pinch to zoom, double-tap to reset. Long-press an empty spot for 3 seconds to
tune icon and text sizes for THIS screen (saved per device). To hide Home
Assistant's own header use the companion app's kiosk settings or the
[kiosk-mode](https://github.com/NemesisRE/kiosk-mode) plugin.
## Installation
### HACS
One click if you already run HACS:
[![Open the repository in HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
1. In HACS open **⋮ → Custom repositories**.
2. Add `https://github.com/Matysh/houseplan-card` as an **Integration**.
3. Install House Plan and restart Home Assistant.
4. Open **Settings → Devices & services → Add integration → House Plan**.
The card is registered automatically. If you manage Lovelace resources
manually, use the URL served by the integration:
### Via HACS (recommended)
```yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
```
1. Open **HACS → menu (⋮) → Custom repositories**.
2. Paste the URL of this repository, set the category to **Integration**, and click **Add**.
3. Find **House Plan** in the list, install it and **restart Home Assistant**.
4. Go to **Settings → Devices & Services → Add integration** and select **House Plan**.
Do not use the on-disk path inside `custom_components`; Home Assistant does not
serve that path as a JavaScript module.
The card is registered automatically — no need to add a Lovelace resource manually.
### Manual installation
> **Card doesn't load (`Custom element doesn't exist: houseplan-card`) or you manage Lovelace
> resources in YAML?** Add the resource manually pointing at the URL the integration *serves*:
>
> ```yaml
> resources:
> - url: /houseplan_files/houseplan-card.js
> type: module
> ```
>
> Do **not** use `/custom_components/houseplan/frontend/houseplan-card.js` — that is the file
> on disk, which Home Assistant does not serve over HTTP (you'll get a `text/plain` MIME error
> and the element never registers). The correct, integration-served URL is
> `/houseplan_files/houseplan-card.js`. Both cards (`houseplan-card` and
> `houseplan-space-card`) ship in that one file — no separate resource is needed.
Copy `custom_components/houseplan` to `config/custom_components`, restart Home
Assistant, and add the House Plan integration.
### Manually
### Add the card
1. Copy the `custom_components/houseplan` folder into the `config/custom_components` directory of your Home Assistant.
2. Restart Home Assistant.
3. Add the integration: **Settings → Devices & Services → Add integration → House Plan**.
Create a dashboard view (Panel works best) and add the card in the UI or as:
### Adding a plan screen
Create a new dashboard tab (a "Panel" view works best) and add the card:
```yaml
type: custom:houseplan-card
title: House plan
```
Different screens may start on different spaces:
Nothing else needs to be specified — everything else is configured right on the screen.
```yaml
type: custom:houseplan-card
default_floor: ground
```
---
All cards share server-side rooms and coordinates. Current mode, viewport and
selected space remain local to the screen. Revision checks and live sync cover
concurrent clients, but avoid editing the same object in two browsers at once.
## How to use
## Detailed documentation
### Step 1. Add a space (floor)
- [Full user guide](docs/USER-GUIDE.md)
- [Mouse/touch/keyboard matrix](docs/USER-GUIDE.md#6-navigation-zoom-and-input)
- [Plan tools](docs/USER-GUIDE.md#plan-tools-at-a-glance)
- [Background editor](docs/DECOR-EDITOR.md)
- [Robot vacuums](docs/VACUUM.md)
- [Touch support](docs/TOUCH-SUPPORT.md)
On first open the plan is still empty — House Plan immediately offers to create the first space.
If your Home Assistant already has **floors** configured, a wizard offers to create a space
for each floor (names prefilled, a plan image is asked for one by one; any floor can be skipped).
<!-- docs-section: support -->
![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
## Support and feedback
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions by default and can be moved, resized or rotated at any time in the Background editor.
- Questions and plan examples: [Telegram @ha_houseplan](https://t.me/ha_houseplan).
- Bugs and proposals: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues).
- Before reporting, update House Plan, restart HA and hard-refresh the page.
Include the version, browser, logs and reproduction steps; private entity IDs
may be replaced with fictional ones.
![Space creation dialog](docs/images/03-space-dialog.png)
Documentation screenshots are produced by the reproducible
`npm run build && node demo/docs/capture.mjs` command using synthetic data only. Scenario version,
source fingerprint and every image hash are recorded in the
[screenshot index](docs/images/screenshots.json).
> 💡 You can draw the background in any floor planner (for example, REMPLANNER) or photograph a paper plan. SVG works best — it stays crisp when zoomed in.
License: [MIT](LICENSE).
Later you can add as many spaces as you like (floors, yard, garage) with the **+** button next to the tabs.
### Step 2. Outline the rooms
After the first space is added, the card switches to the **Plan** tab by itself. The card has three mode tabs in the header — **View** (default: display and device control only, nothing can be moved or edited), **Plan** (rooms, openings, labels, space settings) and **Devices** (placing and configuring markers); the edit tabs are shown to administrators. In Plan, click grid points, connecting them with lines, and close the room outline by clicking the first point.
As soon as the outline is closed, the room-save dialog appears. Here you need to **bind the room to a Home Assistant area** — this is exactly what enables the automation. For utility rooms with no devices (hall, sauna) there is a **"No area"** button.
![Marking up a room and saving it](docs/images/05-room-dialog.png)
While drawing, a ruler follows the cursor showing the current segment's real length (metres, or feet + inches on an imperial Home Assistant). The scale is set per space — the **"Scale (grid cell size)"** field in the space dialog says how many centimetres one grid cell represents (default 5 cm).
Rooms may not overlap: a click strictly inside an existing room, or an outline that would swallow one, is refused. Two more tools help you reshape the plan later:
- **Merge** — click a room, then a neighbour that shares a wall; they fuse into one. A dialog picks which name and area survive.
- **Split** — click a room, then two points on its walls; the chord cuts it in two. The bigger part stays the room it was (name, area, devices); the smaller one asks for a new name and area.
### Doors, windows, gates and locks
In markup mode the **"Opening"** tool places doors, windows and gates: click next to a wall and the
opening snaps onto it. Pick the type, the **length in real centimetres** (defaults: door 90 cm,
window 120 cm, gate 300 cm), an open/close sensor and — for doors and gates — a **lock entity**.
With a sensor bound, the plan comes alive: the door leaf swings on its hinge and the swing arc
draws itself in as the real door opens; a window opens its two casements. While open, the moving
parts take an accent colour. A gate keeps a 3–4 m opening compact on the plan: two half-width
leaves open only 10° outwards, without a full-width swing arc, while contact, lock and light
passage work exactly like a door. A door or gate with a lock shows a padlock badge next to it — green when
locked, orange when unlocked. For safety the lock can **not** be toggled from the plan; a click
on the opening shows a status card with both states instead.
Openings are easy to adjust later: hovering one highlights it, you can **drag it along the
walls** (it slides around corners too), and a **double click opens its properties**.
### Step 3. Devices appear by themselves
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Hidden and disabled"**: user-hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light. A device disabled in Home Assistant appears there as a labelled grey service ghost and is excluded from all plan data/actions until it is enabled in HA again.
From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
![Device card on click](docs/images/08-info.png)
### Step 4. Zoom
The mouse wheel or the **- / ⊹ / +** buttons zoom the plan in and out; on a touch screen the two-finger pinch works. Zoomed out you see the whole plan, zoomed in you see the details, and everything stays crisp. The zoom level is remembered separately for each space.
![Zoomed-in plan — everything stays crisp](docs/images/09-zoom.png)
### Step 5. Put the icons in their places
Switch to the **Devices** tab to arrange icons: drag them with the mouse, click one to open its editor. In **View** mode nothing can be moved — panning the map never displaces a sensor (a top user request). Positions are saved on the server and are identical in all browsers and devices. The **↺** button restores the automatic layout.
![Dragging icons — available at all times](docs/images/06-edit.png)
### Tap actions: control devices from the plan
By default a tap on an icon opens its info card. A device can instead use the
universal **Toggle state** action. Its editor shows the exact entity or configured
group, the current state and what the next tap will do; when nothing can be toggled,
it says so and the tap is a quiet no-op rather than an unexpected info-card fallback.
Lights keep their convenient toggle default. Covers and valves use open/close/stop
semantics automatically, while locks, alarm panels and secure garage/door/gate covers
remain blocked. An exact entity binding never falls through to a sibling switch, and
temporarily unavailable group members are skipped without erasing the configuration.
A **long press** still opens the info card and right-click still opens HA more-info.
### Icon rules
Which MDI icon a device gets is decided by **icon rules** — editable right in the card
(the ⬡ button in the header): an ordered list of “name pattern → icon” regexes with a
live test field, bilingual defaults (EN/RU) and a one-click reset. When no rule
matches, the entity *device class* decides (thermometer for temperature sensors, etc.).
### Step 6. Adding your own devices manually
You can also place a **single entity** (not just a whole device): start typing in the binding search and individual entities appear next to devices — handy when one device exposes several values (e.g. temperature and humidity) and you want each as its own icon.
Not everything has to be left to the automation. With the **+** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
To represent a dumb physical lamp controlled by a smart relay, place a virtual
point where the lamp really is and set **Light source → Always**. Manual colour,
brightness and radius stay available even though the point has no HA entity.
Then open the relay and add that plan source under **Controls other light
sources**. The relay continues to show the aggregate working state, while Glow,
room fill and statistics belong to the lamp's position. An unlinked passive
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
**icon size** (×0.5–3) and **rotation** are also per-device, so a wall valve can be small and
turned the way it is mounted.
![Adding a device manually](docs/images/07-marker-dialog.png)
### Styling the plan with card-mod (advanced, unsupported)
The card ships finished and has no CSS field of its own — but if you already run [card-mod](https://github.com/thomasloven/lovelace-card-mod), every object on the plan now carries a stable hook you can aim at: `data-hp="device"` (plus `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. We promise not to rename them; we do not ship card-mod, do not support it, and are not responsible for what your CSS does to the card. The full table, the examples and the limits are in **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
---
## Uninstalling
1. Remove the card (or the tab with the plan) from the dashboard.
2. **Settings → Devices & Services → House Plan → Delete** the integration entry.
3. Remove the integration from **HACS** (or delete the `custom_components/houseplan` folder if installed manually) and restart Home Assistant.
4. Optionally delete the saved plan data: the `config/houseplan/` files (backgrounds and attachments) and the `houseplan.config` / `houseplan.layout` entries in the `config/.storage` directory.
---
## Getting help & sharing your plan
- 💬 **[Telegram chat — @ha_houseplan](https://t.me/ha_houseplan)** — questions,
setup help, feature ideas, and screenshots of your plans. The fastest way to
reach the author and other users.
- 🐞 [GitHub issues](https://github.com/Matysh/houseplan-card/issues) — bug
reports and feature requests (please attach your House Plan version).
- 💡 [GitHub discussions](https://github.com/Matysh/houseplan-card/discussions) —
longer-form ideas.
- 📜 [Changelog](docs/CHANGELOG.md) — what changed in every version
([на русском](docs/CHANGELOG.ru.md)).
When reporting a problem, the version number helps a lot: it is shown in the
browser console on load (`HOUSEPLAN-CARD vX.Y.Z`) and in **Settings → Devices &
Services → House Plan**.
---
## Frequently asked questions
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. Open the device editor and press **"Hidden and disabled"**: a blue ghost is user-hidden and can be shown; a grey disabled ghost must first be enabled in Home Assistant.
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
**Is the data stored in the cloud?** No. Everything is stored locally in your Home Assistant.
---
<p align="center"><sub>Screenshots were taken on a real Home Assistant configuration.</sub></p>
+297 -107
View File
@@ -1,161 +1,351 @@
# 🏠 House Plan — живой план дома для Home Assistant
# 🏠 House Plan — интерактивный поэтажный план дома для Home Assistant
[![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases)
[![CI](https://github.com/Matysh/houseplan-card/actions/workflows/validate.yml/badge.svg)](https://github.com/Matysh/houseplan-card/actions)
[![GitHub stars](https://img.shields.io/github/stars/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/stargazers)
[![Live demo](https://img.shields.io/badge/демо-попробовать-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
[![Telegram chat](https://img.shields.io/badge/Telegram-чат-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
📘 **[Полное руководство](docs/USER-GUIDE.ru.md)** · 🇬🇧 **[English](README.md)** · 🗂 **[Задачи проекта](https://github.com/Matysh/houseplan-card/issues)**
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · 🗂 **[Беклог проекта](https://github.com/users/Matysh/projects/1)**
<!-- docs-section: overview -->
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
своих местах: живые состояния, свет по клику, температура и влажность по
комнатам, карта Zigbee-сигнала, светящиеся пятна ламп и полноэкранный
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
редакторов — весь план настраивается прямо на дашборде.
## Дом целиком — одним взглядом
> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
> операции могут быть менее удобны, работать ограниченно или отсутствовать.
House Plan превращает Home Assistant в живую карту дома. Загрузите изображение
плана или нарисуйте комнаты прямо на дашборде, свяжите их с зонами Home
Assistant — и устройства появятся на плане автоматически. Сразу видно, где
горит свет, открыта дверь, слишком холодно, слабый Zigbee-сигнал или сработал
датчик протечки.
![Интерактивный план дома для Home Assistant: комнаты, устройства, свет и климат на реальном поэтажном плане](docs/images/demo.gif)
![Синтетический дом в режиме просмотра: комнаты, устройства, свет и климат](docs/images/01-view-desktop.png)
> ### 🚀 Попробовать вживую — без установки
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — настоящий Home
> Assistant с готовым планом. Вход **`demo`** / **`demo`**, можно нажимать всё:
> включать свет, открывать редакторы, ломать что угодно. Каждый час стенд сам
> возвращается в исходное состояние.
Настройка выполняется в графическом интерфейсе: без YAML-разметки, Inkscape и
внешнего редактора плана. Данные плана и расположение устройств хранятся на
сервере Home Assistant и синхронизируются между экранами.
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
> **Редактируйте на компьютере.** Режим просмотра и киоск полноценно работают
> на телефонах и планшетах. Редакторы рассчитаны прежде всего на мышь и
> клавиатуру; на touch отдельные операции могут быть неудобны или недоступны.
> Подробный контракт: [поддержка touch](docs/TOUCH-SUPPORT.md).
**Главное**
<!-- docs-section: features -->
- ♾️ **Бесконечный холст** — нет «размера плана» и нет края, за который
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
план обратно в экран.
- 🖱 **Редакторы прямо в карточке** — комнаты, двери, окна и ворота, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
линейка в реальных метрах.
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
освещает ровно тот пол, который видит: через проёмы и открытые границы,
а стены, колонны и перегородки его не пропускают и дают настоящие тени.
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
в комнаты настоящие клинья солнечного света; облачность — опционально, от
weather-сущности.
- 🪟 **Шторы открываются тапом** — одно действие открывает, закрывает или
останавливает штору, а сам значок морфится между открытым и закрытым
видом и мягко пульсирует кольцом, пока штора едет.
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
температурная заливка по комфортным границам.
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
никогда случайным тапом.
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
этажами, автокарусель, свои размеры на каждом экране.
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
(по именам комнат) или перетаскиванием призрака карты. Диагностика и явный
выбор источника поддерживают registry-less камеры карт и не подменяют молча
сломавшуюся привязку. Работают Xiaomi Cloud Map Extractor, dreame-vacuum
(Tasshack) и Valetudo.
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
## Что умеет House Plan
---
- **Живые состояния и безопасные действия.** Свет и другие безопасные устройства
переключаются с плана; замок нельзя открыть случайным нажатием.
- **Три встроенных редактора.** «План» создаёт комнаты, стены и проёмы;
«Устройства» размещает и настраивает маркеры; «Подложка» добавляет линии,
подписи и мебель.
- **Комнаты, связанные с зонами HA.** Новые устройства появляются автоматически,
а карточки комнат показывают температуру, влажность, свет и средний LQI.
- **Свет и окружение.** Заливки комнат, Glow от ламп, тени от стен, дневной фон и
солнечные лучи из окон.
- **Двери, окна, ворота и пылесосы.** Проёмы отражают реальные датчики и замки;
робот показывает позицию, базу и пройденный путь.
- **Несколько этажей и экранов.** Вкладки пространств, жесты переключения,
локальный масштаб и отдельный стартовый этаж для каждой карточки.
- **Киоск для настенного экрана.** Только план, полноэкранная навигация и размеры
значков, сохранённые отдельно для этого устройства.
![Тот же синтетический дом в touch-режиме просмотра](docs/images/02-view-touch.png)
## Что это и зачем
<!-- docs-section: first-run -->
House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота.
## Первая рабочая комната
Это удобно, когда:
1. Установите интеграцию и добавьте карточку на дашборд.
2. Создайте первое **пространство**: загрузите SVG/PNG/JPG/WebP либо выберите
вариант без изображения, чтобы нарисовать план вручную.
3. В редакторе «План» выберите **Контур комнаты**, поставьте вершины и замкните
контур нажатием на первую точку.
4. Назовите комнату и свяжите её с зоной Home Assistant. Для помещения без
устройств выберите «Без зоны».
5. Откройте «Устройства»: устройства связанной зоны уже размещены автоматически;
перетащите маркеры в нужные места.
6. При необходимости оформите подложку линиями, текстом и мебелью.
7. Вернитесь в «Просмотр» — теперь план показывает живые состояния и принимает
безопасные действия.
- устройств много, и списками пользоваться неудобно;
- нужно быстро понять состояние дома «одним взглядом»;
- хочется отдать доступ близким — по картинке разберётся любой;
- вы хотите красивый обзорный экран для настенного планшета.
![Создание первого пространства](docs/images/03-space-create.png)
Интеграция состоит из двух частей, которые ставятся вместе:
![Замыкание контура комнаты на первой точке](docs/images/04-room-contour-close.png)
- **карточка Lovelace** `houseplan-card` — сам интерактивный план;
- **серверный компонент** — хранит разметку комнат и позиции иконок в Home Assistant, поэтому план одинаков во всех браузерах и на всех устройствах.
![Выбранная перегородка и контекстная панель редактора плана](docs/images/05-plan-context-tray.png)
---
![Настройка устройства с источником привязки и точным результатом действия](docs/images/06-device-editor.png)
## Чем отличается от аналогов
![Живой предпросмотр отображения того же устройства](docs/images/06-device-display-preview.png)
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan`
или новые GUI-карточки, где стены и мебель рисуют прямо на дашборде. Там либо
YAML/SVG, либо конфиг живёт в YAML карточки. House Plan — это **общий живой
план** на серверной интеграции Home Assistant:
Пошаговые сценарии, все инструменты и особые случаи описаны в
[полном руководстве](docs/USER-GUIDE.ru.md). Возможности подложки отдельно
зафиксированы в [документе редактора](docs/DECOR-EDITOR.md), а роботов — в
[руководстве по пылесосам](docs/VACUUM.md).
| | House Plan | picture-elements / ha-floorplan | GUI-рисовалки (напр. easy-floorplan) |
|---|---|---|---|
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML / Inkscape SVG | Рисование стен и мебели в карточке |
| **Добавление устройств** | Автоматически по **зоне** HA | Каждую сущность вписываете руками | Ставите сущности руками на чертёж |
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели в YAML | Drag на холсте |
| **Разметка комнат** | Встроенный редактор контуров, привязка к зонам | Сторонний SVG-редактор | Сами рисуете стены (мебельный CAD) |
| **Хранение** | На сервере HA (`.storage`, общее, multi-client) | В YAML дашборда | В карточке / YAML дашборда |
| **Оверлеи** | Glow, климат, LQI, солнце, пылесосы, киоск | Что пропишете в SVG/CSS | Зависит от карточки |
| **Масштаб** | Плавный векторный зум | Обычно фиксированная картинка | SVG / виртуальный холст |
<!-- docs-section: installation -->
**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не
универсальная CAD-система. Редактор подложки покрывает практический декор,
надписи и мебель; для свободного архитектурного черчения лучше отдельный
draw-инструмент. При наличии плана и зон HA House Plan держит один живой layout
на всех планшетах.
Ключевые преимущества коротко:
- **Никакого кода.** Всё — пространства, комнаты, устройства — настраивается кликами.
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно;
если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением.
Оранжевый = открыто / не заперто. Пульсирующее красное
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
---
## Настенный планшет / ТВ (киоск-режим)
Отдельный дашборд с view типа «панель», у карточки — `kiosk: true` (или
галочка «Режим настенного устройства» в редакторе карточки):
```yaml
type: custom:houseplan-card
kiosk: true
cycle: 0 # автосмена пространств каждые N секунд, 0 = выкл (удобно для ТВ)
```
Без шапки и редакторов — только живой план. Свайп листает этажи (при 1:1),
пинч — зум, двойной тап — сброс. Долгое нажатие (3 с) по пустому месту —
настройка размеров значков и текста для ЭТОГО экрана (хранится на
устройстве). Шапку самого Home Assistant скрывают настройки companion-app
или плагин [kiosk-mode](https://github.com/NemesisRE/kiosk-mode).
## Установка
### Через HACS
В один клик, если у вас уже есть HACS:
[![Открыть репозиторий в HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
1. В HACS откройте **⋮ → Пользовательские репозитории**.
2. Добавьте `https://github.com/Matysh/houseplan-card` с типом **Интеграция**.
3. Установите House Plan и перезапустите Home Assistant.
4. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
добавьте именно URL, который публикует интеграция:
### Через HACS (рекомендуется)
```yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
```
1. Откройте **HACS → меню (⋮) → Custom repositories**.
2. Вставьте URL этого репозитория, категория — **Integration**, и нажмите **Add**.
3. Найдите в списке **House Plan**, установите и **перезапустите Home Assistant**.
4. Перейдите в **Настройки → Устройства и службы → Добавить интеграцию** и выберите **House Plan**.
Не используйте путь к файлу внутри `custom_components`: Home Assistant не
публикует его как JavaScript-модуль.
Карточка подключается автоматически — добавлять ресурс Lovelace вручную не нужно.
> **Карточка не грузится (`Custom element doesn't exist: houseplan-card`) или вы ведёте ресурсы
> Lovelace в YAML?** Добавьте ресурс вручную, указав URL, который *раздаёт сама интеграция*:
>
> ```yaml
> resources:
> - url: /houseplan_files/houseplan-card.js
> type: module
> ```
>
> **Не** используйте `/custom_components/houseplan/frontend/houseplan-card.js` — это путь к файлу
> на диске, который Home Assistant не отдаёт по HTTP (получите ошибку MIME `text/plain`, и элемент
> не зарегистрируется). Правильный URL, который раздаёт интеграция, — `/houseplan_files/houseplan-card.js`.
> Обе карточки (`houseplan-card` и `houseplan-space-card`) лежат в этом одном файле — отдельный ресурс
> не нужен.
### Вручную
Скопируйте `custom_components/houseplan` в `config/custom_components`,
перезапустите Home Assistant и добавьте интеграцию House Plan.
1. Скопируйте папку `custom_components/houseplan` в каталог `config/custom_components` вашего Home Assistant.
2. Перезапустите Home Assistant.
3. Добавьте интеграцию: **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
### Добавление карточки
### Добавление экрана с планом
Создайте представление дашборда (лучше Panel) и добавьте карточку через UI либо:
Создайте новую вкладку дашборда (удобнее всего — в режиме «Панель»/Panel) и добавьте карточку:
```yaml
type: custom:houseplan-card
title: План дома
```
Для нескольких экранов можно задать разные стартовые пространства:
Больше ничего указывать не нужно — всё остальное настраивается прямо на экране.
```yaml
type: custom:houseplan-card
default_floor: ground
```
---
Все карточки используют общие серверные комнаты и координаты. Текущий режим,
масштаб и выбранное пространство локальны для экрана. Одновременное
редактирование поддерживает синхронизацию и проверку ревизий, но один объект
лучше не менять параллельно в двух браузерах.
## Как пользоваться
## Где искать подробности
### Шаг 1. Добавьте пространство (этаж)
- [Полное руководство пользователя](docs/USER-GUIDE.ru.md)
- [Матрица mouse/touch/keyboard](docs/USER-GUIDE.ru.md#6-навигация-масштаб-и-жесты)
- [Инструменты плана](docs/USER-GUIDE.ru.md#инструменты-плана-в-короткой-таблице)
- [Редактор подложки](docs/DECOR-EDITOR.md)
- [Роботы-пылесосы](docs/VACUUM.md)
- [Поддержка touch](docs/TOUCH-SUPPORT.md)
При первом открытии план ещё пуст — House Plan сразу предложит создать первое пространство.
<!-- docs-section: support -->
Если в вашем Home Assistant уже настроены **этажи**, мастер предложит создать
пространство для каждого: названия подставятся сами, план попросит по очереди,
любой этаж можно пропустить.
## Помощь и обратная связь
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png)
- Вопросы и примеры планов: [Telegram @ha_houseplan](https://t.me/ha_houseplan).
- Баги и предложения: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues).
- Перед отчётом обновите House Plan, перезапустите HA и выполните жёсткое
обновление страницы (`Ctrl+F5`). Приложите версию, браузер, логи и шаги
воспроизведения; приватные entity ID можно заменить вымышленными.
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка по умолчанию сохраняет пропорции, а подвинуть, изменить размер или повернуть её можно в любой момент в редакторе подложки.
Скриншоты в документации получены воспроизводимой командой
`npm run build && node demo/docs/capture.mjs` только на синтетических данных. Версия сценариев,
fingerprint исходников и хеш каждого изображения находятся в
[индексе снимков](docs/images/screenshots.json).
![Диалог создания пространства](docs/images/03-space-dialog.png)
Лицензия: [MIT](LICENSE).
> 💡 Подложку можно нарисовать в любом планировщике (например, РЕМПЛАННЕР) или сфотографировать бумажный план. Лучше всего SVG — он остаётся чётким при увеличении.
Позже можно добавить сколько угодно пространств (этажи, двор, гараж) кнопкой **+** рядом со вкладками.
### Шаг 2. Обведите комнаты
После добавления первого пространства карточка сама переходит в режим разметки. Кликайте по точкам сетки, соединяя их линиями, и замкните контур комнаты кликом по первой точке.
Как только контур замкнётся, появится окно сохранения комнаты. Здесь нужно **привязать комнату к зоне Home Assistant** — именно это включает автоматику. Для служебных помещений без устройств (холл, сауна) есть кнопка **«Без зоны»**.
![Разметка комнаты и её сохранение](docs/images/05-room-dialog.png)
Во время рисования у курсора показывается линейка с реальной длиной текущего отрезка (метры или футы+дюймы на имперской системе HA). Масштаб задаётся для каждого пространства — поле **«Масштаб (размер ячейки сетки)»** в диалоге пространства: сколько сантиметров в одной ячейке (по умолчанию 5 см).
Комнаты не могут пересекаться: клик строго внутри существующей комнаты или контур, охватывающий её, отклоняются. Ещё два инструмента помогают перекроить план позже:
- **Объединить** — кликните комнату, затем соседнюю с общей стеной; они сольются в одну. Диалог выбирает, чьё имя и зона останутся.
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
### Двери, окна, ворота и замки
В режиме разметки инструмент **«Проём»** ставит двери, окна и ворота: кликните рядом со стеной — проём
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
окно 120 см, ворота 300 см), датчик открытия и — для двери или ворот — **замок**.
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
подвижные части подсвечены акцентным цветом. Ворота не занимают полплана даже при ширине 3–4 м: две половинные створки показаны открытыми наружу всего на 10°, без большой дуги. Датчик, замок и пропуск света работают как у двери. У двери или ворот с замком рядом отображается замочек —
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
переключить — клик по проёму показывает карточку с обоими статусами.
Проёмы легко поправить позже: при наведении проём подсвечивается, его можно **перетащить вдоль
стен** (в том числе за угол), а **двойной клик открывает свойства**.
### Шаг 3. Устройства появляются сами
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти их, откройте редактор устройств и нажмите **«Скрытые и деактивированные»**: пользовательски скрытые устройства отображаются синими призраками. Деактивированное в HA устройство показывается серым служебным призраком и полностью исключается из данных и действий плана до повторной активации.
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
![Карточка устройства по клику](docs/images/08-info.png)
### Шаг 4. Масштаб
Колесо мыши или кнопки **- / ⊹ / +** приближают и отдаляют план; на сенсорном экране работает «щипок» двумя пальцами. При отдалении виден весь план целиком, при приближении — детали, и всё остаётся чётким. Масштаб запоминается отдельно для каждого пространства.
![Приближённый план — всё остаётся чётким](docs/images/09-zoom.png)
### Шаг 5. Расставьте значки по местам
Расставлять значки нужно на вкладке **«Устройства»**: там они перетаскиваются мышью, а клик открывает редактор. В режиме **«Просмотр»** ничего сдвинуть нельзя — панорамирование карты больше не сдвигает датчики (главная просьба пользователей). Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** возвращает автоматическую раскладку.
![Перетаскивание значков — доступно всегда](docs/images/06-edit.png)
### Управление с плана (tap actions)
По умолчанию тап по значку открывает инфо-карточку. В настройках карточки можно
переключить **«Тап по устройству»** на *Переключить* — тогда тап включает/выключает
свет, розетки, вентиляторы и увлажнители прямо с плана (режим настенного планшета).
Для безопасности общий toggle не действует на замки, сигнализации, шторы/ворота и
клапаны; для конкретного устройства toggle можно включить осознанно в его диалоге
(кроме замков и сигнализаций — они с плана не переключаются никогда). **Долгое
нажатие** всегда открывает инфо-карточку.
### Правила иконок
Какая MDI-иконка достанется устройству, решают **правила иконок** — редактируются
прямо в карточке (кнопка ⬡ в шапке): упорядоченный список «шаблон имени → иконка»
с живым тест-полем, двуязычные умолчания (EN/RU) и сброс одной кнопкой. Если ни одно
правило не подошло — решает *device class* сущности (термометр для датчиков
температуры и т.п.).
### Шаг 6. Добавление своих устройств вручную
Можно поставить и **отдельную сущность** (не только устройство целиком): начните печатать в поиске привязки — рядом с устройствами появятся отдельные сущности. Удобно, когда одно устройство отдаёт несколько значений (например, температуру и влажность), а вы хотите каждое своей иконкой.
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
В этом же диалоге настраивается вид устройства на плане. **Отображение** переключает значок,
анимированную **пульсацию присутствия** (расходящиеся кольца, пока сущность активна, и тусклая
точка в покое — идеально для датчиков движения) или то и другое сразу, с цветом и размером колец
на устройство. **Размер значка** (×0,5–3) и **поворот** — тоже индивидуальные: вентиль на стене
может быть маленьким и повёрнутым так, как он установлен.
![Добавление устройства вручную](docs/images/07-marker-dialog.png)
### Свои стили через card-mod (для продвинутых, без поддержки)
Карточка приезжает готовой, и поля для CSS у неё нет — но если у вас уже стоит [card-mod](https://github.com/thomasloven/lovelace-card-mod), у каждого объекта плана теперь есть стабильный «крючок», за который можно зацепиться: `data-hp="device"` (плюс `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. Мы обещаем их не переименовывать; сам card-mod мы не поставляем, не поддерживаем и за то, что ваш CSS сделает с карточкой, не отвечаем. Полная таблица, примеры и ограничения — в **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
---
## Удаление
1. Уберите карточку (или вкладку с планом) из дашборда.
2. **Настройки → Устройства и службы → House Plan → Удалить** запись интеграции.
3. Удалите интеграцию из **HACS** (или папку `custom_components/houseplan` при ручной установке) и перезапустите Home Assistant.
4. При желании удалите сохранённые данные плана: файлы `config/houseplan/` (подложки и вложения) и записи `houseplan.config` / `houseplan.layout` в каталоге `config/.storage`.
---
## Помощь и обмен опытом
- 💬 **[Чат в Telegram — @ha_houseplan](https://t.me/ha_houseplan)** — вопросы,
помощь с настройкой, идеи и скриншоты ваших планов. Самый быстрый способ
связаться с автором и другими пользователями.
- 🐞 [Issues на GitHub](https://github.com/Matysh/houseplan-card/issues) — баги
и запросы фич (пожалуйста, указывайте версию House Plan).
- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для
развёрнутых обсуждений.
- 📜 [История изменений](docs/CHANGELOG.ru.md) — что менялось в каждой версии.
Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в
**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно
быстрее.
---
## Часто задаваемые вопросы
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Откройте **«Скрытые и деактивированные»**: синий призрак можно показать в его диалоге, серый сначала нужно активировать в Home Assistant.
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
**Данные хранятся в облаке?** Нет. Всё хранится локально в вашем Home Assistant.
---
<p align="center"><sub>Скриншоты сделаны на реальной конфигурации Home Assistant.</sub></p>
+4 -4
View File
@@ -5,7 +5,7 @@ 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 = 2
STORAGE_MINOR_VERSION = 1
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
PLANS_URL = "/houseplan_files/plans"
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
@@ -46,12 +46,12 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.67.0-rc.3"
VERSION = "1.63.0"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
# user have different compatibility lifecycles.
PLAN_MODEL_VERSION = 7
PLAN_MODEL_VERSION = 6
EXPORT_VERSION = 1
MAX_EXPORT_BYTES = 8 * 1024 * 1024
IMPORT_PREVIEW_TTL_S = 10 * 60
@@ -65,5 +65,5 @@ MAX_IMPORT_PREVIEWS_TOTAL = 3
DEFAULT_CONFIG: dict = {
"spaces": [],
"markers": [],
"settings": {"bg_mode": "daynight"},
"settings": {},
}
@@ -1,176 +0,0 @@
"""Lossless, allow-listed canonicalisation of persisted geometry.
The frontend mirrors this module in src/coordinate-canonicalization.ts.
Keep the precision, lattice formula and field allow-list in lockstep; a shared
fixture is exercised by both runtimes.
"""
from __future__ import annotations
import copy
import math
from typing import Any
COORDINATE_DECIMALS = 9
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
LATTICE_GRID_N = 240
LATTICE_NOISE_STEPS = 1e-4
def canonicalize_number(value: Any) -> Any:
"""Return one stable IEEE-754 representation for an allow-listed scalar."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return value
number = float(value)
if not math.isfinite(number):
return value
sign = -1.0 if math.copysign(1.0, number) < 0 else 1.0
result = sign * (
math.floor(abs(number) * COORDINATE_FACTOR + 0.5)
/ COORDINATE_FACTOR
)
if result == 0:
return 0.0
return result
def canonicalize_lattice_coordinate(value: Any) -> Any:
"""Collapse near-node noise while preserving authored off-grid values."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return value
number = float(value)
if not math.isfinite(number):
return value
scaled = number * LATTICE_GRID_N
# JavaScript Math.round: ties go toward +infinity, unlike Python round().
nearest = math.floor(scaled + 0.5)
if abs(scaled - nearest) < LATTICE_NOISE_STEPS:
result = nearest / LATTICE_GRID_N
return 0.0 if result == 0 else result
return canonicalize_number(number)
def _record(value: Any) -> dict[str, Any] | None:
return value if isinstance(value, dict) else None
def _records(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, dict)]
def _scalar_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
for name in names:
if name in record:
record[name] = canonicalize_number(record[name])
def _lattice_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
for name in names:
if name in record:
record[name] = canonicalize_lattice_coordinate(record[name])
def _lattice_point(value: Any) -> None:
if not isinstance(value, list):
return
for index in range(min(2, len(value))):
value[index] = canonicalize_lattice_coordinate(value[index])
def _lattice_points(value: Any) -> None:
if not isinstance(value, list):
return
for point in value:
_lattice_point(point)
def canonicalize_position(position: Any) -> Any:
"""Canonicalise lattice x/y in one layout record, preserving metadata."""
result = copy.deepcopy(position)
record = _record(result)
if record is not None:
_lattice_fields(record, ("x", "y"))
return result
def canonicalize_layout_geometry(layout: Any) -> Any:
"""Canonicalise lattice x/y in every layout record."""
result = copy.deepcopy(layout)
record = _record(result)
if record is None:
return result
for position in record.values():
item = _record(position)
if item is not None:
_lattice_fields(item, ("x", "y"))
return result
def canonicalize_config_geometry(config: Any) -> Any:
"""Canonicalise only the named persisted geometry fields."""
result = copy.deepcopy(config)
root = _record(result)
if root is None:
return result
for space in _records(root.get("spaces")):
_scalar_fields(
space,
(
"plan_x",
"plan_y",
"plan_scale",
"plan_scale_x",
"plan_scale_y",
"plan_angle",
),
)
for room in _records(space.get("rooms")):
_lattice_fields(room, ("x", "y", "w", "h"))
_lattice_points(room.get("poly"))
for wall in _records(space.get("walls")):
_lattice_point(wall.get("a"))
_lattice_point(wall.get("b"))
for opening in _records(space.get("openings")):
_lattice_fields(opening, ("x", "y"))
_scalar_fields(opening, ("angle", "length"))
host = _record(opening.get("host"))
if host is not None:
_scalar_fields(host, ("t",))
for decor in _records(space.get("decor")):
kind = decor.get("kind")
if kind == "line":
_lattice_fields(decor, ("x1", "y1", "x2", "y2"))
elif kind in ("rect", "ellipse", "furniture"):
_lattice_fields(decor, ("x", "y", "w", "h"))
_scalar_fields(decor, ("angle",))
elif kind == "text":
_lattice_fields(decor, ("x", "y"))
_scalar_fields(decor, ("scale", "angle"))
for draft in _records(space.get("room_drafts")):
_lattice_points(draft.get("points"))
for partition in _records(space.get("partitions")):
_lattice_point(partition.get("a"))
_lattice_point(partition.get("b"))
for column in _records(space.get("wall_columns")):
_lattice_point(column.get("center"))
if column.get("shape") == "square":
_scalar_fields(column, ("angle",))
for span in _records(space.get("open_spans")):
_lattice_point(span.get("a"))
_lattice_point(span.get("b"))
for marker in _records(root.get("markers")):
_scalar_fields(marker, ("angle",))
return result
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.67.0-rc.3"
"version": "1.63.0"
}
-95
View File
@@ -1,95 +0,0 @@
"""Read-only projections of the stored plan (#256).
A full configuration is 70 KB on a real installation: three spaces and 139
markers. Every diagnostic question — "which space does this marker point at",
"how many markers are hidden", "what is on the first floor" — used to require
downloading all of it, because `houseplan/config/get` had no way to ask for
less.
The functions here are pure and deliberately unaware of Home Assistant: the
websocket handlers stay thin, and the interesting part is covered by tests that
run without the HA harness.
Two rules shape everything below.
* Absent parameter means *no projection*. The response must stay byte-for-byte
what it was before this module existed; no existing client may notice it.
* A projection never invents or repairs data. An unknown field name simply adds
nothing, and an unknown space yields an empty list rather than an error — the
caller distinguishes "no such thing" from "broken" by content, not by an
error code.
"""
from __future__ import annotations
from typing import Any, Iterable
def _names(value: Any) -> list[str] | None:
"""Normalise a field list; anything unusable means "no projection"."""
if not isinstance(value, (list, tuple)):
return None
names = [str(item) for item in value if isinstance(item, str) and item]
return names or None
def project_markers(markers: Any, marker_fields: Iterable[str] | None) -> Any:
"""Keep only the requested marker fields, plus `id`.
`id` is added unconditionally: a marker without it cannot be matched to
anything, so a projection that drops it produces an answer nobody can use.
"""
names = _names(marker_fields)
if names is None or not isinstance(markers, list):
return markers
keep = {"id", *names}
out = []
for marker in markers:
if not isinstance(marker, dict):
out.append(marker)
continue
out.append({key: value for key, value in marker.items() if key in keep})
return out
def project_config(
config: Any,
*,
space_id: str | None = None,
fields: Iterable[str] | None = None,
marker_fields: Iterable[str] | None = None,
) -> Any:
"""Return a narrowed copy of the configuration.
The original object is never mutated: the caller hands us the store's
document, and a projection that edited it in place would corrupt the very
thing it was asked to read.
"""
if not isinstance(config, dict):
return config
field_names = _names(fields)
if space_id is None and field_names is None and _names(marker_fields) is None:
return config
projected: dict[str, Any] = dict(config)
if space_id is not None:
spaces = projected.get("spaces")
projected["spaces"] = [
space for space in spaces
if isinstance(space, dict) and str(space.get("id", "")) == str(space_id)
] if isinstance(spaces, list) else spaces
if marker_fields is not None:
projected["markers"] = project_markers(projected.get("markers"), marker_fields)
if field_names is not None:
projected = {key: value for key, value in projected.items() if key in set(field_names)}
return projected
def project_layout(layout: Any, *, space_id: str | None = None) -> Any:
"""Keep only the positions of one space."""
if space_id is None or not isinstance(layout, dict):
return layout
wanted = str(space_id)
return {
key: position for key, position in layout.items()
if isinstance(position, dict) and str(position.get("s", "")) == wanted
}
+7 -38
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import copy
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
@@ -20,39 +19,9 @@ from .const import (
STORAGE_VERSION,
STORAGE_VIRTUAL_LIGHTS_KEY,
)
from .coordinate_canonicalization import (
canonicalize_config_geometry,
canonicalize_layout_geometry,
)
_LOGGER = logging.getLogger(__name__)
_BG_MODES = frozenset({"static", "daynight"})
def migrate_config_background_mode(old_data: dict[str, Any]) -> dict[str, Any]:
"""Materialize the legacy implicit background mode without changing its view.
Only the config-store document has a top-level ``config`` object. Layout
and virtual-light stores pass through this helper unchanged even though
they share the same Store subclass and minor version.
"""
config = old_data.get("config")
if not isinstance(config, dict):
return old_data
settings = config.get("settings")
mode = settings.get("bg_mode") if isinstance(settings, dict) else None
if mode in _BG_MODES:
return old_data
data = copy.deepcopy(old_data)
migrated_config = data["config"]
migrated_settings = migrated_config.get("settings")
if not isinstance(migrated_settings, dict):
migrated_settings = {}
migrated_config["settings"] = migrated_settings
migrated_settings["bg_mode"] = "static"
return data
class HouseplanStore(Store):
@@ -70,9 +39,10 @@ class HouseplanStore(Store):
old_minor_version: int,
old_data: dict[str, Any],
) -> dict[str, Any]:
if old_major_version == 1 and old_minor_version < 2:
return migrate_config_background_mode(old_data)
return old_data
data = old_data
# if old_major_version == 1 and old_minor_version < 2:
# ...migrate...
return data
@dataclass
@@ -161,7 +131,7 @@ def layout_store_payload(
}
if metadata:
out.update(metadata)
out["layout"] = canonicalize_layout_geometry(layout)
out["layout"] = layout
out["rev"] = rev
return out
@@ -209,8 +179,7 @@ async def async_save_config_state(
except (TypeError, ValueError):
previous_rev = 0
canonical_config = canonicalize_config_geometry(config)
payload = {"config": canonical_config, "rev": rev}
payload = {"config": config, "rev": rev}
await runtime.config_store.async_save(payload)
# The config is already durable at this point. Reconciliation remains a
@@ -221,7 +190,7 @@ async def async_save_config_state(
try:
await async_reconcile_virtual_lights(
runtime.virtual_light_store,
canonical_config,
config,
rev,
previous_config_rev=previous_rev,
)
+3 -31
View File
@@ -10,7 +10,6 @@ want to see where the cleanup has already been).
from __future__ import annotations
import asyncio
import math
import time
from typing import Any
@@ -25,33 +24,11 @@ import logging
_LOGGER = logging.getLogger(__name__)
TRAIL_CAP = 2000 # raw points per run before decimation
TRAIL_RESUME_GRACE_S = 30 * 60 # same-map stop/pause belongs to one cleanup
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
MOVING_STATES = {"cleaning", "returning", "on"}
def can_resume_trail_run(run: Any, map_id: str, now: float) -> bool:
"""Whether an ended current run may be reopened for this point.
Store timestamps are untrusted persisted data. Only finite JSON-number
timestamps and a non-negative inclusive grace interval are accepted;
malformed values and wall-clock rollback fail closed into a new run.
"""
if not isinstance(run, dict) or run.get("map_id") != map_id:
return False
ended = run.get("ended")
if (
isinstance(ended, bool)
or not isinstance(ended, (int, float))
or isinstance(now, bool)
or not isinstance(now, (int, float))
):
return False
elapsed = now - ended
return math.isfinite(elapsed) and 0 <= elapsed <= TRAIL_RESUME_GRACE_S
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
"""Map-id normalisation contract, shared with the frontend.
@@ -87,10 +64,7 @@ class TrailBook:
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
rec = self.data.setdefault(marker, {})
cur = rec.get("current")
resumed = bool(cur and can_resume_trail_run(cur, map_id, now))
if resumed:
cur["ended"] = None
if not cur or cur.get("ended") is not None or cur.get("map_id") != map_id:
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
# a new run begins: the old one becomes "previous" (and the one
# before it is forgotten — we keep exactly two, per the owner)
if cur:
@@ -99,9 +73,7 @@ class TrailBook:
rec["current"] = cur
pts: list[list[float]] = cur["points"]
if pts and pts[-1][0] == x and pts[-1][1] == y:
# Clearing ended is observable state even if the source repeats
# the dock point: it must still reach Store and live cards.
return resumed
return False
pts.append([x, y])
if len(pts) > TRAIL_CAP:
# decimate by two but never lose the freshest point
@@ -113,7 +85,7 @@ class TrailBook:
def end_run(self, marker: str, now: float) -> bool:
cur = (self.data.get(marker) or {}).get("current")
if cur and cur.get("ended") is None:
if cur and not cur.get("ended"):
cur["ended"] = now
return True
return False
+50 -702
View File
@@ -5,17 +5,10 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
from __future__ import annotations
from collections import Counter
import math
import re
import voluptuous as vol
from custom_components.houseplan.coordinate_canonicalization import (
canonicalize_config_geometry,
canonicalize_layout_geometry,
canonicalize_position,
)
# ---------- limits and extension sets ----------
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
MAX_PLAN_BYTES = 8 * 1024 * 1024
@@ -39,595 +32,6 @@ class MarkerControlError(ValueError):
self.code = code
class OpeningPassageError(ValueError):
"""Semantic open-passage error with a stable public code and payload."""
code = "invalid_passage_fields"
def __init__(self, space_id: str, opening_id: str, fields: list[str]) -> None:
self.space_id = space_id
self.opening_id = opening_id
self.fields = tuple(sorted(fields))
super().__init__(
f"space={space_id}; opening={opening_id}; fields={','.join(self.fields)}"
)
class PartitionOpeningHostError(ValueError):
"""A write tried to strip explicit host identity from a surviving opening."""
code = "invalid_partition_opening_host"
class PartitionOpeningJambMarginError(ValueError):
"""A direct geometry write leaves no physical jamb at a wall endpoint."""
code = "invalid_partition_opening_jamb_margin"
def __init__(
self, space_id: str, opening_id: str, margin: float, margin_cm: float
) -> None:
self.space_id = space_id
self.opening_id = opening_id
self.margin = margin
self.margin_cm = margin_cm
super().__init__(
f"space={space_id}; opening={opening_id}; "
f"margin={margin:.12g}; margin_cm={margin_cm:.12g}"
)
# One normalized canvas width contains this many physical grid cells. Keep in
# sync with GRID_STEP_N/NORM_W in the frontend; it is a geometry scale, not a
# user setting.
NORMALIZED_CANVAS_CELLS = 240.0
_DEFAULT_ROOM_WALL_CM = 15.0
_OPTIMIZE_REHOST_EPSILON = 1e-8
def _room_polygon(room: dict) -> list[list[float]]:
poly = room.get("poly")
if isinstance(poly, list) and len(poly) >= 3:
return poly
if all(key in room for key in ("x", "y", "w", "h")):
x, y = float(room["x"]), float(room["y"])
w, h = float(room["w"]), float(room["h"])
return [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]
return []
def _segment_metrics(a: list, b: list) -> tuple[float, float, float]:
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
return dx, dy, math.hypot(dx, dy)
def _line_distance(point: list, a: list, b: list) -> float:
dx, dy, length = _segment_metrics(a, b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return math.inf
return abs((float(point[0]) - float(a[0])) * dy
- (float(point[1]) - float(a[1])) * dx) / length
def _projection(point: list, a: list, b: list) -> float:
dx, dy, length = _segment_metrics(a, b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return math.inf
return ((float(point[0]) - float(a[0])) * dx
+ (float(point[1]) - float(a[1])) * dy) / length
def _segment_covers(a: list, b: list, target_a: list, target_b: list) -> bool:
"""Return whether one exact collinear segment covers the target."""
_, _, length = _segment_metrics(a, b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return False
if (_line_distance(target_a, a, b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(target_b, a, b) > _OPTIMIZE_REHOST_EPSILON):
return False
for point in (target_a, target_b):
along = _projection(point, a, b)
if along < -_OPTIMIZE_REHOST_EPSILON or along > length + _OPTIMIZE_REHOST_EPSILON:
return False
return True
def _segments_overlap_on_axis(a: list, b: list, other_a: list, other_b: list) -> bool:
if (_line_distance(other_a, a, b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(other_b, a, b) > _OPTIMIZE_REHOST_EPSILON):
return False
_, _, length = _segment_metrics(a, b)
lo, hi = sorted((_projection(other_a, a, b), _projection(other_b, a, b)))
return min(length, hi) - max(0.0, lo) > _OPTIMIZE_REHOST_EPSILON
def _angle_delta_mod_180(first: float, second: float) -> float:
return abs((first - second + 90.0) % 180.0 - 90.0)
def _segments_cover_target(segments: list[tuple[list, list]], target_a: list, target_b: list) -> bool:
"""Whether a collinear union covers one complete positive target span."""
dx, dy, length = _segment_metrics(target_a, target_b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return False
ranges = []
for a, b in segments:
if (_line_distance(a, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(b, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON):
continue
lo, hi = sorted((_projection(a, target_a, target_b),
_projection(b, target_a, target_b)))
lo, hi = max(0.0, lo), min(length, hi)
if hi - lo > _OPTIMIZE_REHOST_EPSILON:
ranges.append((lo, hi))
reached = 0.0
for lo, hi in sorted(ranges):
if lo > reached + _OPTIMIZE_REHOST_EPSILON:
return False
reached = max(reached, hi)
if reached >= length - _OPTIMIZE_REHOST_EPSILON:
return True
return False
_OPTIMIZE_PARTITION_KEYS = {"id", "a", "b", "cm"}
def _known_optimize_partition(partition: dict) -> bool:
return isinstance(partition, dict) and set(partition).issubset(_OPTIMIZE_PARTITION_KEYS)
def _axis_range(a: list, b: list, axis_a: list, axis_b: list) -> tuple[float, float] | None:
"""Project one positive collinear overlap onto ``axis_a -> axis_b``."""
if not (isinstance(a, list) and len(a) == 2
and isinstance(b, list) and len(b) == 2):
return None
if (_line_distance(a, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(b, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON):
return None
_, _, length = _segment_metrics(axis_a, axis_b)
lo, hi = sorted((_projection(a, axis_a, axis_b),
_projection(b, axis_a, axis_b)))
lo, hi = max(0.0, lo), min(length, hi)
return (lo, hi) if hi - lo > _OPTIMIZE_REHOST_EPSILON else None
def _axis_point(a: list, b: list, along: float) -> list[float]:
dx, dy, length = _segment_metrics(a, b)
return [float(a[0]) + dx / length * along,
float(a[1]) + dy / length * along]
def _optimize_partition_needs_proof(old_partition: dict, space: dict) -> bool:
"""Select #296 removal/splitting, not ordinary Optimize grid alignment."""
partition_id = str(old_partition.get("id", ""))
current = next((item for item in space.get("partitions") or []
if str(item.get("id", "")) == partition_id), None)
if current == old_partition:
return False
if current is None:
return True
if not (_known_optimize_partition(old_partition)
and _known_optimize_partition(current)):
return False
try:
if float(current.get("cm")) != float(old_partition.get("cm")):
return False
old_a, old_b = old_partition["a"], old_partition["b"]
current_range = _axis_range(current["a"], current["b"], old_a, old_b)
_, _, old_length = _segment_metrics(old_a, old_b)
except (KeyError, TypeError, ValueError):
return False
return current_range is not None and (
current_range[0] > _OPTIMIZE_REHOST_EPSILON
or current_range[1] < old_length - _OPTIMIZE_REHOST_EPSILON
)
def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:
"""Independently prove every removed atom of one old partition axis."""
if not _known_optimize_partition(old_partition):
return False
try:
old_a, old_b = old_partition["a"], old_partition["b"]
old_cm = float(old_partition["cm"])
_, _, length = _segment_metrics(old_a, old_b)
except (KeyError, TypeError, ValueError):
return False
if length <= _OPTIMIZE_REHOST_EPSILON or old_cm <= 0:
return False
rooms: list[tuple[str, list[tuple[list, list]]]] = []
for room in space.get("rooms") or []:
poly = _room_polygon(room)
if len(poly) < 3:
return False
rooms.append((str(room.get("id", "")), [
(poly[index], poly[(index + 1) % len(poly)])
for index in range(len(poly))
]))
candidate_partitions = space.get("partitions") or []
retained: list[tuple[float, float]] = []
breakpoints = [0.0, length]
def note_segment(a: list, b: list) -> tuple[float, float] | None:
projected = _axis_range(a, b, old_a, old_b)
if projected:
breakpoints.extend(projected)
return projected
for partition in candidate_partitions:
try:
projected = note_segment(partition["a"], partition["b"])
candidate_cm = float(partition["cm"])
except (KeyError, TypeError, ValueError):
return False
if projected and _known_optimize_partition(partition) and candidate_cm == old_cm:
retained.append(projected)
for _, edges in rooms:
for edge_a, edge_b in edges:
note_segment(edge_a, edge_b)
for collection in (space.get("walls") or [], space.get("open_spans") or []):
for segment in collection:
try:
note_segment(segment["a"], segment["b"])
except (KeyError, TypeError, ValueError):
return False
points = sorted(set(round(point, 12) for point in breakpoints
if -_OPTIMIZE_REHOST_EPSILON <= point
<= length + _OPTIMIZE_REHOST_EPSILON))
for lo, hi in zip(points, points[1:]):
if hi - lo <= _OPTIMIZE_REHOST_EPSILON:
continue
if any(start <= lo + _OPTIMIZE_REHOST_EPSILON
and end >= hi - _OPTIMIZE_REHOST_EPSILON
for start, end in retained):
continue
target_a, target_b = _axis_point(old_a, old_b, lo), _axis_point(old_a, old_b, hi)
owners = {room_id for room_id, edges in rooms
if _segments_cover_target(edges, target_a, target_b)}
collinear = {room_id for room_id, edges in rooms
if any(_segments_overlap_on_axis(
target_a, target_b, edge_a, edge_b
) for edge_a, edge_b in edges)}
if len(owners) not in (1, 2) or collinear != owners:
return False
if any(_segments_overlap_on_axis(
target_a, target_b, span["a"], span["b"]
) for span in space.get("open_spans") or []):
return False
if any(_segments_overlap_on_axis(
target_a, target_b, partition["a"], partition["b"]
) for partition in candidate_partitions):
return False
covering_walls = []
for wall in space.get("walls") or []:
try:
wall_a, wall_b = wall["a"], wall["b"]
if _segment_covers(wall_a, wall_b, target_a, target_b):
covering_walls.append((
_segment_metrics(wall_a, wall_b)[2], float(wall["cm"]),
))
except (KeyError, TypeError, ValueError):
return False
effective_cm = (min(covering_walls, key=lambda item: item[0])[1]
if covering_walls else _DEFAULT_ROOM_WALL_CM)
if effective_cm + _OPTIMIZE_REHOST_EPSILON < old_cm:
return False
return True
def _hosted_opening_geometry(opening: dict, partition: dict) -> tuple[float, float, float, float] | None:
host = opening.get("host")
try:
if not isinstance(host, dict) or host.get("kind") != "partition":
return None
t = float(host["t"])
a, b = partition["a"], partition["b"]
dx, dy, length = _segment_metrics(a, b)
opening_length = float(opening["length"])
except (KeyError, TypeError, ValueError):
return None
if not 0 <= t <= 1 or length <= _OPTIMIZE_REHOST_EPSILON or opening_length <= 0:
return None
angle = math.degrees(math.atan2(dy, dx))
if angle >= 90:
angle -= 180
elif angle < -90:
angle += 180
return float(a[0]) + dx * t, float(a[1]) + dy * t, angle, opening_length
def _safe_optimize_residual_rehost(
space: dict, old_space: dict, opening: dict, old_opening: dict,
) -> bool:
"""Prove a hosted opening rebound to a newly-created residual id."""
old_host, new_host = old_opening.get("host"), opening.get("host")
if not (isinstance(old_host, dict) and isinstance(new_host, dict)
and old_host.get("kind") == new_host.get("kind") == "partition"):
return False
old_partition = next((item for item in old_space.get("partitions") or []
if str(item.get("id", "")) == str(old_host.get("id", ""))), None)
new_partition = next((item for item in space.get("partitions") or []
if str(item.get("id", "")) == str(new_host.get("id", ""))), None)
if old_partition is None or new_partition is None:
return False
if not _safe_optimize_partition_delta(space, old_partition):
return False
ignored = {"host", "x", "y", "angle"}
if ({key: value for key, value in old_opening.items() if key not in ignored}
!= {key: value for key, value in opening.items() if key not in ignored}):
return False
old_geometry = _hosted_opening_geometry(old_opening, old_partition)
new_geometry = _hosted_opening_geometry(opening, new_partition)
if old_geometry is None or new_geometry is None:
return False
old_x, old_y, old_angle, old_length = old_geometry
new_x, new_y, new_angle, new_length = new_geometry
try:
explicit_x, explicit_y = float(opening["x"]), float(opening["y"])
explicit_angle = float(opening["angle"])
except (KeyError, TypeError, ValueError):
return False
return (
abs(old_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
and abs(old_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
and abs(old_length - new_length) <= _OPTIMIZE_REHOST_EPSILON
and _angle_delta_mod_180(old_angle, new_angle) <= 1e-7
and abs(explicit_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
and abs(explicit_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
and _angle_delta_mod_180(explicit_angle, new_angle) <= 1e-7
)
def _safe_optimize_partition_rehost(
space: dict, old_space: dict, opening: dict, old_opening: dict,
) -> bool:
"""Independently prove the #276/#296 partition-to-room-wall transition."""
old_host = old_opening.get("host")
if not isinstance(old_host, dict) or old_host.get("kind") != "partition":
return False
partition_id = str(old_host.get("id", ""))
old_partition = next((item for item in old_space.get("partitions") or []
if str(item.get("id", "")) == partition_id), None)
if old_partition is None:
return False
if not _safe_optimize_partition_delta(space, old_partition):
return False
a, b = old_partition.get("a"), old_partition.get("b")
if not (isinstance(a, list) and len(a) == 2 and isinstance(b, list) and len(b) == 2):
return False
dx, dy, length = _segment_metrics(a, b)
if length <= _OPTIMIZE_REHOST_EPSILON:
return False
ignored = {"host", "x", "y", "angle"}
old_stable = {key: value for key, value in old_opening.items() if key not in ignored}
new_stable = {key: value for key, value in opening.items() if key not in ignored}
if old_stable != new_stable:
return False
try:
t = float(old_host["t"])
x, y = float(opening["x"]), float(opening["y"])
opening_angle = float(opening["angle"])
opening_length = float(opening["length"])
except (KeyError, TypeError, ValueError):
return False
if not (0 <= t <= 1 and opening_length > 0):
return False
expected_x, expected_y = float(a[0]) + dx * t, float(a[1]) + dy * t
expected_angle = math.degrees(math.atan2(dy, dx))
if expected_angle >= 90:
expected_angle -= 180
elif expected_angle < -90:
expected_angle += 180
if (abs(x - expected_x) > _OPTIMIZE_REHOST_EPSILON
or abs(y - expected_y) > _OPTIMIZE_REHOST_EPSILON
or _angle_delta_mod_180(opening_angle, expected_angle) > 1e-7):
return False
along = t * length
if (along - opening_length / 2 < -_OPTIMIZE_REHOST_EPSILON
or along + opening_length / 2 > length + _OPTIMIZE_REHOST_EPSILON):
return False
half_dx, half_dy = dx / length * opening_length / 2, dy / length * opening_length / 2
target_a = [expected_x - half_dx, expected_y - half_dy]
target_b = [expected_x + half_dx, expected_y + half_dy]
owners: set[str] = set()
collinear_rooms: set[str] = set()
for room in space.get("rooms") or []:
poly = _room_polygon(room)
room_id = str(room.get("id", ""))
edges = [(poly[index], poly[(index + 1) % len(poly)])
for index in range(len(poly))]
if _segments_cover_target(edges, target_a, target_b):
owners.add(room_id)
if any(_segments_overlap_on_axis(target_a, target_b, edge_a, edge_b)
for edge_a, edge_b in edges):
collinear_rooms.add(room_id)
if len(owners) not in (1, 2) or collinear_rooms != owners:
return False
if any(_segments_overlap_on_axis(target_a, target_b, span["a"], span["b"])
for span in space.get("open_spans") or []):
return False
if any(_segments_overlap_on_axis(target_a, target_b, item["a"], item["b"])
for item in space.get("partitions") or []):
return False
covering_walls = []
for wall in space.get("walls") or []:
wall_a, wall_b = wall.get("a"), wall.get("b")
if not (isinstance(wall_a, list) and isinstance(wall_b, list)):
continue
if _segment_covers(wall_a, wall_b, target_a, target_b):
covering_walls.append((_segment_metrics(wall_a, wall_b)[2], float(wall["cm"])))
effective_cm = min(covering_walls, key=lambda item: item[0])[1] \
if covering_walls else _DEFAULT_ROOM_WALL_CM
if effective_cm + _OPTIMIZE_REHOST_EPSILON < float(old_partition.get("cm", 0)):
return False
for other in space.get("openings") or []:
if other is opening or str(other.get("id", "")) == str(opening.get("id", "")):
continue
try:
center = [float(other["x"]), float(other["y"])]
other_angle = float(other["angle"])
other_length = float(other["length"])
except (KeyError, TypeError, ValueError):
continue
if (_line_distance(center, a, b) > _OPTIMIZE_REHOST_EPSILON
or _angle_delta_mod_180(other_angle, expected_angle) > 1e-7):
continue
other_along = _projection(center, a, b)
if (min(along + opening_length / 2, other_along + other_length / 2)
- max(along - opening_length / 2, other_along - other_length / 2)
> _OPTIMIZE_REHOST_EPSILON):
return False
return True
def validate_partition_opening_hosts(
config: dict, previous: dict | None = None, *, allow_optimize_rehost: bool = False
) -> None:
"""Validate hosted-opening write deltas without rejecting legacy reads.
Deleting the opening together with its partition remains valid. Surviving
records keep their host, while new/direct geometry changes reserve half the
actual wall depth at both endpoints. Rigid host translation and unrelated
edits round-trip existing near-end records unchanged.
"""
old_spaces = {
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
}
for space in config.get("spaces") or []:
space_id = str(space.get("id", ""))
old_space = old_spaces.get(space_id)
old_openings = {
str(opening.get("id")): opening
for opening in (old_space or {}).get("openings") or []
}
partitions = {
str(partition.get("id")): partition
for partition in space.get("partitions") or []
}
old_partitions = {
str(partition.get("id")): partition
for partition in (old_space or {}).get("partitions") or []
}
for opening in space.get("openings") or []:
opening_id = str(opening.get("id", ""))
old = old_openings.get(opening_id)
if old and old.get("host") is not None and opening.get("host") is None:
if not (allow_optimize_rehost and old_space
and _safe_optimize_partition_rehost(
space, old_space, opening, old
)):
raise PartitionOpeningHostError(
f"space={space_id}; opening={opening_id}; host removed"
)
host = opening.get("host")
if host is None:
continue
partition = partitions.get(str(host.get("id", "")))
if partition is None:
# SPACE_SCHEMA owns missing-host diagnostics.
continue
old_host = (old or {}).get("host")
old_partition = old_partitions.get(str((old_host or {}).get("id", "")))
if (allow_optimize_rehost and old and old_space
and isinstance(old_host, dict)
and old_host.get("kind") == "partition"
and isinstance(host, dict) and host.get("kind") == "partition"
and old_host.get("id") != host.get("id")
and not _safe_optimize_residual_rehost(
space, old_space, opening, old
)):
raise PartitionOpeningHostError(
f"space={space_id}; opening={opening_id}; residual host changed"
)
ax, ay = partition["a"]
bx, by = partition["b"]
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
old_span = None
if old_partition is not None:
old_ax, old_ay = old_partition["a"]
old_bx, old_by = old_partition["b"]
old_span = ((old_bx - old_ax) ** 2 + (old_by - old_ay) ** 2) ** 0.5
strict = (
old is None
or old_host is None
or old_host.get("id") != host.get("id")
or old_host.get("t") != host.get("t")
or old.get("length") != opening.get("length")
or old_partition is None
or old_partition.get("cm") != partition.get("cm")
or abs(old_span - span) > 1e-9
)
if not strict:
continue
cell_cm = float(space.get("cell_cm", 5))
margin_cm = float(partition["cm"]) / 2
margin = margin_cm / cell_cm / NORMALIZED_CANVAS_CELLS
along = float(host["t"]) * span
half = float(opening["length"]) / 2
if (along - half < margin - 1e-9
or along + half > span - margin + 1e-9):
raise PartitionOpeningJambMarginError(
space_id, opening_id, margin, margin_cm
)
if allow_optimize_rehost and old_space:
for old_partition in old_space.get("partitions") or []:
if (_optimize_partition_needs_proof(old_partition, space)
and not _safe_optimize_partition_delta(space, old_partition)):
raise PartitionOpeningHostError(
f"space={space_id}; partition={old_partition.get('id', '')}; "
"optimize delta unproved"
)
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
def validate_opening_passages(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Reject new/changed inapplicable fields while preserving dormant bad data.
A passage read from an older/future writer may already contain door-only
keys. Unrelated writes must remain possible, but imports and any write that
introduces or changes such a key are fail-closed.
"""
old_spaces = {
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
}
for space in config.get("spaces") or []:
space_id = str(space.get("id", ""))
old_space = None if validate_all else old_spaces.get(space_id)
old_openings = {
str(opening.get("id")): opening
for opening in (old_space or {}).get("openings") or []
}
for opening in space.get("openings") or []:
if opening.get("type") != "passage":
continue
opening_id = str(opening.get("id", ""))
present = sorted(PASSAGE_FORBIDDEN_FIELDS & set(opening))
if not present:
continue
old_opening = None if validate_all else old_openings.get(opening_id)
if not old_opening or old_opening.get("type") != "passage":
raise OpeningPassageError(space_id, opening_id, present)
changed = [
field for field in present
if field not in old_opening or opening[field] != old_opening[field]
]
if changed:
raise OpeningPassageError(space_id, opening_id, changed)
VALUE_BADGE_ATTRIBUTES = {
"current_temperature", "temperature", "current_humidity", "humidity",
"current_position", "percentage", "brightness", "volume_level",
@@ -731,7 +135,7 @@ def validate_marker_value_badges(
def validate_marker_light_entities(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate new/changed light/switch choices without rejecting dormant data.
"""Validate new/changed leading-light choices without rejecting dormant data.
The top-level schema must stay lossless: an old or future literal that the
current frontend cannot edit may round-trip unchanged. Imports validate the
@@ -748,26 +152,16 @@ def validate_marker_light_entities(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
for field, code, message in (
(
"light_entity",
"invalid_light_entity",
"Leading light entity must be light.* or switch.*",
),
(
"toggle_entity",
"invalid_toggle_entity",
"Toggle entity must be light.* or switch.*",
),
):
value = marker.get(field)
old_value = None if validate_all else (old_marker or {}).get(field)
if not validate_all and value == old_value:
continue
if value is None:
continue
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
raise MarkerControlError(code, message)
value = marker.get("light_entity")
old_value = None if validate_all else (old_marker or {}).get("light_entity")
if not validate_all and value == old_value:
continue
if value is None:
continue
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
raise MarkerControlError(
"invalid_light_entity", "Leading light entity must be light.* or switch.*"
)
def validate_marker_controls(
@@ -958,18 +352,11 @@ _URL = vol.All(str, vol.Length(max=MAX_URL))
CANVAS_LIMIT = 5000.0
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
POS_SCHEMA = vol.All(
vol.Schema(
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
),
canonicalize_position,
)
LAYOUT_SCHEMA = vol.All(
vol.Schema({str: POS_SCHEMA}),
vol.Length(max=MAX_LAYOUT),
canonicalize_layout_geometry,
POS_SCHEMA = vol.Schema(
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
)
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
@@ -1316,15 +703,6 @@ WALL_COLUMN_SCHEMA = vol.All(
_strict_wall_column,
)
PARTITION_OPENING_HOST_SCHEMA = vol.Schema(
{
vol.Required("kind"): vol.Equal("partition"),
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("t"): vol.All(_finite, vol.Range(min=0, max=1)),
},
extra=vol.PREVENT_EXTRA,
)
def _space_geometry_invariants(value: dict) -> dict:
"""All stored geometry shares ids; draft segments also have a space cap."""
@@ -1342,29 +720,6 @@ def _space_geometry_invariants(value: dict) -> dict:
)
if draft_segments > MAX_DRAFT_SEGMENTS:
raise vol.Invalid("too many saved room-draft segments")
partitions = {
item.get("id"): item for item in value.get("partitions", []) if item.get("id")
}
hosted_intervals: dict[str, list[tuple[float, float]]] = {}
for opening in value.get("openings", []):
host = opening.get("host")
if host is None:
continue
partition = partitions.get(host["id"])
if partition is None:
raise vol.Invalid("partition opening host must exist in the same space")
ax, ay = partition["a"]
bx, by = partition["b"]
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
length = float(opening["length"])
along = float(host["t"]) * span
if length > span or along - length / 2 < -1e-9 or along + length / 2 > span + 1e-9:
raise vol.Invalid("partition opening must fit inside its host")
lo, hi = along - length / 2, along + length / 2
occupied = hosted_intervals.setdefault(host["id"], [])
if any(max(lo, old_lo) < min(hi, old_hi) - 1e-9 for old_lo, old_hi in occupied):
raise vol.Invalid("partition openings must not overlap")
occupied.append((lo, hi))
return value
@@ -1418,7 +773,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
vol.Schema(
{
vol.Required("id"): str,
vol.Required("type"): vol.Any("door", "window", "gate", "passage"),
vol.Required("type"): vol.Any("door", "window", "gate"),
vol.Required("x"): _GEOM,
vol.Required("y"): _GEOM,
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
@@ -1430,7 +785,6 @@ SPACE_SCHEMA = vol.All(vol.Schema(
vol.Optional("invert"): bool,
vol.Optional("flip_h"): bool,
vol.Optional("flip_v"): bool,
vol.Optional("host"): PARTITION_OPENING_HOST_SCHEMA,
},
extra=vol.ALLOW_EXTRA,
)
@@ -1531,9 +885,6 @@ MARKER_SCHEMA = vol.Schema(
# Semantic delta validation below the schema preserves unknown/future
# literals until that exact field is edited (lossless config doctrine).
vol.Optional("light_entity"): object,
# Exact own entity selected for Toggle. Delta validation preserves an
# untouched future literal while bounding every new/changed value.
vol.Optional("toggle_entity"): object,
vol.Optional("value_badge"): vol.Any(
None,
vol.Schema(
@@ -1567,41 +918,38 @@ MARKER_SCHEMA = vol.Schema(
},
extra=vol.ALLOW_EXTRA,
)
CONFIG_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): _COLOR,
# sun on the plan (docs/SUN.md): global defaults
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
# legacy field so an existing stored config can still load; the
# frontend ignores it and removes it on the next settings save.
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
{
vol.Required("c"): _COLOR,
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
)
}
),
},
extra=vol.ALLOW_EXTRA,
),
},
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
),
canonicalize_config_geometry,
CONFIG_SCHEMA = vol.Schema(
{
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): _COLOR,
# sun on the plan (docs/SUN.md): global defaults
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
# legacy field so an existing stored config can still load; the
# frontend ignores it and removes it on the next settings save.
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
{
vol.Required("c"): _COLOR,
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
)
}
),
},
extra=vol.ALLOW_EXTRA,
),
},
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
)
+22 -301
View File
@@ -25,10 +25,6 @@ from .const import (
MAX_SIGN_PATHS,
PLANS_DIR, PLANS_URL,
)
from .coordinate_canonicalization import (
canonicalize_config_geometry,
canonicalize_layout_geometry,
)
from .auth import may_write
from .import_export import (
ImportFailure,
@@ -59,12 +55,9 @@ from .virtual_lights import (
async_virtual_light_snapshot,
)
from .registry_snapshot import import_registry_snapshot
from .projection import project_config, project_layout
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
PartitionOpeningHostError, PartitionOpeningJambMarginError, sanitize_filename,
validate_opening_passages, validate_partition_opening_hosts,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, sanitize_filename,
validate_marker_controls, validate_marker_light_entities,
validate_marker_value_badges, valid_space_id,
)
@@ -143,7 +136,6 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_optimize)
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
websocket_api.async_register_command(hass, ws_space_delete)
websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_plans_list)
websocket_api.async_register_command(hass, ws_plans_delete)
@@ -271,7 +263,6 @@ async def _commit_import_pair(
vol.Required("type"): "houseplan/export/create",
vol.Required("kind"): vol.In(["full", "space"]),
vol.Optional("space_id"): str,
vol.Optional("plan_only", default=False): bool,
vol.Optional("card_version", default=""): str,
}
)
@@ -296,7 +287,6 @@ async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any])
layout_data,
kind=msg["kind"],
space_id=msg.get("space_id"),
plan_only=msg.get("plan_only", False),
card_version=msg.get("card_version", ""),
config_root=Path(hass.config.path("")),
)
@@ -432,12 +422,8 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
if kind == "full":
backup = {
"kind": "import",
"config": canonicalize_config_geometry(
config_data.get("config") or DEFAULT_CONFIG
),
"layout": canonicalize_layout_geometry(
layout_data.get("layout") or {}
),
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout") or {},
"created": int(time.time()),
"after_config_rev": new_config_rev,
"after_layout_rev": new_layout_rev,
@@ -454,20 +440,16 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
final_metadata[_OPTIMIZE_BACKUP] = backup
pending = {
"kind": "import",
"config": canonicalize_config_geometry(target_config),
"layout": canonicalize_layout_geometry(target_layout),
"config": target_config,
"layout": target_layout,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"final_metadata": final_metadata,
}
rollback = {
"kind": "import_rollback",
"config": canonicalize_config_geometry(
config_data.get("config") or DEFAULT_CONFIG
),
"layout": canonicalize_layout_geometry(
layout_data.get("layout") or {}
),
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout") or {},
"config_rev": config_rev,
"layout_rev": layout_rev,
"final_metadata": original_metadata,
@@ -510,9 +492,6 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
"layout_rev": new_layout_rev,
"counts": details.get("counts", {}),
"space_id": details.get("space_id"),
"repaired_target_refs": details.get("repaired_target_refs", 0),
"preserved_unresolved_refs": details.get("preserved_unresolved_refs", 0),
"reference_report": details.get("reference_report", {}),
"can_undo": kind == "full",
})
@@ -532,15 +511,10 @@ def _live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, An
return live_layout(config, layout)
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/layout/get",
vol.Optional("space_id"): vol.All(str, vol.Length(min=1, max=200)),
}
)
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
@websocket_api.async_response
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Return the saved layout, optionally narrowed to one space (#256)."""
"""Return the saved layout."""
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
@@ -548,9 +522,7 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
config_data = await rt.config_store.async_load() or {}
connection.send_result(
msg["id"], {
"layout": project_layout(
data.get("layout", {}), space_id=msg.get("space_id"),
),
"layout": data.get("layout", {}),
"rev": int(data.get("rev", 0)),
"can_optimize_undo": _optimizer_backup_is_current(config_data, data),
"undo_kind": _undo_kind(config_data, data),
@@ -590,9 +562,6 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
return
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
if layout == data.get("layout", {}):
connection.send_result(msg["id"], {"ok": True, "rev": current_rev})
return
new_rev = current_rev + 1
await async_save_layout_state(
rt, data, layout, new_rev,
@@ -654,10 +623,7 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
return
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
if layout.get(msg["device_id"]) == msg["pos"]:
connection.send_result(msg["id"], {"ok": True, "rev": int(data.get("rev", 0))})
return
layout = {**layout, msg["device_id"]: msg["pos"]}
layout[msg["device_id"]] = msg["pos"]
# keep the revision: a point-wise write used to drop it, which made the
# optimistic locking on layout/set meaningless — every drag reset the
# counter to 0 (HP-1454-08)
@@ -765,10 +731,7 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
rt, data, new_layout, new_rev,
metadata={
**_optimizer_backup_after_layout_maintenance(data, new_rev),
"repair_backup": {
"space": space_id,
"positions": canonicalize_layout_geometry(touched),
},
"repair_backup": {"space": space_id, "positions": touched},
},
remove=("repair_backup",),
)
@@ -1091,20 +1054,7 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
# ---------------- space configuration ----------------
_PROJECTION_FIELDS = vol.All([vol.All(str, vol.Length(min=1, max=100))], vol.Length(max=50))
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/config/get",
# Проекция ответа (#256). Все параметры необязательны, и без них ответ
# прежний — это главный инвариант: ни один существующий клиент не
# должен заметить появление этой возможности.
vol.Optional("space_id"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("fields"): _PROJECTION_FIELDS,
vol.Optional("marker_fields"): _PROJECTION_FIELDS,
}
)
@websocket_api.websocket_command({vol.Required("type"): "houseplan/config/get"})
@websocket_api.async_response
async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Return the configuration, its revision, and whether this user may write.
@@ -1112,11 +1062,6 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
`can_write` is the single source of truth for the card's editor chrome
(audit P0-4): the UI must mirror `may_write`, not a hard-coded is_admin
check that drifted from the integration option.
Optional `space_id`/`fields`/`marker_fields` narrow ONLY the returned
document (#256). Revisions and capability flags are computed from the whole
stored configuration: a caller that asked for one floor must not receive a
revision that describes only that floor.
"""
rt = _runtime(hass, connection, msg["id"])
if rt is None:
@@ -1140,12 +1085,7 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
connection.send_result(
msg["id"],
{
"config": project_config(
config,
space_id=msg.get("space_id"),
fields=msg.get("fields"),
marker_fields=msg.get("marker_fields"),
),
"config": config,
"rev": config_rev,
"virtual_lights": virtual_lights,
"can_write": may_write(hass, getattr(connection, "user", None)),
@@ -1312,12 +1252,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
validate_marker_controls(msg["config"], data.get("config"))
validate_marker_light_entities(msg["config"], data.get("config"))
validate_marker_value_badges(msg["config"], data.get("config"))
validate_opening_passages(msg["config"], data.get("config"))
validate_partition_opening_hosts(msg["config"], data.get("config"))
except (
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError,
) as err:
except MarkerControlError as err:
connection.send_error(msg["id"], err.code, str(err))
return
# An internal plan url must name a file that exists. The card can pick a
@@ -1337,17 +1272,6 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
"Plan file no longer exists: " + ", ".join(sorted(missing)),
)
return
if msg["config"] == data.get("config"):
# A semantic no-op still has to reconcile Repairs with external
# file-system changes. It must not create a revision, event, or
# discard the optimizer snapshot merely to refresh diagnostics.
entry = get_entry(hass)
if entry is not None:
from .repairs import async_check_plan_files
hass.async_create_task(async_check_plan_files(hass, entry))
connection.send_result(msg["id"], {"ok": True, "rev": int(current_rev)})
return
new_rev = current_rev + 1
await async_save_config_state(
rt,
@@ -1387,193 +1311,6 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
# ---------------- whole-plan maintenance ----------------
def _space_marker_dependencies(
config: dict[str, Any], layout: dict[str, Any], space_id: str,
) -> list[str]:
"""Active marker ids that make deleting a space unsafe (deduplicated)."""
space = next(
(item for item in config.get("spaces") or [] if item.get("id") == space_id),
None,
)
room_ids = {
str(room.get("id")) for room in (space or {}).get("rooms") or []
if room.get("id") is not None
}
dependencies = {
str(marker.get("id"))
for marker in config.get("markers") or []
if marker.get("removed") is not True
and marker.get("id") is not None
and (
marker.get("space") == space_id
or (
marker.get("room_id") is not None
and str(marker.get("room_id")) in room_ids
)
or (layout.get(str(marker.get("id"))) or {}).get("s") == space_id
)
}
return sorted(dependencies)
def _space_delete_candidate(
config: dict[str, Any], layout: dict[str, Any], space_id: str,
) -> tuple[dict[str, Any], dict[str, Any], list[str], int]:
"""Return a pure exact pair; blockers leave both inputs unchanged."""
candidate_config = json.loads(json.dumps(config))
candidate_layout = json.loads(json.dumps(layout))
dependencies = _space_marker_dependencies(candidate_config, candidate_layout, space_id)
spaces = candidate_config.get("spaces") or []
deleting_last_space = len(spaces) == 1 and spaces[0].get("id") == space_id
if dependencies and not deleting_last_space:
return candidate_config, candidate_layout, dependencies, 0
space = next(
(item for item in candidate_config.get("spaces") or []
if item.get("id") == space_id),
None,
)
room_ids = {
str(room.get("id")) for room in (space or {}).get("rooms") or []
if room.get("id") is not None
}
candidate_config["spaces"] = [
item for item in candidate_config.get("spaces") or []
if item.get("id") != space_id
]
for marker in candidate_config.get("markers") or []:
marker_id = str(marker.get("id")) if marker.get("id") is not None else None
marker_position = candidate_layout.get(marker_id) if marker_id is not None else None
references_deleted_space = (
marker.get("space") == space_id
or (
marker.get("room_id") is not None
and str(marker.get("room_id")) in room_ids
)
or (
isinstance(marker_position, dict)
and marker_position.get("s") == space_id
)
)
if deleting_last_space and references_deleted_space:
marker.pop("space", None)
marker.pop("room_id", None)
continue
if marker.get("removed") is not True:
continue
if marker.get("space") == space_id:
marker.pop("space", None)
if (marker.get("room_id") is not None
and str(marker.get("room_id")) in room_ids):
marker.pop("room_id", None)
removed_layout = 0
for key in list(candidate_layout):
position = candidate_layout.get(key)
if isinstance(position, dict) and position.get("s") == space_id:
del candidate_layout[key]
removed_layout += 1
return candidate_config, candidate_layout, dependencies, removed_layout
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/space/delete",
vol.Required("space_id"): str,
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
}
)
@websocket_api.async_response
async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Delete one space as a crash-recoverable config/layout pair."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only editors may delete spaces")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
space_id = msg["space_id"]
if not valid_space_id(space_id):
connection.send_error(msg["id"], "invalid_space_id", "Invalid space id")
return
try:
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if (msg["expected_config_rev"] != config_rev
or msg["expected_layout_rev"] != layout_rev):
connection.send_error(msg["id"], "conflict", "Plan changed elsewhere")
return
current_config = config_data.get("config") or DEFAULT_CONFIG
current_layout = layout_data.get("layout") or {}
if not any(
item.get("id") == space_id for item in current_config.get("spaces") or []
):
connection.send_error(msg["id"], "space_not_found", "Space no longer exists")
return
target_config, target_layout, dependencies, removed_layout = (
_space_delete_candidate(current_config, current_layout, space_id)
)
spaces = current_config.get("spaces") or []
deleting_last_space = (
len(spaces) == 1 and spaces[0].get("id") == space_id
)
if dependencies and not deleting_last_space:
connection.send_error(
msg["id"], "space_in_use",
f"Space is still used by {len(dependencies)} active marker(s)",
)
return
target_config = CONFIG_SCHEMA(target_config)
target_layout = LAYOUT_SCHEMA(target_layout)
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
original_metadata = _layout_metadata(layout_data)
final_metadata = {
key: value for key, value in original_metadata.items()
if key not in {_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup", "geom_pending"}
}
pending = {
"kind": "space_delete",
"config": canonicalize_config_geometry(target_config),
"layout": canonicalize_layout_geometry(target_layout),
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"final_metadata": final_metadata,
}
rollback = {
"kind": "space_delete_rollback",
"config": canonicalize_config_geometry(current_config),
"layout": canonicalize_layout_geometry(current_layout),
"config_rev": config_rev,
"layout_rev": layout_rev,
"final_metadata": original_metadata,
}
await _commit_import_pair(rt, pending, rollback)
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
except vol.Invalid as err:
connection.send_error(msg["id"], "invalid_config", str(err))
return
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan space delete failed")
connection.send_error(msg["id"], "commit_failed", "Space delete failed")
return
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
_refresh_trail_recorder(hass)
connection.send_result(msg["id"], {
"ok": True,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"removed_layout": removed_layout,
})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plan/optimize",
@@ -1625,15 +1362,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
validate_marker_controls(msg["config"], config_data.get("config"))
validate_marker_light_entities(msg["config"], config_data.get("config"))
validate_marker_value_badges(msg["config"], config_data.get("config"))
validate_opening_passages(msg["config"], config_data.get("config"))
validate_partition_opening_hosts(
msg["config"], config_data.get("config"),
allow_optimize_rehost=True,
)
except (
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError,
) as err:
except MarkerControlError as err:
connection.send_error(msg["id"], err.code, str(err))
return
@@ -1654,19 +1383,15 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
new_layout_rev = layout_rev + 1
backup = {
"kind": "optimize",
"config": canonicalize_config_geometry(
config_data.get("config") or DEFAULT_CONFIG
),
"layout": canonicalize_layout_geometry(
layout_data.get("layout", {})
),
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout", {}),
"created": int(time.time()),
"after_config_rev": new_config_rev,
"after_layout_rev": new_layout_rev,
}
pending = {
"config": canonicalize_config_geometry(msg["config"]),
"layout": canonicalize_layout_geometry(msg["layout"]),
"config": msg["config"],
"layout": msg["layout"],
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"clear_backup": False,
@@ -1736,12 +1461,8 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
backup = layout_data[_OPTIMIZE_BACKUP]
restored_kind = str(backup.get("kind") or "optimize")
restored_config = canonicalize_config_geometry(
backup.get("config") or DEFAULT_CONFIG
)
restored_layout = canonicalize_layout_geometry(
backup.get("layout") or {}
)
restored_config = backup.get("config") or DEFAULT_CONFIG
restored_layout = backup.get("layout") or {}
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
pending = {
+1 -1
View File
@@ -8,7 +8,7 @@ public materials.
fake `hass` (registries, states, `callWS`, `callService`, floors).
- `srv/assets/` — generated plan SVGs and `icons.js` (`node demo/gen_icons.mjs`,
needs the repo's devDependencies). The card bundle is copied from `dist/`:
`npm run bundle:sync` (копия стенда не коммитится, #255).
`cp dist/houseplan-card.js demo/srv/assets/`.
- `serve.mjs` — playwright launcher (route interception, no web server).
- `smoke_*.mjs` — feature smoke tests; run with a Chromium installed via
`PLAYWRIGHT_BROWSERS_PATH=<dir> npx playwright install chromium-headless-shell`.
-92
View File
@@ -1,92 +0,0 @@
// #276: same-process incremental cost of coincident-partition reconciliation
// inside the complete Optimize candidate pass on the deterministic large house.
import { performance } from 'node:perf_hooks';
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import { optimizePlans } from '../test-build/plan-optimizer.js';
const WARMUPS = 5;
const SAMPLES = 20;
const BATCH_SIZE = 10;
const RELATIVE_OVERHEAD = 0.15;
const ABSOLUTE_OVERHEAD_MS = 25;
const fixture = makeLargeHouseFixture();
const emptyReconciliation = (rawSpace, _model, walls) => ({
walls: walls || [],
partitions: Array.isArray(rawSpace?.partitions) ? rawSpace.partitions : [],
openings: Array.isArray(rawSpace?.openings) ? rawSpace.openings : [],
partitionsReconciled: 0,
openingsRehosted: 0,
});
const baseline = () => optimizePlans(fixture.config, {}, {}, {
reconcileCoincidentPartitions: emptyReconciliation,
});
const candidate = () => optimizePlans(fixture.config, {});
const timed = (operation) => {
const start = performance.now();
for (let index = 0; index < BATCH_SIZE; index++) {
const result = operation();
if (!result || !result.report) throw new Error('Optimize candidate returned no report');
}
return (performance.now() - start) / BATCH_SIZE;
};
for (let index = 0; index < WARMUPS; index++) {
baseline();
candidate();
}
const baselineTimes = [];
const candidateTimes = [];
const overheadTimes = [];
for (let index = 0; index < SAMPLES; index++) {
// ABBA/BAAB cancels first-order clock drift and balances cache/GC order;
// batching then averages timer noise before the paired p95 is computed.
const candidateFirst = index % 2 === 1;
const first = timed(candidateFirst ? candidate : baseline);
const second = timed(candidateFirst ? baseline : candidate);
const third = timed(candidateFirst ? baseline : candidate);
const fourth = timed(candidateFirst ? candidate : baseline);
const baselineMs = candidateFirst ? (second + third) / 2 : (first + fourth) / 2;
const candidateMs = candidateFirst ? (first + fourth) / 2 : (second + third) / 2;
baselineTimes.push(baselineMs);
candidateTimes.push(candidateMs);
overheadTimes.push(candidateMs - baselineMs);
}
const quantile = (values, ratio) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
};
const summary = (values) => ({
min: Math.min(...values),
median: quantile(values, 0.5),
p95: quantile(values, 0.95),
max: Math.max(...values),
});
const baselineSummary = summary(baselineTimes);
const candidateSummary = summary(candidateTimes);
const overheadSummary = summary(overheadTimes);
const measuredOverheadP95 = Math.max(0, overheadSummary.p95);
const relativeP95 = measuredOverheadP95
/ Math.max(baselineSummary.p95, Number.EPSILON);
const pass = measuredOverheadP95 <= ABSOLUTE_OVERHEAD_MS
&& relativeP95 <= RELATIVE_OVERHEAD;
const report = {
issue: 276,
fixture: LARGE_HOUSE_COUNTS,
warmups: WARMUPS,
samples: SAMPLES,
batchSize: BATCH_SIZE,
baseline: baselineSummary,
candidate: candidateSummary,
pairedOverhead: overheadSummary,
budgets: {
relativeOverhead: RELATIVE_OVERHEAD,
absoluteOverheadMs: ABSOLUTE_OVERHEAD_MS,
},
measured: { overheadP95Ms: measuredOverheadP95, relativeP95 },
pass,
};
console.log(JSON.stringify(report, null, 2));
if (!pass) process.exitCode = 1;
@@ -1,56 +0,0 @@
#!/usr/bin/env node
// #291: same-process p95 of the complete config+layout boundary versus the
// pre-existing full-candidate clone contract. Batching makes the strict 20%
// ratio meaningful even on coarse/loaded CI timers.
import { performance } from 'node:perf_hooks';
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
import {
canonicalizeConfigGeometry, canonicalizeLayoutGeometry,
} from '../test-build/coordinate-canonicalization.js';
const WARMUPS = 30;
const SAMPLES = 120;
const BATCH = 10;
const MAX_RATIO = 1.2;
const fixture = makeLargeHouseFixture();
const baseline = () => {
JSON.parse(JSON.stringify(fixture.config));
JSON.parse(JSON.stringify(fixture.layout || {}));
};
const candidate = () => {
canonicalizeConfigGeometry(fixture.config);
canonicalizeLayoutGeometry(fixture.layout || {});
};
const measure = (operation) => {
const started = performance.now();
for (let index = 0; index < BATCH; index++) operation();
return (performance.now() - started) / BATCH;
};
for (let index = 0; index < WARMUPS; index++) {
baseline();
candidate();
}
const baselineSamples = [];
const candidateSamples = [];
for (let index = 0; index < SAMPLES; index++) {
if (index % 2) {
candidateSamples.push(measure(candidate));
baselineSamples.push(measure(baseline));
} else {
baselineSamples.push(measure(baseline));
candidateSamples.push(measure(candidate));
}
}
const p95 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.95) - 1];
const baselineP95 = p95(baselineSamples);
const candidateP95 = p95(candidateSamples);
const ratio = candidateP95 / baselineP95;
const report = {
fixture: 'large-house-v1', samples: SAMPLES, batch: BATCH,
baselineP95Ms: baselineP95, candidateP95Ms: candidateP95,
ratio, limit: MAX_RATIO, pass: ratio <= MAX_RATIO,
};
console.log(JSON.stringify(report, null, 2));
if (!report.pass) process.exitCode = 1;
+3 -145
View File
@@ -14,27 +14,11 @@ const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const targetRoot = resolve(valueArg('target-root') ?? '.');
const profile = valueArg('profile') ?? 'large-house-v1';
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
if (!['large-house-v1', 'large-house-isometric-v1'].includes(profile))
throw new Error(`unknown large-house profile: ${profile}`);
const isometric = profile === 'large-house-isometric-v1';
const planSnap = profile === 'large-house-plan-snap-v1';
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
const fixture = makeLargeHouseFixture();
if (planSnap) {
for (const [floor, space] of fixture.config.spaces.entries()) {
space.room_drafts = [0, 1].map((draft) => {
const y = 0.985 + draft * 0.025;
return {
id: `perf-draft-${floor}-${draft}`,
points: [[0.10, y], [0.38, y], [0.46, y + 0.035]],
segments: [{ cm: 15 }, { cm: 20 }],
};
});
}
fixture.counts = { ...fixture.counts, drafts: 6, pointerMoves: 120 };
}
const viewport = { width: 1440, height: 1000 };
const { page, browser } = await launch(
@@ -64,10 +48,7 @@ const rows = [];
try {
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const measuredSample = iteration - warmups;
const row = await page.evaluate(async ({
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
requiresWallFace,
}) => {
const row = await page.evaluate(async ({ fixture, sample, cardContract, isometric, requiresIsometric }) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
const started = performance.now();
@@ -123,8 +104,6 @@ try {
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
isoGeometry: card._isoGeometryCache?.size ?? 0,
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
wallFaceGraph: card._wallFaceGraphCache?.length ?? 0,
});
window.__card?.remove?.();
@@ -141,7 +120,6 @@ try {
card.setConfig({
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
});
let wsCalls = 0;
const connection = {
subscribeEvents: async () => () => undefined,
subscribeMessage: async () => () => undefined,
@@ -156,7 +134,6 @@ try {
three: { floor_id: 'three', name: 'Three', level: 2 },
},
callWS: async (message) => {
wsCalls++;
if (message.type === 'houseplan/config/get')
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
if (message.type === 'houseplan/layout/get')
@@ -178,13 +155,6 @@ try {
const loadLongTasks = startLongTaskWindow();
const loadStarted = performance.now();
host.replaceChildren(card);
if (requiresIsometric) {
if (typeof card._onLabsSnapshot !== 'function')
throw new Error('large-house-isometric-v1 candidate has no Labs fixture hook');
// The product flag expires at 1.65.0. Performance keeps exercising
// the dormant renderer without changing the public registry contract.
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
}
card.hass = hassFor(fixture.states);
window.__hpAssertCardContract(card, cardContract);
if (requiresIsometric && (typeof card._setProjection !== 'function'
@@ -226,112 +196,6 @@ try {
await card.updateComplete;
});
let planSnapDiagnostics = null;
const planSnapPointer = planSnap ? await duration(async () => {
card._setMode('plan');
card._tool = 'draw';
card._path = [];
card.requestUpdate();
await card.updateComplete;
await frame();
const stage = card.renderRoot.querySelector('.stage');
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
if (requiresPlanSnap && !overlay) throw new Error('plan-snap candidate has no overlay');
const staticLines = overlay?.querySelectorAll('.plan-snap-line').length ?? 0;
const staticNodes = overlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0;
const cacheValue = card._planSnapGeometryCache?.value ?? null;
const configBefore = JSON.stringify(card._serverCfg);
const callsBefore = wsCalls;
const wallFaceCacheBeforePointer = card._wallFaceGraphCache?.length ?? 0;
const view = card._viewOr(card._baseVb());
const rect = stage.getBoundingClientRect();
const fromPlan = (x, y) => ({
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
});
const firstEndpoint = overlay?.querySelector('.plan-snap-node[data-kind="endpoint"]');
const longLine = [...(overlay?.querySelectorAll('.plan-snap-line') || [])]
.map((line) => ({
line,
a: [+line.getAttribute('x1'), +line.getAttribute('y1')],
b: [+line.getAttribute('x2'), +line.getAttribute('y2')],
}))
.sort((a, b) => Math.hypot(b.b[0] - b.a[0], b.b[1] - b.a[1])
- Math.hypot(a.b[0] - a.a[0], a.b[1] - a.a[1]))[0];
const points = [
firstEndpoint
? [+firstEndpoint.getAttribute('cx'), +firstEndpoint.getAttribute('cy')]
: [40, 40],
longLine
? [(longLine.a[0] + longLine.b[0]) / 2, (longLine.a[1] + longLine.b[1]) / 2]
: [120, 40],
[10, 10],
];
const seenKinds = new Set();
for (let index = 0; index < 120; index++) {
const point = points[index % points.length];
stage.dispatchEvent(new PointerEvent('pointermove', {
...fromPlan(point[0], point[1]),
bubbles: true, composed: true, pointerId: 880, pointerType: 'mouse',
}));
await card.updateComplete;
const active = card.renderRoot.querySelector(
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
);
if (active) seenKinds.add(active.getAttribute('data-kind'));
if (requiresPlanSnap && card.renderRoot.querySelectorAll(
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
).length > 1) throw new Error('plan-snap rendered more than one active candidate');
}
const finalOverlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
planSnapDiagnostics = {
supported: requiresPlanSnap,
staticLines,
staticNodes,
activeKinds: [...seenKinds].sort(),
cacheStable: cacheValue != null && card._planSnapGeometryCache?.value === cacheValue,
domStable: (finalOverlay?.querySelectorAll('.plan-snap-line').length ?? 0) === staticLines
&& (finalOverlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0)
=== staticNodes,
configStable: JSON.stringify(card._serverCfg) === configBefore,
wsWrites: wsCalls - callsBefore,
wallFaceCacheStableOnPointer:
(card._wallFaceGraphCache?.length ?? 0) === wallFaceCacheBeforePointer,
};
if (requiresPlanSnap && (
staticLines < fixture.counts.rooms || staticNodes < fixture.counts.rooms
|| !planSnapDiagnostics.cacheStable || !planSnapDiagnostics.domStable
|| !planSnapDiagnostics.configStable || planSnapDiagnostics.wsWrites !== 0
|| !planSnapDiagnostics.wallFaceCacheStableOnPointer
|| !seenKinds.has('endpoint') || !seenKinds.has('line')
)) throw new Error(`plan-snap structural contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
if (requiresWallFace) {
const oldPath = card._path;
const oldDraftId = card._activeDraftId;
const oldCms = card._draftSegmentCms;
const beforePath = [[10, 10]];
card._path = [[10, 10], [20, 10]];
card._activeDraftId = 'perf-face-draft';
card._draftSegmentCms = [15];
const acceptedStarted = performance.now();
card._offerWallFaces(beforePath);
planSnapDiagnostics.wallFaceAcceptedClickMs = performance.now() - acceptedStarted;
planSnapDiagnostics.wallFaceCacheEntries = card._wallFaceGraphCache?.length ?? 0;
card._wallFaceBatch = null;
card._roomDialog = false;
card._path = oldPath;
card._activeDraftId = oldDraftId;
card._draftSegmentCms = oldCms;
if (planSnapDiagnostics.wallFaceAcceptedClickMs > 1000
|| planSnapDiagnostics.wallFaceCacheEntries < 1
|| planSnapDiagnostics.wallFaceCacheEntries > 4) {
throw new Error(`wall-face accepted-click contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
}
}
card._setMode('view');
await card.updateComplete;
}) : null;
const resizePreview = await duration(async () => {
card._setMode('plan');
card._tool = 'resize';
@@ -415,10 +279,6 @@ try {
modelReadyMs,
firstStableRenderMs,
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
...(planSnapPointer ? {
planSnapPointerMs: planSnapPointer.ms,
planSnapDiagnostics,
} : {}),
spaceSwitchMs: spaceSwitch.ms,
stateUpdateMs: stateUpdate.ms,
resizePreviewMs: resizePreview.ms,
@@ -428,7 +288,6 @@ try {
longTasks: {
load: loadLongTaskResult,
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
spaceSwitch: spaceSwitch.longTasks,
stateUpdate: stateUpdate.longTasks,
resizePreview: resizePreview.longTasks,
@@ -447,7 +306,7 @@ try {
return result;
}, {
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
isometric, requiresIsometric,
});
if (measuredSample >= 0) rows.push(row);
}
@@ -460,7 +319,6 @@ const metricNames = [
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
];
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
const report = {
schema: 2,
profile,
@@ -1,96 +0,0 @@
// #199: same-process production-builder baseline versus the complete Optimize
// preflight wrapper on the deterministic 3-floor large-house fixture.
import { performance } from 'node:perf_hooks';
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import {
checkOptimizeGeometry,
prepareSpacePhysicalGeometryInputs,
} from '../test-build/plan-geometry-preflight.js';
import { spaceModels } from '../test-build/space-geometry.js';
import {
floorFootprintGeometry,
wallBodiesGeometry,
} from '../test-build/wall-thickness.js';
const WARMUPS = 3;
const SAMPLES = 20;
const ABSOLUTE_P95_MS = 250;
const RELATIVE_RATIO = 1.2;
const RELATIVE_NOISE_MS = 15;
const fixture = makeLargeHouseFixture();
const models = spaceModels(fixture.config);
const prepared = fixture.config.spaces.map((space, index) =>
prepareSpacePhysicalGeometryInputs(space, models[index]));
const directProductionPass = () => {
for (const input of prepared) {
const hasWalls = input.walls.length > 0 || input.physicalBodies.length > 0;
const united = hasWalls
? wallBodiesGeometry(
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
input.physicalBodies,
)
: null;
if (hasWalls && united == null) throw new Error(`baseline wall failure: ${input.space.id}`);
if (input.space.rooms.length && united?.paperGeom == null) {
const floor = floorFootprintGeometry(
input.space.rooms, input.walls, input.openCuts,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
);
if (floor == null) throw new Error(`baseline floor failure: ${input.space.id}`);
}
}
};
const completePreflight = () => {
const result = checkOptimizeGeometry(fixture.config);
if (!result.ok || result.spaces.length !== LARGE_HOUSE_COUNTS.floors
|| result.spaces.some((space) => space.status !== 'ok')) {
throw new Error(`candidate preflight failure: ${JSON.stringify(result.spaces)}`);
}
};
const sample = (operation) => {
const start = performance.now();
operation();
return performance.now() - start;
};
const run = (operation) => {
for (let index = 0; index < WARMUPS; index++) operation();
return Array.from({ length: SAMPLES }, () => sample(operation));
};
const quantile = (values, ratio) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
};
const summary = (values) => ({
min: Math.min(...values),
median: quantile(values, 0.5),
p95: quantile(values, 0.95),
max: Math.max(...values),
});
const baseline = summary(run(directProductionPass));
const candidate = summary(run(completePreflight));
const relativeLimit = baseline.p95 * RELATIVE_RATIO + RELATIVE_NOISE_MS;
const pass = candidate.p95 <= ABSOLUTE_P95_MS && candidate.p95 <= relativeLimit;
const report = {
issue: 199,
fixture: LARGE_HOUSE_COUNTS,
warmups: WARMUPS,
samples: SAMPLES,
baseline,
candidate,
budgets: {
absoluteP95Ms: ABSOLUTE_P95_MS,
relativeRatio: RELATIVE_RATIO,
relativeNoiseMs: RELATIVE_NOISE_MS,
relativeLimitP95Ms: relativeLimit,
},
pass,
};
console.log(JSON.stringify(report, null, 2));
if (!pass) process.exitCode = 1;
-107
View File
@@ -1,107 +0,0 @@
// #277: same-run historical edge-drag baseline versus the fixed-topology
// pointer clamp, plus the exact one-space production preflight used on release.
import { performance } from 'node:perf_hooks';
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import {
planEdgeDrag, clampEdgeDrag,
resolveSafeResize, clampSafeResize, applySafeResize,
safeResizeCachedDeltaCount,
} from '../test-build/resize.js';
import { checkOptimizeGeometry } from '../test-build/plan-geometry-preflight.js';
const WARMUPS = 5;
const SAMPLES = 20;
const BATCH = 25;
const POINTER_P95_MS = 16;
const POINTER_RATIO = 1.2;
const POINTER_NOISE_MS = 0.25;
const PREFLIGHT_P95_MS = 75;
const rooms = [{ id: 'active', poly: [[0, 0], [300, 0], [300, 300], [0, 300]] }];
for (let index = 0; index < 199; index++) {
const x = 1000 + (index % 20) * 500;
const y = Math.floor(index / 20) * 500;
rooms.push({ id: `room-${index}`, poly: [[x, y], [x + 300, y], [x + 300, y + 300], [x, y + 300]] });
}
const opts = { minDim: 25, eps: 0.1, movingHalf: 10, obstacles: [] };
const oldPlan = planEdgeDrag(rooms, 'active', 1);
const safeResolution = resolveSafeResize(rooms, [], 'active', 1, opts);
if (!oldPlan || !safeResolution.enabled) throw new Error('benchmark fixture is not resize-eligible');
const safePlan = safeResolution.plan;
const baselinePointer = () => clampEdgeDrag(rooms, [], oldPlan, 100, 5, opts);
const safePointer = () => {
const delta = clampSafeResize(rooms, [], safePlan, 100, 5, opts);
return applySafeResize(rooms, [], safePlan, delta);
};
const large = makeLargeHouseFixture();
const currentSpaceConfig = { ...large.config, spaces: [large.config.spaces[0]] };
const precomputeStart = performance.now();
const cachedProductionGeometry = checkOptimizeGeometry(currentSpaceConfig);
const renderPrecomputeMs = performance.now() - precomputeStart;
if (!cachedProductionGeometry.ok
|| cachedProductionGeometry.spaces.some((space) => space.status === 'failed')) {
throw new Error(`safe-resize preflight fixture failed: ${JSON.stringify(cachedProductionGeometry.spaces)}`);
}
// The final preview frame owns the expensive production union. pointerup reads
// the exact cached result for that cfg epoch; this measures commit latency, not
// the existing large-house render budget measured by benchmark:large-house.
const preflight = () => {
if (!cachedProductionGeometry.ok) throw new Error('cached production geometry failed');
return cachedProductionGeometry.fingerprint;
};
const timedBatch = (operation) => {
const start = performance.now();
for (let index = 0; index < BATCH; index++) operation();
return (performance.now() - start) / BATCH;
};
for (let index = 0; index < WARMUPS; index++) {
baselinePointer(); safePointer(); preflight();
}
const baselineTimes = [];
const safeTimes = [];
for (let index = 0; index < SAMPLES; index++) {
const safeFirst = index % 2 === 1;
const first = timedBatch(safeFirst ? safePointer : baselinePointer);
const second = timedBatch(safeFirst ? baselinePointer : safePointer);
const third = timedBatch(safeFirst ? baselinePointer : safePointer);
const fourth = timedBatch(safeFirst ? safePointer : baselinePointer);
baselineTimes.push(safeFirst ? (second + third) / 2 : (first + fourth) / 2);
safeTimes.push(safeFirst ? (first + fourth) / 2 : (second + third) / 2);
}
const preflightTimes = Array.from({ length: SAMPLES }, () => {
const start = performance.now(); preflight(); return performance.now() - start;
});
const quantile = (values, ratio) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
};
const summary = (values) => ({
min: Math.min(...values), median: quantile(values, 0.5),
p95: quantile(values, 0.95), max: Math.max(...values),
});
const baseline = summary(baselineTimes);
const candidate = summary(safeTimes);
const commitPreflight = summary(preflightTimes);
const relativeLimit = baseline.p95 * POINTER_RATIO + POINTER_NOISE_MS;
const pass = candidate.p95 <= POINTER_P95_MS
&& candidate.p95 <= relativeLimit
&& commitPreflight.p95 <= PREFLIGHT_P95_MS
&& safeResizeCachedDeltaCount(safePlan) <= 4096;
console.log(JSON.stringify({
issue: 277,
fixture: { rooms: rooms.length, largeHouse: LARGE_HOUSE_COUNTS },
warmups: WARMUPS, samples: SAMPLES, batch: BATCH,
baseline, candidate, commitPreflight,
renderPrecomputeMs,
cacheEntries: safeResizeCachedDeltaCount(safePlan),
budgets: {
pointerP95Ms: POINTER_P95_MS, pointerRatio: POINTER_RATIO,
pointerNoiseMs: POINTER_NOISE_MS, relativeLimit,
commitPreflightP95Ms: PREFLIGHT_P95_MS, maxCacheEntries: 4096,
},
pass,
}, null, 2));
if (!pass) process.exitCode = 1;
-78
View File
@@ -1,78 +0,0 @@
// #277: warm Resize-layer render cost on the supported large-house ceiling.
// The deterministic snapshot-call assertion catches the original regression
// even when runner timing noise happens to keep the p95 below its budget.
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import { launch } from './serve.mjs';
const WARMUPS = 3;
const SAMPLES = 20;
const RENDER_P95_MS = 25;
const fixture = makeLargeHouseFixture();
const config = { ...fixture.config, spaces: [fixture.config.spaces[0]] };
const { page, browser } = await launch();
const result = await page.evaluate(async ({ config, warmups, samples }) => {
const card = window.__card;
card._serverCfg = structuredClone(config);
card._space = config.spaces[0].id;
card._modelCache = null;
card._cfgEpoch++;
card._setMode('plan');
card._tool = 'resize';
card.requestUpdate();
await card.updateComplete;
const view = card._viewOr(card._baseVb());
const originalSnapshot = card._rszSnapshot.bind(card);
let snapshotCalls = 0;
card._rszSnapshot = () => {
snapshotCalls++;
return originalSnapshot();
};
for (let index = 0; index < warmups; index++) card._renderResizeLayer(view);
snapshotCalls = 0;
const times = [];
for (let index = 0; index < samples; index++) {
const started = performance.now();
card._renderResizeLayer(view);
times.push(performance.now() - started);
}
card._rszSnapshot = originalSnapshot;
return {
times,
snapshotCalls,
roomCount: card._rszRooms().length,
handleCount: card._rszRooms().reduce((sum, room) => sum + room.poly.length, 0),
};
}, { config, warmups: WARMUPS, samples: SAMPLES });
await browser.close();
const quantile = (values, ratio) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
};
const render = {
min: Math.min(...result.times),
median: quantile(result.times, 0.5),
p95: quantile(result.times, 0.95),
max: Math.max(...result.times),
};
const pass = result.snapshotCalls === SAMPLES
&& result.roomCount === 20
&& result.handleCount === 80
&& render.p95 <= RENDER_P95_MS;
console.log(JSON.stringify({
issue: 277,
fixture: LARGE_HOUSE_COUNTS,
warmups: WARMUPS,
samples: SAMPLES,
roomCount: result.roomCount,
handleCount: result.handleCount,
snapshotCalls: result.snapshotCalls,
snapshotCallsPerFrame: result.snapshotCalls / SAMPLES,
render,
budgets: { renderP95Ms: RENDER_P95_MS, maxSnapshotCallsPerFrame: 1 },
pass,
}, null, 2));
if (!pass) process.exitCode = 1;
-118
View File
@@ -1,118 +0,0 @@
// #278: component projection overhead and degraded-fixture completion budget.
import { performance } from 'node:perf_hooks';
import { readFileSync } from 'node:fs';
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import { prepareSpacePhysicalGeometryInputs } from '../test-build/plan-geometry-preflight.js';
import { spaceModels } from '../test-build/space-geometry.js';
import {
polyclipToPathD, wallBodiesGeometry, wallBodiesUnionPath,
} from '../test-build/wall-thickness.js';
const WARMUPS = 3;
const SAMPLES = 50;
// One projection is sub-millisecond. Time a batch and report per-operation
// cost so scheduler/timer quantisation cannot dominate the 10% relative gate.
const VALID_BATCH = 100;
const RELATIVE_RATIO = 1.1;
const OVERHEAD_P95_MS = 20;
const DEGRADED_P95_MS = 100;
const prepare = (config) => {
const models = spaceModels(config);
return config.spaces.map((space, index) =>
prepareSpacePhysicalGeometryInputs(space, models[index]));
};
const args = (input) => [
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
input.physicalBodies,
];
const large = makeLargeHouseFixture();
const validInputs = prepare(large.config);
const degradedFixture = JSON.parse(readFileSync(
new URL('../test/fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
));
const degradedInput = prepare(degradedFixture.config)[0];
const validResults = validInputs.map((input) => wallBodiesGeometry(...args(input)));
if (validResults.some((result) => result.status !== 'ok'))
throw new Error(`valid geometry: ${validResults.map((result) => result.status).join(',')}`);
const validGeometry = () => {
for (const result of validResults) {
// The previous production projection also serialized primary + paper.
if (!polyclipToPathD(result.geom) || !polyclipToPathD(result.paperGeom))
throw new Error('valid legacy projection');
}
};
const validProjection = () => {
for (const result of validResults) {
const paths = result.components.map((component) => polyclipToPathD(component.geom));
const paper = polyclipToPathD(result.paperGeom);
if (!paths.length || paths.some((path) => !path) || !paper)
throw new Error('valid component projection');
}
};
const degradedProjection = () => {
const result = wallBodiesUnionPath(...args(degradedInput));
if (!result || result.status !== 'degraded-extra' || result.paths.length !== 2)
throw new Error(`degraded projection: ${result?.status || 'null'}`);
};
const timed = (operation, iterations = 1) => {
const started = performance.now();
for (let index = 0; index < iterations; index++) operation();
return performance.now() - started;
};
const run = (operation) => {
for (let index = 0; index < WARMUPS; index++) operation();
return Array.from({ length: SAMPLES }, () => timed(operation));
};
const runPairs = () => {
for (let index = 0; index < WARMUPS; index++) {
validGeometry();
validProjection();
}
const baseline = [], candidate = [], overhead = [];
for (let index = 0; index < SAMPLES; index++) {
let baseMs, candidateMs;
if (index % 2 === 0) {
baseMs = timed(validGeometry, VALID_BATCH) / VALID_BATCH;
candidateMs = timed(validProjection, VALID_BATCH) / VALID_BATCH;
} else {
candidateMs = timed(validProjection, VALID_BATCH) / VALID_BATCH;
baseMs = timed(validGeometry, VALID_BATCH) / VALID_BATCH;
}
baseline.push(baseMs);
candidate.push(candidateMs);
overhead.push(candidateMs - baseMs);
}
return { baseline, candidate, overhead };
};
const quantile = (values, ratio) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
};
const summary = (values) => ({
min: Math.min(...values), median: quantile(values, 0.5),
p95: quantile(values, 0.95), max: Math.max(...values),
});
const pairs = runPairs();
const baseline = summary(pairs.baseline);
const candidate = summary(pairs.candidate);
const degraded = summary(run(degradedProjection));
const relativeLimit = baseline.p95 * RELATIVE_RATIO;
const overheadP95 = quantile(pairs.overhead, 0.95);
const pass = candidate.p95 <= relativeLimit && overheadP95 <= OVERHEAD_P95_MS
&& degraded.p95 <= DEGRADED_P95_MS;
console.log(JSON.stringify({
issue: 278, fixture: LARGE_HOUSE_COUNTS, warmups: WARMUPS, samples: SAMPLES,
validBatch: VALID_BATCH,
baseline, candidate, degraded, overheadP95,
budgets: {
relativeRatio: RELATIVE_RATIO, overheadP95Ms: OVERHEAD_P95_MS,
relativeLimitP95Ms: relativeLimit, degradedP95Ms: DEGRADED_P95_MS,
},
pass,
}, null, 2));
if (!pass) process.exitCode = 1;
-29
View File
@@ -29,32 +29,3 @@ export async function assertFreshDemoBundle(page, root = process.cwd()) {
}
return expected;
}
/** Env switch that lets a debugging session run against a stale bundle. */
export const ALLOW_STALE_BUNDLE = 'HP_ALLOW_STALE_BUNDLE';
/**
* The freshness gate for every browser check, escape hatch included (#236).
*
* The smoke launcher had no freshness check at all, while golden runs and
* benchmarks did. A smoke against a stale `demo/srv/assets/houseplan-card.js`
* does not fail cleanly: on #234 three assertions went red and a fourth went
* GREEN, because the old code was wrong in two places that agreed with each
* other. A partly-red partly-green result looks like a logic defect and sends
* the reader hunting in the wrong file.
*
* Skipping is allowed for debugging, but never silently: a skipped guard that
* says nothing is the same silent success this project keeps digging out.
*/
export async function assertFreshDemoBundleUnlessAllowed(
page, root = process.cwd(), env = process.env,
) {
if (env[ALLOW_STALE_BUNDLE]) {
console.warn(
`[houseplan] ${ALLOW_STALE_BUNDLE} is set — bundle freshness NOT verified. `
+ 'A red result may mean a stale bundle rather than a defect (#236).',
);
return null;
}
return assertFreshDemoBundle(page, root);
}
-202
View File
@@ -1,202 +0,0 @@
#!/usr/bin/env node
// Issue #211: human-reviewable Reference SVG <-> Runtime matrix.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { mdiLightbulbSpot } from '@mdi/js';
import { launch } from './serve.mjs';
const artifactDir = resolve('artifacts/device-icon-reference');
mkdirSync(artifactDir, { recursive: true });
const referenceAsset = (theme, file, coreSize) => {
let source = readFileSync(resolve('demo/srv/reference/device-icons', theme, file), 'utf8');
if (file === 'Lock.svg') {
const old = theme === 'Dark' ? '#252525' : 'black';
source = source.replaceAll(old, '#66D17A');
if (theme === 'Dark') source = source.replaceAll('fill="white"', 'fill="#252525"');
}
if (file === 'Unlock.svg') {
source = source.replaceAll(theme === 'Dark' ? '#1DC21D' : '#F0A00C', '#F0410C');
}
const nativeWidth = Number(source.match(/<svg[^>]*width="([\d.]+)"/)?.[1] || 127);
return {
url: `data:image/svg+xml;base64,${Buffer.from(source).toString('base64')}`,
displayWidth: nativeWidth * coreSize / 80,
};
};
const { page, browser } = await launch(
{ width: 1280, height: 960 }, 1, [], { colorScheme: 'dark' },
);
await page.evaluate((path) => { window.__ICONS['mdi:lightbulb-spot'] = path; }, mdiLightbulbSpot);
await page.evaluate(async () => {
const c = window.__card;
const marker = (id, patch) => ({
...(c._serverCfg.markers || []).find((item) => item.id === id),
id, binding: `device:${id}`, ...patch,
});
const replacements = new Map([
['d_light1', marker('d_light1', { display: 'badge', icon: 'mdi:lightbulb-spot' })],
['d_tv', marker('d_tv', { display: 'value' })],
['d_temp', marker('d_temp', {
display: 'badge',
value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: 'sensor.living_temp' },
position: 'right',
},
})],
]);
c._serverCfg.markers = [
...(c._serverCfg.markers || []).filter((item) => !replacements.has(item.id)),
...replacements.values(),
];
c.hass = {
...c.hass,
states: {
...c.hass.states,
'sensor.living_temp': {
...c.hass.states['sensor.living_temp'],
state: '23',
attributes: { ...c.hass.states['sensor.living_temp']?.attributes, unit_of_measurement: '%' },
},
'media_player.tv': {
...c.hass.states['media_player.tv'],
state: 'Working',
},
},
};
c._regSignature = '';
c._cfgEpoch++;
c._maybeRebuildDevices();
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
const qaStyle = document.createElement('style');
qaStyle.textContent = '.devtip{display:none!important}';
(c.renderRoot || c.shadowRoot).append(qaStyle);
await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)));
});
const selector = (id) => `.dev[data-id="${id}"]`;
async function runtimePng(theme, row, size) {
await page.mouse.move(1, 1);
await page.evaluate(({ id, themeName, classes, px, clearValues }) => {
const node = (window.__card.renderRoot || window.__card.shadowRoot)
.querySelector(`.dev[data-id="${id}"]`);
for (const marker of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.dev'))
marker.style.visibility = marker === node ? 'visible' : 'hidden';
node.classList.remove(...[
'theme-light', 'theme-dark', 'on', 'open', 'alarm', 'unavail', 'virtual',
'sel', 'lock-locked', 'lock-unlocked',
]);
node.classList.add(`theme-${themeName}`, ...classes);
node.style.setProperty('--device-base-size', `${px}px`);
node.style.setProperty('--dev-scale', '1');
node.querySelector('.device-core')?.style.setProperty('transition', 'none');
node.querySelector('.device-shell-frame')?.style.setProperty('transition', 'none');
if (clearValues) node.querySelectorAll('.value-badge').forEach((value) => value.remove());
node.blur();
}, {
id: row.id,
themeName: theme.toLowerCase(),
classes: row.classes || [],
px: size,
clearValues: row.clearValues || false,
});
if (row.hover) {
await page.hover(selector(row.id));
await page.waitForTimeout(180);
}
if (row.focus) {
await page.$eval(selector(row.id), (node) => node.focus());
}
await page.$eval(selector(row.id), (node) => {
for (const tooltip of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.devtip'))
tooltip.style.setProperty('display', 'none', 'important');
node.querySelector('.lqi')?.style.setProperty('display', 'none');
});
const clip = await page.$eval(selector(row.id), (node) => {
const shell = node.querySelector('.device-shell-frame').getBoundingClientRect();
const pad = 22;
return {
x: Math.max(0, shell.left - pad),
y: Math.max(0, shell.top - pad),
width: shell.width + pad * 2,
height: shell.height + pad * 2,
};
});
return (await page.screenshot({ clip })).toString('base64');
}
const rows = [
{ label: 'Default', file: 'Icon Default.svg', id: 'd_light1' },
{ label: 'Hover', file: 'Icon Hover.svg', id: 'd_light1', hover: true },
{ label: 'Active', file: 'Icon Active.svg', id: 'd_light1', classes: ['on'] },
{ label: 'Lock', file: 'Lock.svg', id: 'd_lock', classes: ['lock-locked'] },
{ label: 'Unlock', file: 'Unlock.svg', id: 'd_lock', classes: ['lock-unlocked'] },
{ label: 'Selected', file: 'Selected.svg', id: 'd_light1', classes: ['sel'] },
{ label: 'Focus', file: 'Focus Visible.svg', id: 'd_light1', focus: true },
{ label: 'Alert', file: 'Alert Value.svg', id: 'd_temp', classes: ['alarm'] },
{ label: 'Virtual', file: 'Virtual Device Default.svg', id: 'd_motion', classes: ['virtual'] },
{ label: 'Unavailable', file: 'Unavailable.svg', id: 'd_light1', classes: ['unavail'] },
{ label: 'Text', file: 'Text Default.svg', id: 'd_tv' },
{ label: 'Double Right', file: 'Double Default Right.svg', id: 'd_temp' },
];
const matrix = [];
for (const theme of ['Light', 'Dark']) {
for (const row of rows) {
matrix.push({
theme,
row,
size: 56,
runtime: await runtimePng(theme, row, 56),
});
}
for (const size of [32, 96]) {
const row = rows[0];
matrix.push({ theme, row, size, runtime: await runtimePng(theme, row, size) });
}
const textRow = rows.find((row) => row.label === 'Text');
matrix.push({ theme, row: textRow, size: 96, runtime: await runtimePng(theme, textRow, 96) });
}
const escapeHtml = (value) => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
const body = matrix.map(({ theme, row, size, runtime }) => {
const reference = referenceAsset(theme, row.file, size);
return `
<tr>
<td>${theme}</td><td>${escapeHtml(row.label)}</td><td>${size}px</td>
<td class="preview"><img style="width:${reference.displayWidth}px" src="${reference.url}" alt="Reference ${escapeHtml(row.label)}"></td>
<td class="preview runtime"><img src="data:image/png;base64,${runtime}" alt="Runtime ${escapeHtml(row.label)}"></td>
</tr>`;
}).join('');
const html = `<!doctype html>
<html><head><meta charset="utf-8"><title>Device icon reference/runtime matrix</title>
<style>
body{margin:24px;background:#777;color:#111;font:16px system-ui,sans-serif}
h1,p{max-width:1100px} table{border-collapse:collapse;width:100%;background:#aaa}
th,td{border:1px solid #555;padding:8px;text-align:left} th{position:sticky;top:0;background:#ddd;z-index:2}
.preview{width:38%;text-align:center;background:linear-gradient(135deg,#d5d5d5 50%,#666 50%)}
.preview img{display:block;margin:auto;max-width:300px;max-height:180px}.runtime img{image-rendering:auto}
</style></head><body>
<h1>House Plan device icons: package 1.1.1 vs runtime</h1>
<p>Issues #211/#217. Reference SVG is loaded directly from the designer package; Runtime is a fresh browser capture. Default covers 32/56/96 px and Text has an additional large 96 px row so its outer stadium curvature is reviewable. Dark Unlock is evaluated using the owner's amber override from #179.</p>
<table><thead><tr><th>Theme</th><th>State/layout</th><th>Core</th><th>Reference SVG</th><th>Runtime</th></tr></thead>
<tbody>${body}</tbody></table></body></html>`;
const htmlPath = resolve(artifactDir, 'device-icons-reference-runtime.html');
writeFileSync(htmlPath, html);
await page.setViewportSize({ width: 1600, height: 1000 });
await page.setContent(html, { waitUntil: 'load' });
await page.screenshot({
path: resolve(artifactDir, 'device-icons-reference-runtime.png'),
fullPage: true,
});
await browser.close();
console.log(`OK device icon reference/runtime matrix: ${htmlPath}`);
-85
View File
@@ -1,85 +0,0 @@
/** Local-only visual proof for #275; external backup contents are never printed. */
import { mkdirSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { optimizePlans } from '../test-build/plan-optimizer.js';
import { launch } from './serve.mjs';
const input = process.argv[2];
const outputDir = resolve(process.argv[3] || '.');
const mode = process.argv.includes('--optimized') ? 'optimized' : 'raw';
const nodesArg = process.argv.find((value) => value.startsWith('--nodes='))?.slice(8) || '';
const nodes = nodesArg.split(';').filter(Boolean).map((pair) => pair.split(',').map(Number));
if (!input) {
console.error('usage: node demo/capture_wall_strip_backup.mjs <backup> <outdir> '
+ '[--optimized] [--nodes=x,y;x,y]');
process.exit(2);
}
const backup = JSON.parse(readFileSync(input, 'utf8'));
const payload = backup?.payload && typeof backup.payload === 'object' ? backup.payload : backup;
const source = {
config: payload?.config && typeof payload.config === 'object' ? payload.config : payload,
layout: payload?.layout && typeof payload.layout === 'object' ? payload.layout : {},
};
const rendered = mode === 'optimized'
? optimizePlans(source.config, source.layout)
: source;
const config = rendered.config;
const layout = rendered.layout;
if (!Array.isArray(config?.spaces) || !config.spaces.length) {
throw new Error('backup has no spaces');
}
mkdirSync(outputDir, { recursive: true });
const { page, browser } = await launch({ width: 1800, height: 1250 }, 1);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(async ({ cfg, lay }) => {
const card = window.__card;
card._serverCfg = structuredClone(cfg);
card._layout = structuredClone(lay);
card._space = cfg.spaces[0].id;
card._setMode('plan');
card._tool = 'select';
card._cfgEpoch++;
card._modelCache = null;
card._frame = null;
card._wallUnionCache = null;
card._physicalBodiesCache = null;
card._lightBarrierCache = null;
card._isoGeometryCache.clear();
card.requestUpdate();
await card.updateComplete;
card._fitAll();
card.requestUpdate();
await card.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
}, { cfg: config, lay: layout });
const fullPath = resolve(outputDir, `${mode}-full.png`);
await page.screenshot({ path: fullPath, animations: 'disabled' });
for (let index = 0; index < nodes.length; index++) {
const center = await page.evaluate(([x, y]) => {
const card = window.__card;
const svg = (card.shadowRoot || card.renderRoot).querySelector('.stage svg');
const matrix = svg?.getScreenCTM?.();
if (!matrix) return null;
const point = new DOMPoint(x * 1000, y * 1000).matrixTransform(matrix);
return { x: point.x, y: point.y };
}, nodes[index]);
if (!center) continue;
const width = 420, height = 360;
const clip = {
x: Math.max(0, Math.min(1800 - width, center.x - width / 2)),
y: Math.max(0, Math.min(1250 - height, center.y - height / 2)),
width,
height,
};
await page.screenshot({
path: resolve(outputDir, `${mode}-node-${index + 1}.png`),
clip,
animations: 'disabled',
});
}
await browser.close();
console.log(JSON.stringify({ mode, full: fullPath, crops: nodes.length }));
-158
View File
@@ -1,158 +0,0 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { visualFingerprint } from '../../scripts/source-fingerprint.mjs';
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
import { launch } from '../serve.mjs';
import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from './screenshots.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const OUTPUT = resolve(ROOT, 'docs/images');
const BUNDLE = resolve(ROOT, 'dist/houseplan-card.js');
const DEMO_BUNDLE = resolve(ROOT, 'demo/srv/assets/houseplan-card.js');
const INTEGRATION_BUNDLE = resolve(ROOT, 'custom_components/houseplan/frontend/houseplan-card.js');
const SCRIPT = fileURLToPath(import.meta.url);
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
const roomCardClip = (page) => page.evaluate(() => {
const card = window.__goldenCard;
const roomCards = [...(card?.renderRoot?.querySelectorAll('.roomlabel') || [])];
const target = roomCards.find((item) => item.querySelector('.rlm')) || roomCards[0];
if (!target) throw new Error('documentation room card is missing');
const rect = target.getBoundingClientRect();
const marginX = 80;
const marginY = 70;
return {
x: Math.max(0, rect.left - marginX),
y: Math.max(0, rect.top - marginY),
width: Math.min(innerWidth, rect.right + marginX) - Math.max(0, rect.left - marginX),
height: Math.min(innerHeight, rect.bottom + marginY) - Math.max(0, rect.top - marginY),
};
});
/**
* Documentation-only presentation state. Keep these mutations out of the
* golden harness: changing that release fixture would invalidate every visual
* baseline even though the production component and golden matrix are intact.
*/
const applyDocumentationState = (page, scenario) => page.evaluate(async (current) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const card = window.__goldenCard;
if (!card) throw new Error(`documentation card is missing: ${current.id}`);
if (current.title) {
card.setConfig({ ...card._config, title: current.title });
}
if (current.roomMetrics) {
const space = card._serverCfg?.spaces?.find((item) => item.id === current.space);
if (!space) throw new Error(`documentation room metrics space is missing: ${current.space}`);
space.settings = {
...(space.settings || {}),
label_temp: true,
label_hum: true,
label_lqi: true,
label_light: true,
};
card._cfgEpoch += 1;
card._modelCache = null;
}
if (current.fixture === 'empty') {
card._serverCfg = { ...(card._serverCfg || {}), spaces: [] };
card._cfgEpoch += 1;
card._modelCache = null;
card._space = '';
card._onboardingShown = true;
card.hass = { ...card.hass, floors: {} };
card._openSpaceDialog('create');
}
if (current.dialog === 'device-info') {
const device = card._devices.find((item) => item.id === current.deviceId);
if (!device) throw new Error(`documentation device is missing: ${current.deviceId}`);
card._infoCard = device;
}
card.requestUpdate();
await card.updateComplete;
await frame();
if (current.devicePresentationPreview) {
const dialog = card.renderRoot.querySelector('hp-dialog');
const body = dialog?.querySelector('.body');
const preview = dialog?.querySelector('hp-device-preview');
await preview?.updateComplete;
if (!body || !preview)
throw new Error('documentation device presentation preview is missing');
const bodyRect = body.getBoundingClientRect();
const previewRect = preview.getBoundingClientRect();
body.scrollTop += previewRect.top - bodyRect.top - 180;
await frame();
const visibleBody = body.getBoundingClientRect();
const visiblePreview = preview.getBoundingClientRect();
if (visiblePreview.top < visibleBody.top - 1 || visiblePreview.bottom > visibleBody.bottom + 1)
throw new Error('documentation viewport does not show the device presentation preview');
}
return { dialog: !!card.renderRoot.querySelector('hp-dialog') };
}, scenario);
mkdirSync(OUTPUT, { recursive: true });
copyFileSync(BUNDLE, DEMO_BUNDLE);
copyFileSync(BUNDLE, INTEGRATION_BUNDLE);
const { page, browser } = await launch();
const browserErrors = [];
page.on('pageerror', (error) => browserErrors.push(error.message));
try {
// Свежесть бандла проверяется строго, вместе с версией: картинки обязаны
// приехать из бандла, собранного из ЭТОГО дерева. А в манифест пишется
// версионно-нечувствительный отпечаток (#245) — номер версии на скриншотах
// не виден, и требовать из-за него пересъёмки нечестно.
await assertFreshDemoBundle(page, ROOT);
const fingerprint = visualFingerprint(ROOT);
const scenarios = {};
for (const scenario of DOC_SCREENSHOTS) {
await prepareGoldenScenario(page, scenario);
const runtime = await applyDocumentationState(page, scenario);
if (scenario.expectDialog && !runtime.dialog)
throw new Error(`documentation scenario did not open its dialog: ${scenario.id}`);
const clip = scenario.capture === 'room-card'
? await roomCardClip(page)
: await goldenClip(page, scenario.capture);
const image = await page.screenshot({
...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css',
});
writeFileSync(resolve(OUTPUT, scenario.file), image);
scenarios[scenario.id] = {
file: scenario.file,
viewport: scenario.viewport,
theme: scenario.theme,
language: scenario.language,
sourceSha256: fingerprint,
imageSha256: sha256(image),
};
console.log(`captured ${scenario.id} -> docs/images/${scenario.file}`);
}
if (browserErrors.length) throw new Error(`browser errors: ${browserErrors.join(' | ')}`);
const manifest = {
version: DOC_SCREENSHOT_VERSION,
fixture: 'synthetic-only',
// Кто снимал. Смена браузера переписывает все картинки без содержательных
// изменений (#246), поэтому окружение съёмки — часть доказательства.
chromium: browser.version(),
sourceFingerprint: fingerprint,
captureScriptSha256: sha256(readFileSync(SCRIPT)),
command: 'npm run build && node demo/docs/capture.mjs',
scenarios,
};
writeFileSync(resolve(OUTPUT, 'screenshots.json'), `${JSON.stringify(manifest, null, 2)}\n`);
} finally {
await browser.close();
}
-75
View File
@@ -1,75 +0,0 @@
/**
* Каталог сценариев съёмки документации. Отдельным модулем, потому что его
* читают трое: сам капчур, `scripts/check-docs.mjs` и приёмка артефакта
* `scripts/docs-accept.mjs` (#246). Импортировать его из `capture.mjs` нельзя —
* тот скрипт при импорте поднимает браузер и снимает картинки.
*/
export const DOC_SCREENSHOT_VERSION = 1;
export const DOC_SCREENSHOTS = Object.freeze([
{
id: 'view-desktop', file: '01-view-desktop.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'view-touch', file: '02-view-touch.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true, kiosk: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 390, height: 760 }, capture: 'page',
},
{
id: 'space-create', file: '03-space-create.png', fixture: 'empty', noFloors: true,
title: 'House Plan', language: 'en', theme: 'dark',
viewport: { width: 900, height: 850 }, capture: 'page', expectDialog: true,
},
{
id: 'room-contour-close', file: '04-room-contour-close.png', fixture: 'visual',
space: 'golden-geometry', mode: 'plan',
wallJunctionPreview: {
path: [[0.18, 0.18], [0.40, 0.18], [0.40, 0.40], [0.18, 0.40]],
pointer: [0.18, 0.18], cms: [440, 440, 440], cm: 15,
},
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'plan-context-tray', file: '05-plan-context-tray.png', fixture: 'visual',
space: 'golden-geometry', mode: 'plan', editorTray: 'plan-selection',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'device-editor', file: '06-device-editor.png', fixture: 'visual',
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
deviceName: 'Living-room ceiling light',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
},
{
id: 'device-display-preview', file: '06-device-display-preview.png', fixture: 'visual',
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
deviceName: 'Living-room ceiling light', devicePresentationPreview: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
},
{
id: 'background-editor', file: '07-background-editor.png', fixture: 'visual',
space: 'golden-geometry', mode: 'decor', editorTray: 'decor-selection',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'room-card', file: '08-room-card.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'room-card',
},
{
id: 'device-info', file: '09-device-info.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', dialog: 'device-info',
deviceId: 'golden-light-two', deviceName: 'Living-room ceiling light',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 900 }, capture: 'page', expectDialog: true,
},
]);
-59
View File
@@ -1,59 +0,0 @@
// AC-13 for #157. Usage:
// node demo/downgrade_open_passage.mjs --bundle=/absolute/v1.64.0/dist/houseplan-card.js
// The v1.64.0 frontend does not understand `passage`; this executable fixture
// pins its documented best-effort fallback (door symbol) and, critically,
// rejects any pageerror/unhandled exception while reading the newer literal.
import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { launch, checkAll, finish } from './serve.mjs';
const value = process.argv.find((arg) => arg.startsWith('--bundle='))?.slice('--bundle='.length);
if (!value) {
console.error('usage: node demo/downgrade_open_passage.mjs --bundle=/absolute/v1.64.0/houseplan-card.js');
process.exit(2);
}
const bundle = isAbsolute(value) ? value : resolve(value);
if (!existsSync(bundle)) {
console.error(`v1.64.0 bundle not found: ${bundle}`);
process.exit(2);
}
const currentDemo = fileURLToPath(new URL('./srv', import.meta.url));
const serveRoot = mkdtempSync(join(tmpdir(), 'hp-157-downgrade-'));
let browser;
try {
cpSync(currentDemo, serveRoot, { recursive: true });
cpSync(bundle, join(serveRoot, 'assets', 'houseplan-card.js'));
const launched = await launch(undefined, 1, [], {}, serveRoot);
browser = launched.browser;
const out = await launched.page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const space = card._serverCfg.spaces.find((item) => item.id === card._space);
space.openings = [{
id: 'future-passage', type: 'passage', x: 0.3, y: 0.14,
angle: 0, length: 0.09, future_material: 'stone',
}];
card._setMode('plan');
card._cfgEpoch++;
card.requestUpdate();
await card.updateComplete;
await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)));
const opening = root().querySelector('[data-hp="opening"][data-id="future-passage"]');
const stored = space.openings[0];
return {
newerLiteralLoads: !!opening,
documentedDoorFallback: !!opening?.querySelector('.op-leaf,.op-arc'),
readDoesNotRewriteConfig: stored.type === 'passage'
&& stored.future_material === 'stone' && space.openings.length === 1,
};
});
checkAll(out);
await finish(browser, out);
browser = undefined;
} finally {
await browser?.close?.();
rmSync(serveRoot, { recursive: true, force: true });
}
+2 -10
View File
@@ -3,8 +3,6 @@
* visual-regression tooling. Nothing here depends on a real HA installation.
*/
import { fixtureWallKey } from './wall-key.mjs';
const FLOOR_COUNT = 3;
const ROOMS_PER_FLOOR = 20;
const DEVICE_COUNT = 200;
@@ -215,14 +213,8 @@ export const makeLargeHouseFixture = () => {
cell_cm: 5,
settings: { fill_mode: 'glow', show_borders: true, show_names: true },
rooms,
// The key must be the real one. A label like `perf-wall-0-3` does not
// parse as coordinates, so neither the exact match nor the tolerant
// fallback in lookupWall finds the record: every solid edge of this
// fixture resolved to zero thickness and the plan carried no wall bodies
// at all, while the same fixture backs four golden scenes and all six
// performance budgets (#260).
walls: segments.map((wall) => ({
key: fixtureWallKey(wall.a, wall.b), cm: 15, a: wall.a, b: wall.b,
walls: segments.map((wall, index) => ({
key: `perf-wall-${floor}-${index}`, cm: 15, a: wall.a, b: wall.b,
})),
openings: makeOpenings(floor, segments, openingCount),
partitions: makePartitions(floor, rooms, partitionCount),
+22 -82
View File
@@ -4,13 +4,22 @@ const round = (value) => Number(value.toFixed(6));
// Golden fixtures must use the same persisted wall-key contract as real plan
// data. Arbitrary labels make every configured wall look virtual to the
// renderer, which lets a visually ineffective baseline pass unnoticed — and a
// key that merely differs in precision is found only through the tolerant
// fallback in lookupWall, which is luck rather than contract (#260). The
// formula lives in one place for every fixture and is pinned to the product one
// by test/fixture-wall-key.test.mjs.
export { fixtureWallKey } from './wall-key.mjs';
import { fixtureWallKey } from './wall-key.mjs';
// renderer, which lets a visually ineffective baseline pass unnoticed.
const WALL_KEY_PITCH = 1 / 240;
export const fixtureWallKey = (a, b) => {
const quantize = (value) => Math.round(value / WALL_KEY_PITCH) * WALL_KEY_PITCH;
const mx = quantize((a[0] + b[0]) / 2);
const my = quantize((a[1] + b[1]) / 2);
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (length < 1e-12) { dx = 1; dy = 0; }
else { dx /= length; dy /= length; }
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
const bucket = Math.round(angle * 1800) / 1800;
return `${mx.toFixed(4)},${my.toFixed(4)}@${bucket.toFixed(4)}`;
};
const uniqueEdges = (rooms) => {
const edges = new Map();
@@ -48,11 +57,6 @@ const lightingRooms = [
poly: [[0.50, 0.10], [0.93, 0.10], [0.93, 0.88], [0.50, 0.88]] },
];
const applianceRooms = [
{ id: 'appliance-room', name: 'Laundry', area: 'golden_appliance',
poly: [[0.08, 0.10], [0.92, 0.10], [0.92, 0.90], [0.08, 0.90]] },
];
const geometrySpace = {
id: 'golden-geometry',
title: 'Geometry matrix',
@@ -118,25 +122,7 @@ const lightingSpace = {
decor: [],
};
const applianceSpace = {
id: 'golden-appliance',
title: 'Appliance lifecycle',
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: {
fill_mode: 'none', glow_enabled: false, show_borders: true, show_names: true,
sun_rays: false, bg_mode: 'static',
},
rooms: applianceRooms,
walls: wallsFor('appliance', applianceRooms, 15),
openings: [],
partitions: [],
wall_columns: [],
decor: [],
};
const runtime = (includeAppliance = false) => {
const runtime = () => {
const devices = {};
const entities = {};
const states = {
@@ -150,8 +136,7 @@ const runtime = (includeAppliance = false) => {
// The production projection must preserve them.
const layout = {};
const areas = Object.fromEntries(
[...geometryRooms, ...lightingRooms, ...(includeAppliance ? applianceRooms : [])]
.map((room) => [room.area, { area_id: room.area, name: room.name }]),
[...geometryRooms, ...lightingRooms].map((room) => [room.area, { area_id: room.area, name: room.name }]),
);
const add = (id, domain, area, x, y, state, attributes = {}) => {
const entityId = `${domain}.${id.replaceAll('-', '_')}`;
@@ -186,44 +171,6 @@ const runtime = (includeAppliance = false) => {
add('golden-right-linkquality', 'sensor', 'golden_light_right', 0.66, 0.70, '190', {
unit_of_measurement: 'lqi',
});
if (includeAppliance) {
const washerId = 'golden-washer';
devices[washerId] = {
id: washerId,
name: 'Golden washing machine',
model: 'GOLDEN-WASHER-COMPOSITE',
area_id: 'golden_appliance',
identifiers: [['houseplan_golden', washerId]],
config_entries: ['golden_entry'],
entry_type: null,
via_device_id: null,
disabled_by: null,
};
const addWasherEntity = (entityId, state, attributes = {}, registry = {}) => {
entities[entityId] = {
entity_id: entityId,
device_id: washerId,
platform: 'houseplan_golden',
config_entry_id: 'golden_entry',
disabled_by: null,
...registry,
};
states[entityId] = {
entity_id: entityId,
state,
attributes: { friendly_name: registry.original_name || entityId, ...attributes },
};
};
addWasherEntity('switch.golden_washer_power', 'on', {}, { original_name: 'Power' });
addWasherEntity('switch.golden_washer_child_lock', 'off', {}, { original_name: 'Child lock' });
addWasherEntity('sensor.golden_washer_status', 'done', {}, {
original_name: 'Status', translation_key: 'status',
});
addWasherEntity('sensor.golden_washer_stage', 'Rinse', {}, { original_name: 'Stage' });
addWasherEntity('sensor.golden_washer_program', 'mixed_wash', {}, { original_name: 'Program' });
layout[washerId] = { s: 'golden-appliance', x: 0.5, y: 0.5 };
}
return { devices, entities, states, layout, areas };
};
@@ -235,12 +182,9 @@ export const VISUAL_MATRIX_COUNTS = Object.freeze({
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
});
export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) => ({
export const makeVisualMatrixFixture = () => ({
config: {
spaces: [
structuredClone(geometrySpace), structuredClone(lightingSpace),
...(applianceLifecycle ? [structuredClone(applianceSpace)] : []),
],
spaces: [structuredClone(geometrySpace), structuredClone(lightingSpace)],
// A persisted marker is part of the fixture contract for scenarios that
// override per-source Glow controls. The device/layout alone are not a
// saved marker configuration and must not be silently treated as one.
@@ -257,10 +201,6 @@ export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) =>
},
},
},
...runtime(applianceLifecycle),
counts: applianceLifecycle ? {
...VISUAL_MATRIX_COUNTS,
spaces: VISUAL_MATRIX_COUNTS.spaces + 1,
rooms: VISUAL_MATRIX_COUNTS.rooms + applianceRooms.length,
} : VISUAL_MATRIX_COUNTS,
...runtime(),
counts: VISUAL_MATRIX_COUNTS,
});
-63
View File
@@ -1,63 +0,0 @@
/**
* Ключ записи толщины стены — один на все фикстуры проекта.
*
* Копия формулы из `src/wall-thickness.ts`, и копия здесь неизбежна. Фикстуры
* обязаны оставаться без внешних импортов: бэкенд-гейт запускает их как
* `node --input-type=module --eval "import * as f from './demo/fixtures/…'"`
* в job без `npm ci` и без `test-build/` (`.github/workflows/validate.yml`,
* job `backend`), а `scripts/source-fingerprint.mjs` хеширует только `src/**`
* и `.mjs` из `demo/fixtures` и `demo/golden` — код, втянутый из `scripts/`,
* менял бы поведение фикстуры при неизменном отпечатке, на котором стоят и
* валидность golden-эталонов, и переиспользование гейтов.
*
* Поэтому файл лежит ЗДЕСЬ, внутри `demo/fixtures`: так он попадает в
* отпечаток, и так его видит одна привязка вместо трёх копий формулы.
* `test/fixture-wall-key.test.mjs` сверяет его с продуктовым `wallKey`.
*
* Ловушка, из-за которой этот файл и появился (#260): точность зависит от шага.
* При `pitch = 1/240` продукт печатает ШЕСТЬ знаков, а не четыре — фикстура с
* четырьмя расходилась с продуктом на каждой записи и находилась только через
* терпимый запас `lookupWall`. Метка вместо ключа (`perf-wall-0-3`) не
* находилась вовсе: все сплошные рёбра оставались с нулевой толщиной.
*/
/** Шаг решётки редактора в нормализованных координатах (`GRID_N = 240`). */
export const WALL_KEY_PITCH = 1 / 240;
/** Направление стены по модулю 180°: стена одна и та же с любого конца. */
const direction = (a, b) => {
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (length < 1e-12) return [1, 0];
dx /= length; dy /= length;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) return [-dx, -dy];
return [dx, dy];
};
/**
* Координата, отличающаяся от узла решётки не больше точности хранения, — это
* тот же узел (#258). Канонизация опознания, а не снап геометрии: произвольная
* точка вне решётки остаётся вне решётки. Без этого шага ничья округления на
* стене нечётной длины в шагах разводила один и тот же ключ на два.
*/
const keyEpsilon = (pitch) => Math.max(Math.abs(pitch) * 1e-6, 1e-9);
const canonical = (value, pitch) => {
if (!(pitch > 0) || !Number.isFinite(value)) return value;
const snapped = Math.round(value / pitch) * pitch;
return Math.abs(snapped - value) <= keyEpsilon(pitch) ? snapped : value;
};
export const fixtureWallKey = (a, b, pitch = WALL_KEY_PITCH) => {
const quantise = (value) => (pitch > 0 && Number.isFinite(value)
? Math.round(value / pitch) * pitch : value);
const ca = [canonical(a[0], pitch), canonical(a[1], pitch)];
const cb = [canonical(b[0], pitch), canonical(b[1], pitch)];
const mx = quantise((ca[0] + cb[0]) / 2);
const my = quantise((ca[1] + cb[1]) / 2);
const [dx, dy] = direction(ca, cb);
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
const bucket = Math.round(angle * 1800) / 1800;
const precision = pitch > 0 && pitch < 0.01 ? 6 : pitch < 1 ? 4 : 2;
return `${mx.toFixed(precision)},${my.toFixed(precision)}@${bucket.toFixed(4)}`;
};
+1 -1
View File
@@ -35,7 +35,7 @@ Build and copy the exact current source first:
```bash
npm run build
npm run bundle:sync
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
npm run golden:capture
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 66 KiB

+52 -131
View File
@@ -1,138 +1,59 @@
{
"schema": 1,
"matrixVersion": 46,
"acceptedAt": "2026-08-25T20:12:50.448Z",
"sourceFingerprint": "e28606975682347f90f12971053825ce474085cbddd56082e867370b1039d14d",
"matrixVersion": 18,
"acceptedAt": "2026-08-13T19:26:19.387Z",
"sourceFingerprint": "27fda3d75e4cda95b9d85a9481f15ccd44b96d8c9d3311e0476718d36e2588c5",
"chromium": "151.0.7922.34",
"scenarios": {
"hidden-wall-diagnostics-plan-light": "6470ed8b09b21144ec1399389e9a57774e977a6473b8eaa7de7e9b5df3273f21",
"hidden-wall-diagnostics-plan-dark": "da6e725ddfad7c807408a710b78090dc222b56dcee484a29baa68b5452fc44e5",
"coincident-partition-before-dark": "19626db704fccb7a42b519ffb20b7bad82c726e8845c752ab451f731d264561c",
"coincident-partition-thin-dark": "3afede1f99b86454bf4e50d9628196a2240243e034644592c5c3efa1b4561930",
"coincident-partition-thick-dark": "57960bd09e6cbbeb073561692690ca68a72d6dbf8242198ef73b8d4ae4d8fbcc",
"coincident-partition-virtual-dark": "da7adbf931ad95f4e620d9d088177f1da20d88a21b9e85b6ff2675925d5557b4",
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "17e49394c300887bb5349bf477292571e96e209c9515db6853348883d95a1422",
"split-corner-wall-thick-dark": "f7864a6b2ab58586540f836183c9e59f09f9f57165ec0e0b9cd8444ada71153c",
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
"isometric-geometry-view-dark": "43ad006844a78b502b36aeeb82d82669532e06d2c798de928a5207acd24bd5f9",
"isometric-geometry-view-light": "0fcb10ffdc0baa9006d1c2f0406ebf19e71ba9feda180979a51b74166d7200d2",
"isometric-live-layers-dark": "8aaa914fc45780f67dd5c008a106696d21235a9acee7402f9cc8ae51a26660f3",
"isometric-no-borders-dark": "c105382d883bbdae8562b756ccbf47ac1fcf46675c6aa7654289856223a3ebf0",
"isometric-touch-kiosk-dark": "be122ceec8114374ba384f51d5d9287e498a97354865c5a9f91d505ca18d8640",
"isometric-large-warm-remount-dark": "d76e3334bf80bff265b1e8aa6770c5eff5df6fbfebeb4703b04d048c4252eb68",
"geometry-view-dark-fit": "1b80d2d0fe84cfe15f1fa29ccc2a2dcc66b4c5e62d1722c06c00442c78979be0",
"geometry-view-light-fit": "91caf6621af00eb0cd16589e2fc79976cdca1e8467d0e86151ecbc46948612d9",
"room-label-parity-view-dark": "7f1da43466a13f82a7595dddabe6938ed974debfb14c8f40e729e273a53ac4ff",
"room-label-parity-plan-dark": "5f976d5743d68a27b594e1a5ddf45e5ad3708f5c703409e5cefb7c96d3048096",
"room-label-parity-view-light": "ee3d303fb2b329c356a4354d355c29694130a38d4812ffbfefbe861f5d3f9860",
"room-label-parity-plan-light": "d32b538499a031d4310b243cc9ef43610f6cd67aabfde7cd01b59c88b0363ec5",
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
"day-cycle-dawn-dark": "3f32bc1ed042475516f7e239aedd7fee11717b00fa2103ae9f1231e006d64015",
"day-cycle-day-dark": "1a0f53c948ae669c7ab4e1dcb931c11e7c878974862637dd1f596011876a7ed4",
"day-cycle-dusk-dark": "ec95e99ac3c7e4d6affa2f7944759727251dc4f1b62e2855e421a7e48f7280eb",
"day-cycle-night-dark": "cc49dc53dabb63b7dd800eaa65c8748972fcf3c1abd948b34d1e0f973e63f0d2",
"geometry-plan-editor-dark": "fd8d067daa3b2a4e572dea9644486e6324318e8ea4e5f43e3df2069a05389a88",
"safe-resize-handles-clamp-light": "ed705040357cad043095baf12d02cc69abdc3db27ff40066a6979be85d035f77",
"safe-resize-handles-clamp-dark": "9d81a45f3acac787cde1b9eb74a858020fbad09670b8ff5b081869b5eca73b82",
"space-tab-drop-before-light": "401dded3d7fdff3e823d46eac1b3cef2d6e0eb05f48b5ea289cdd8ecd941dee0",
"space-tab-drop-after-dark": "a851621040e14f911b1080488f2c75e3d9bc0d5fef74fb5d0081f80b023ff0a2",
"plan-snap-endpoint-light": "db967076cd9fd50c2a2a1337e4ecbf4ef67cb7f800401f6227497c75415951d5",
"plan-snap-line-gaps-dark": "ff1b6bbf2e1b4272bb2f0e96d92136aa6960ee24acef1b63b4478e54441a4b58",
"wall-junctions-plan-preview-light": "fa81fa6c6dba92578a4beee5ed29f407443a3bc61affa25c44fcfefab0c7070c",
"wall-junctions-plan-t-dark": "f8a517d316a99ca9ee5b4ff56f515be15d440adfc551bbdaaa5e9d9a411a79bc",
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
"junction-patch-resilience-plan-dark": "57ea7522c438e52292f226f975bb112930976b6e072b72322180d52ec0277f4f",
"junction-patch-resilience-view-dark": "1f0df26ed72bcc5bef06a8366d7043ad40b4a2bb744fa5a64248c29eae968424",
"wall-union-isolation-view-light": "291c045758be2814be88d12ab46bdc3d12cb99a03dbbcc9a1539c2c9a5931edf",
"wall-union-isolation-view-dark": "616fc6cc7144c9596ae52421027914e222f30f26de45215fa7e7b6175ca78543",
"multiwall-junction-bevel-view-dark": "e226d023fbab0502eb747395d43a46bb56bcf2be037ba4f9b7ad74ba406d9671",
"orthogonal-strip-cell-5-view-dark": "9b8e4bd22cbf1a82ff48f052822967c61179290a3234ae48ef547642329b5a90",
"orthogonal-strip-cell-1-view-dark": "e59e51ad3645144161459c438a07420730c4dcfd2c33bd2b18e8c3e57ea84515",
"wall-key-roundtrip-view-dark": "df46b4e209f98b97958748097ddc646ddc64d8285c2651762de71ec2456ac690",
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
"opening-placement-door-thick-wall-dark": "1157139aac7325b40e3869b8c3b5444509b4bc24a415ddf6f8d5402624f3a04a",
"opening-placement-passage-thick-wall-dark": "6d4a9a953b70401ca11cd6ce96c2e0e0781bbfff71e758dc57958a4163e2712d",
"opening-placement-passage-thick-wall-light": "b004b1395652c8a8eef5f5002b471ecf8f2f0347f158a14a056ff2b7382e4ea3",
"opening-symbol-room-wall-light": "562fed297210ef542de608e384c2d344dddd193a5b43615930c9f76faaf53b42",
"opening-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
"opening-symbol-flip-pairs-light": "33364f51fb65e49bfdd6c41d6e0c53542c89fadc45a3c6e5adc590144c4676ea",
"isometric-opening-symbol-parity-dark": "302daf5d90d4af5ef90036cc10e957e6fc909ea56f9f2af938ab850d4f608e34",
"geometry-devices-editor-dark": "4b91bdc00082e33d2529ea3cf7ce7af36383a84838f4a2c909463df24483b47b",
"geometry-decor-editor-dark": "8b03bc87d2353cb6ef8fb4e2dec3f0d3be85de903ac9346171ea4aaa31170a66",
"tray-wide-selection-en": "d140cf6a51c634a38d2e3661c4b61835d63673174fd934b85f9da0db4fa20d1a",
"tray-wide-tool-ru": "806ff7c5ae3d2e708a4682833af7dc28a88001ef09e4ca564c91a5e6023b297d",
"tray-medium-group-en": "5a1adbaa64d2b73f294003e2752a79008f7ec313ab41dd0514b6b38918b8c00e",
"tray-medium-selection-ru": "53c7ea5099772fd14c2e27ad414c4d57d87c020866992b7a0b0dd62a22fbeb7e",
"tray-narrow-palette-en": "5bc029bb7a4b077a45abc0c18f54360f781caa3684befb63cfa714efcfaf7edd",
"tray-narrow-tool-ru": "32e27435ec9f0ce6db2e2800ffd71b6a5fcb1ab7dde17454a3ba7dda680b8a45",
"geometry-diagonal-45-opening-dark": "a0167f3028cd8ef8283c92ae7e803940cf0eb0db45f1b137108a4e009741a5df",
"openings-thick-wall-dark": "c45bae26ec0b4f719f72e5b92e3b8e03260d7bda4b3182d4c563b50f656e68a1",
"openings-filled-tunnel-dark": "b916d3ce48bba7487ed4b548bf659b610243ebcde07206c21fff65867792d0d1",
"decor-over-opaque-hover-light": "553d279db44aa0a4a6855496036b0f6ddda9570cef133e09a30f29f41b7d0c31",
"decor-over-glow-base-dark": "8e5b8630798e31936f8e7dd52d1cba73a3f80fd3d3217a5ad59d50b5dc12a26d",
"openings-hidden-view-dark": "d99f365376e6ece668060181ce8c3a5ebdb93ec5a8c64e08b0aef5af592c6874",
"lighting-glow-sun-dark": "8599aea80a0479442f891adb30aecd647ddf217e28e3f0da5c21b41e21d746ec",
"device-value-badge-positions-dark": "ae163e96cd976e006eacd09ebc75440d600e81c063e38da27d53000e4e51c68d",
"device-icon-state-table-light": "969879fc7baeb193639a03701eb8bacc9469f6160b8d41a318db8da83590e51d",
"device-icon-state-table-dark": "045145a87ccde3894334009c6b9edd690654fd048c55b770e09d5ed79f9a7351",
"device-text-shell-long-light": "a96d16ac0a02ed322bab949e34ff7ce59da5f5db42e02185fac5fde495591bb1",
"device-text-shell-long-dark": "f301f3aa1e1eb382018c4329db2909a8d2990f5f917bde83e3de46ff4dc7416c",
"lighting-sun-window-state-only-dark": "4da798c4de6d1303d17be246e9a2e63b12da3f53a42136cf745d1a92ad5a4f21",
"lighting-fill-light-axis-split-dark": "6b3a4dd637c6fe7e43a131c44c4be232bd81edab417ea9af3860d82c506ad45f",
"lighting-fill-temp-axis-split-dark": "6025c6efd3ea3565c13e147db550b017eeb86da0be5dfc82a8b5bd7e623d5852",
"lighting-fill-lqi-axis-split-dark": "1ec5c18572fd5be471088286c4aa7c67c3885cb907080fcde44c3583ff794275",
"lighting-temp-glow-dark": "274bcd5a19ebbcb63fb64a5d21f838122f1916d428defcaa3c0356d8ea9809a5",
"lighting-temp-glow-light": "decc386073a1e93d8d457ec09daf83e8afc180977f5880ce56cc4834a2c203f0",
"lighting-custom-glow-dark": "32607e6001c4c8cd541cabf82142f3cbf7f2b298900a02f30c6f44ececbd5907",
"lighting-opaque-glow-two-doorways-dark": "bd9f49432c69c8ed622810e96f4708974e2024c58700b0cfa3e5ebd2edd7bc7b",
"lighting-custom-glow-light": "973ae32bb3bacbfe633041e89352a9ef1a2222b89a4792feef45c26bfa541459",
"lighting-temp-glow-no-sources-dark": "e19b7d86d5dae54f9afc0a1ef04a3848ebec283a5f60e68efdcc7380dff3b0a7",
"lighting-temp-glow-room-override-dark": "bebfc27f460cb75e32ab14076a01e3b9729763d7cecd3f457c1b696ed3e57da3",
"lighting-manual-auto-spill-overlap-dark": "471e39397361832a171f0309302be7d8ba4310d628e337478e23da44dc455ef8",
"hover-over-glow-dark": "6581433024060f773e00211d60eac7b6daffe442031ff076f861918419c71930",
"hover-nested-room-dark": "f05ba332a127b56c00a6a94eac727b168ed34cc3f0355f8b783827f7bf55714c",
"junction-t-90-equal15-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
"junction-t-90-bar50-leg15-dark": "e981f0597a55e9e9e623dd49417f33a0ebac18d18e0e66675a9af1f2224bd152",
"junction-t-90-bar70-leg15-dark": "4428fbe75679fab6e57ef61c5d5097d498960e2539bb9e759e1bae9a160ef174",
"junction-x-90-equal15-dark": "8fe0e262e5873714f94b9a6a76ed920881bc6b267e69153039f52a7102ec55d5",
"junction-x-90-mixed-dark": "deabce4c2b5fa7866a4f56d25404c6a515d47437298bde114442bc80a6339560",
"junction-x-45-alternating-dark": "56a85069baafd6e7af921389b55b916b74cc5d76df908782013b94c2b5b87752",
"junction-star5-equal15-dark": "1b9714dffa3f37b9f1e8d848244c4585d38539b74fe582e8d974e2b9e8fdd01a",
"junction-y-60-equal50-dark": "f90d46cfe79a95bb3ec73253d0b7f77948164e90c49561d79b1e0db655cc0847",
"junction-acute30-mixed-dark": "9ea64ae5864295a1d93a6819efb6e210de2599030fe650913862d99f40b11a69",
"junction-acute15-equal50-dark": "3327caf8bc1ccddb317aa7c4c330e09c098122a4f6d2e4df64502cdc033cb2e4",
"junction-splay10-170-dark": "ae27454bb4213237313f1fe9e2591ada48facf20baca5517968f92aebbfb330e",
"junction-t-virtual-arm-dark": "d325dc112e2f56c31d4511fd586adaa92b6785fc676c2a60ed5aba69c164fd6e",
"junction-x-virtual-through-dark": "cce24ac9d7678959844803c6306e65ab48f723ec0375ee6d3f2dbf1c7def7be1",
"junction-column-node-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
"junction-draft-end-node-dark": "6453bb8463ffdc9a42dbd7acb171c6249fe6d5beec0eae0c789e40f48e65a1f9",
"junction-owner-repro-dark": "23ae216c2637b8ee9ea8f852002693f74d64021a66d4494e2086a53db8ca6520",
"large-house-zoom-040-dark": "df1a5515089b5e470ae550ce6da31a301be5079d3f7bb9130baad99d3d7c641e",
"large-house-zoom-250-dark": "88c0d22686fd9ef314866d0275a5ae785781c3d671e257b2c5f790f816cd65ee",
"large-house-warm-remount-dark": "9a61d668c5fbc61bb370c043771cee91707b3d40378cf89f81199882820bf95c",
"device-dialog-desktop-en": "9739886af3104c2fe3d6d3138293e9fd1a7f480f151decc26f12bb64cab69543",
"device-dialog-mobile-ru": "c90be98e65c37963fd4443e413848ca68a412c9ee568d648aba76bf9232ee59d",
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
"device-help-popover-light-ru": "add3275d74ced358a184ec9f864c83735f1196e2bff7d75fb3e12160cb85bfaf",
"decor-color-popover-mobile-ru": "51725859afc6a1e2d1b2ae8be270ab584608901623ef7e2d283b6b195644fd16",
"decor-color-popover-desktop-en": "8ad229fd3d433f969dc6d09c7677e9949d4f62503196bffbf17030386f7c7511",
"general-color-popover-desktop-en": "91c3b7ebae5146c62d172d1ce10ae9e375ed955f23e9dc860976373a334d8be5",
"device-ripple-color-popover-mobile-ru": "3ab12c339106004c76fe3c7741d242269e157c40ddfa29afd8f13b445b0a7023",
"space-room-color-popover-desktop-ru": "d66d068a1b403f88a5bd3c57f4a9e55c336767c533ddfe3ae251cc4f9da40a74",
"backup-full-preview-desktop-en": "c9fe8ca1cf63b9ed98e2d517a2c06639d044d460011d958573e70c6b22bd7a2f",
"backup-plan-only-export-desktop-en": "c48b3ccdd7d85d36f132c2d7b4c5dba94a1027e3e3f2ef495b4eb89a23f8ca33",
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
"optimize-preflight-dialog-dark-en": "6e0706b951db73c7d699173d9cd0cd3cd8857238e3d7e95bc38eeb3691f907b6",
"optimize-preflight-dialog-light-ru": "a96af4b4428297d2c672f0d0e8f376e81c27765ecc941781a2d3873a054e2ec3",
"optimize-orphan-references-dark-en": "cdf4eb8c79a9961a1e961a4eed4107d4d849ebc5b922ad88ce07befee6b65a9a",
"optimize-orphan-references-light-ru": "c07151ac896e43bef6150128cbef8a5798ee113083961ed8eaf97113a0b56e1e",
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0",
"junction-309-step-dark": "f5e0e649ee19197026b87687fd3512b18536883f071348589ec3a7f0ea2a216f",
"junction-309-spike-dark": "98e709aa2fa58a01fe280635c6a3799e3a5f4158b2ada12c31c38818f92c4f44",
"junction-309-hump-dark": "abae96fd96ed8515d7b1e33b84858bdf08ac28a888898549874baa8c98202e7a"
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
"isometric-geometry-view-dark": "73939fa61bebe2254591a788fad455a026a5e3b7394eeff27d4fe9a905de7ad2",
"isometric-geometry-view-light": "fc62f6ee8935d2e0c57b4918bf5fe554056a5956747b53a752ae265349ce662e",
"isometric-live-layers-dark": "869c62bf9cd762c36d342d1a3bbd992969425b46b132f3243439735e2c75c14f",
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
"isometric-touch-kiosk-dark": "5eba7794e563e1ef5b9387184693c819976454d0efd222bd12b38aad8ea03e20",
"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",
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
"lighting-glow-sun-dark": "a98eee332f25a43c8d9d126c118c8cfea4ee73752b1060227548efedaf0efcdd",
"device-value-badge-positions-dark": "1ad43f2bd866733aa75c34de38fb97d469661799b540a8ae22150067d788cec8",
"lighting-sun-window-state-only-dark": "3bd581a23a2e0ebba58530db5182adea5cba6ec10bc032bee024415c19108a17",
"lighting-fill-light-axis-split-dark": "4f867528aeb9124229f81659876b03ff297a3a7a92bc57ffb32d5c24913c4938",
"lighting-fill-temp-axis-split-dark": "e0535b70701c9fe6753f74c943b9f288a0fb1a23a9cfc8590d4945e0a1724eb5",
"lighting-fill-lqi-axis-split-dark": "485ab183144913569ddc11154553ab4ac7db522ab188de74d652b717dc786d9c",
"lighting-temp-glow-dark": "ecaed039fb6aab4e1fdc9f1856c89e5f563219b8ff813877027737cecbeca41a",
"lighting-temp-glow-light": "5bc8a35aaa94c427d465d198f1cd5eecfdc704f5abeded9e407f32ce93aa7d32",
"lighting-custom-glow-dark": "899e334dfc0d3490ca291b7239bf7b88ca9d1ef3be199ddf347314bab1513f27",
"lighting-opaque-glow-two-doorways-dark": "413f5a8e39193ba941f72955a391ccac954c09f24322b7bf67494c7691275980",
"lighting-custom-glow-light": "266bba4ae1744a884b2cd224b37fdc36447d57405a1c5298ca30027e2957cc8a",
"lighting-temp-glow-no-sources-dark": "5100c81543fc30a7934a6db6f9e67e2c4fa185df0a974be5911879e43c0d3fc9",
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
"hover-nested-room-dark": "6c09526ad885c4555063def5b43287b41124a81d90972c425084dba6e622d055",
"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",
"backup-space-preview-mobile-ru": "998c6b52c1cc95109feb440a9966cade738154ceeae387246d40f8c56f9e8a3a"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 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: 335 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: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

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