Compare commits

..
Author SHA1 Message Date
claude[bot] aaa696cf38 docs: review document for #266
Issue: #266
User-Visible: no
2026-08-25 23:26:58 +00:00
Codex 7fd0ae4b2d docs: refresh the screenshot fingerprint after the cascade fix (#266)
Кадры прежние — golden и смоки подтверждают пиксельную идентичность.

Issue: #266
User-Visible: no
2026-08-26 02:04:17 +03:00
467 changed files with 27714 additions and 71777 deletions
-10
View File
@@ -31,16 +31,6 @@ fi
status=0
# Локальный набор гейтов (#343). Выключен по умолчанию намеренно: 20-45 секунд на
# каждый пуш, включая пуши одной строки документации, — цена, которую стоит
# платить осознанно. Документация: docs/TESTING.md.
if [ "${HP_PREPUSH_GATE:-}" = "1" ] && [ -f "$repo_root/scripts/pre-push-gate.mjs" ]; then
echo "pre-push-gate: HP_PREPUSH_GATE=1, прогоняю локальный набор" >&2
if ! node "$repo_root/scripts/pre-push-gate.mjs" >&2; then
status=1
fi
fi
while read -r local_ref local_sha remote_ref remote_sha; do
# Deleting a remote branch pushes nothing to examine.
if [ "$local_sha" = "$zero" ]; then
+1 -2
View File
@@ -1,4 +1,4 @@
name: Анонс релиза
name: Announce release
# Telegram notifications for t.me/ha_houseplan (owner request, 2026-08-07).
# Stable releases are announced; prereleases are deliberately silent.
# workflow_dispatch exists purely as a connectivity test button and therefore
@@ -36,7 +36,6 @@ permissions:
contents: read
jobs:
telegram:
name: Оповещение в Telegram (только стабильные)
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.prerelease == false) || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
runs-on: ubuntu-latest
steps:
+6 -44
View File
@@ -9,7 +9,7 @@
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
# попадают в репозиторий через явное решение, а не через бота.
name: Скриншоты документации
name: Docs screenshots
on:
workflow_dispatch:
@@ -24,7 +24,6 @@ permissions:
jobs:
capture:
name: Съёмка и сверка скриншот-индекса
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -48,29 +47,6 @@ jobs:
run: npx playwright install chromium
- name: Build the bundle the screenshots must come from
run: npm run build
# oxipng без потерь снимает с набора ~19% (замер в #345 на pyoxipng 9.1.1;
# у 10.2.0 пресеты уровней перебалансированы, точная доля может отличаться,
# но кадры остаются пиксельно идентичными в любой версии — это перепаковка).
#
# Пин версии и контрольной суммы намеренно, а не `apt-get install oxipng`:
# пакет из образа раннера может пропасть или переехать, а падение шага
# съёмки стоит целого цикла приёмки. Тот же урок, что с azure-зеркалом
# Playwright (#175, #206).
- name: Установить oxipng
env:
OXIPNG_VERSION: 10.2.0
OXIPNG_SHA256: b33f84c73d42cb592bea5d84c431030b1e97784817693380dfcec7d9575f871e
run: |
set -euo pipefail
asset="oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
curl -fsSL -o "$asset" \
"https://github.com/oxipng/oxipng/releases/download/v${OXIPNG_VERSION}/${asset}"
echo "${OXIPNG_SHA256} ${asset}" | sha256sum -c -
mkdir -p "$HOME/.local/bin"
tar -xzf "$asset" --strip-components=1 -C "$HOME/.local/bin" \
"oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/oxipng" --version
- name: Capture
run: node demo/docs/capture.mjs
# Вердикт до всякой приёмки. Само число изменившихся файлов ничего не
@@ -81,30 +57,16 @@ jobs:
# разницу рендеринга.
- name: Вердикт
run: |
# Поле манифеста «до» — из закоммиченного состояния, «после» — из
# свежего. Читается одинаково для браузера и для упаковщика: оба
# переписывают все кадры сразу, и различить их причины обязан вердикт,
# а не человек по памяти (#345).
field() {
node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).$1||'')}catch{console.log('')}})"
}
git status --porcelain docs/images
changed=$(git diff --name-only docs/images | grep -c png || true)
before=$(git show HEAD:docs/images/screenshots.json | field chromium)
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)")
packer_before=$(git show HEAD:docs/images/screenshots.json | field oxipng)
packer_after=$(node -e "console.log(require('./docs/images/screenshots.json').oxipng || '')")
echo "--- изменившихся PNG: $changed"
echo "--- Chromium: было «${before:-не записан}», стало «$after»"
echo "--- oxipng: было «${packer_before:-не записан}», стало «${packer_after:-нет}»"
if [ "$packer_before" != "$packer_after" ] && [ "$changed" -gt 0 ]; then
echo "ВЕРДИКТ: изменился упаковщик, поэтому переписаны все кадры сразу."
echo "Это перепаковка без потерь: пиксели те же, размер меньше на ~19%."
echo "Ожидаемо один раз — при включении oxipng либо при смене его версии."
echo "Проверить можно сравнением декодированных кадров, а не байтов."
elif [ "$before" = "$after" ] && [ "$changed" -gt 0 ]; then
echo "ВЕРДИКТ: тот же браузер и тот же упаковщик, а картинки изменились —"
echo "изменился продукт. Смотрите на кадры: если изменение ожидаемое, принимайте."
if [ "$before" = "$after" ] && [ "$changed" -gt 0 ]; then
echo "ВЕРДИКТ: тот же браузер, а картинки изменились — изменился продукт."
echo "Смотрите на кадры: если изменение ожидаемое, принимайте."
elif [ "$before" != "$after" ]; then
echo "ВЕРДИКТ: браузер другой, поэтому переписаны все кадры сразу."
echo "Это ожидаемо один раз — при переходе на канонический прогон."
+9 -23
View File
@@ -1,21 +1,15 @@
name: Мутационный гейт
name: Mutation gate
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
# защитой, и это хуже его отсутствия.
#
# Прогон дорогой, поэтому он не входит в Validate и не идёт на каждый push.
# Его место — перед стабильным релизом (PROCESS.md §8) и раз в неделю по
# расписанию, чтобы дрейф тестов не копился до релиза. Дешёвая половина —
# «якоря патчей живы, guard-файлы существуют» — идёт с обычными юнитами:
# test/mutation-gate.test.mjs.
#
# #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle),
# компиляция тестов в worktree стартует с тёплого test-build (инкрементальный
# tsc), а реестр режется на четыре чересполосных шарда — полный прогон
# укладывается в десятки минут вместо часов. Локальный дифф-режим:
# node scripts/mutation-gate.mjs --changed origin/dev..HEAD
# Прогон дорогой: пересборка бандла на каждого мутанта. Поэтому он не входит в
# Validate и не идёт на каждый push. Его место — перед стабильным релизом
# (PROCESS.md §8) и раз в неделю по расписанию, чтобы дрейф тестов не копился
# до релиза. Дешёвая половина — «якоря патчей живы, guard-файлы существуют» —
# идёт с обычными юнитами: test/mutation-gate.test.mjs.
on:
workflow_dispatch:
@@ -37,14 +31,9 @@ concurrency:
jobs:
mutants:
name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 4)"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
# Шард ~64 мутантов × свой guard; бандл собирают только браузерные гварды.
# Час — потолок против зависшего Chromium.
# Все мутанты × (сборка + свой guard) — это десятки минут, и это нормально:
# гейт предрелизный. Час — потолок против зависшего Chromium.
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
@@ -80,8 +69,5 @@ jobs:
- name: Реестр применим к текущему коду
run: node scripts/mutation-gate.mjs --check
- name: Тёплый test-build для инкрементальной компиляции мутантов
run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
- name: Каждый тест ловит свою поломку
run: node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4
run: node scripts/mutation-gate.mjs
+29 -80
View File
@@ -1,4 +1,4 @@
name: Полные бенчмарки производительности
name: Full Performance
on:
# Every main promotion is a stable-release candidate and must have an
@@ -24,21 +24,10 @@ concurrency:
jobs:
performance:
name: Бенчмарки рендера и геометрии
# Every profile keeps base and candidate sequential on one hosted runner.
# Independent profile pairs run in parallel: cross-profile timing is never
# compared, while serialising all five pairs cannot fit the job timeout.
# Base and candidate stay sequential on one hosted runner. Splitting them
# across runners would turn machine variance into a false regression.
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
profile:
- large-house
- isometric
- plan-snap
- blend
- overlay
steps:
- name: Check out candidate
uses: actions/checkout@v7
@@ -160,78 +149,38 @@ jobs:
npm --prefix candidate run build
(cd candidate && node scripts/bundle-sync.mjs)
npm --prefix baseline run build
if [ -f baseline/scripts/bundle-sync.mjs ]; then
(cd baseline && node scripts/bundle-sync.mjs)
else
echo "::notice::The comparison base predates bundle-sync.mjs; materializing its freshly built bundle with the legacy copy path."
mkdir -p baseline/custom_components/houseplan/frontend baseline/demo/srv/assets
cp baseline/dist/houseplan-card.js baseline/custom_components/houseplan/frontend/houseplan-card.js
cp baseline/dist/houseplan-card.js baseline/demo/srv/assets/houseplan-card.js
(cd baseline && node scripts/bundle-sync.mjs)
- name: Capture base and candidate profiles
working-directory: candidate
run: |
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
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
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/overlay-candidate.json
if ! grep -q "glow_enabled" ../baseline/src/logic.ts; then
echo "Base predates independent Glow; bootstrap relative overlay baseline, keep absolute gate"
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
fi
- name: Capture base and candidate profile
- name: Enforce relative and absolute performance budgets
working-directory: candidate
env:
PROFILE: ${{ matrix.profile }}
run: |
mkdir -p ../artifacts/performance
case "$PROFILE" in
large-house)
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
;;
isometric)
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
;;
plan-snap)
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
;;
blend)
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
;;
overlay)
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/overlay-candidate.json
if ! grep -q "glow_enabled" ../baseline/src/logic.ts; then
echo "Base predates independent Glow; bootstrap relative overlay baseline, keep absolute gate"
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
fi
;;
*)
echo "::error::Unknown performance profile: $PROFILE"
exit 1
;;
esac
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: Enforce relative and absolute performance budget
working-directory: candidate
env:
PROFILE: ${{ matrix.profile }}
run: |
case "$PROFILE" in
large-house)
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
;;
isometric)
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
;;
plan-snap)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
;;
blend)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
;;
overlay)
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
;;
esac
- name: Upload full performance report
- name: Upload full performance reports
if: always()
uses: actions/upload-artifact@v7
with:
name: full-performance-${{ matrix.profile }}
name: full-performance
path: artifacts/performance
+5 -82
View File
@@ -1,4 +1,4 @@
name: Ревью-конвейер
name: Process
# Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это
# сообщение: она порождает событие, событие запускает следующий шаг.
@@ -35,7 +35,6 @@ permissions:
jobs:
guard:
name: "Страж: ребейз на dev и предпосылки ревью"
runs-on: ubuntu-latest
outputs:
stage: ${{ steps.decide.outputs.stage }}
@@ -163,7 +162,6 @@ jobs:
echo "limit=$limit" >> "$GITHUB_OUTPUT"
review:
name: "Ревью (Claude): вердикт в issue"
needs: guard
if: needs.guard.outputs.stage != ''
runs-on: ubuntu-latest
@@ -289,16 +287,6 @@ jobs:
echo "note=Ветка приведена к dev конвейером до ревью: поверх легло $behind коммит(ов) dev, $short_before -> $short_after. После ребейза это другой код (§7.2) — разбор полный, а не по дельте." >> "$GITHUB_OUTPUT"
echo "ветка $BRANCH приведена к dev: $short_before -> $short_after"
# Материал ревью — конкретный SHA (#312). Вердикт применим только к
# нему: если во время ревью в ветку прилетит коммит, шаг слияния обязан
# это заметить и отказаться, а не молча увезти в dev непроверенный код.
- name: Зафиксировать SHA материала ревью
id: material
if: steps.rebase.outputs.conflict != 'true'
run: |
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "материал ревью: $(git rev-parse --short HEAD)"
# Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона
# метка меняется всегда» при этом держится: возврат в S6-in-progress —
# тоже смена метки, и автор не ждёт впустую.
@@ -327,41 +315,6 @@ jobs:
--add-label S6-in-progress --remove-label S7-code-review
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
# Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя
# Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет
# ревью тратится на повторение CI вместо чтения кода.
#
# Доказательство здесь такое же строгое, как у reuse-маркеров (#208): не
# «недавно было зелено», а «completed success ровно на этом SHA». После
# ребейза SHA другой, прогона для него нет — и ревьюер честно гоняет сам.
- name: Зелёные гейты на этом SHA
id: validated
if: steps.rebase.outputs.conflict != 'true'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
run: |
sha=$(git rev-parse HEAD)
short=$(git rev-parse --short HEAD)
row=$(gh run list --repo "${{ github.repository }}" --workflow validate.yml \
--commit "$sha" --limit 5 \
--json status,conclusion,url \
--jq '[.[] | select(.status=="completed" and .conclusion=="success")][0] // empty')
{
echo 'note<<EOF_NOTE'
if [ -n "$row" ]; then
url=$(printf '%s' "$row" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).url||""))')
echo "**Дешёвые гейты на этом SHA уже подтверждены** (#343). Validate на \`$short\` завершился success: $url"
echo ""
echo "Значит \`npx tsc --noEmit\`, \`npm test\` и \`npm run build\` со сверкой копий бандла перегонять не нужно — сошлись на этом прогоне, назвав его ссылкой. Бюджет раунда тратится на чтение кода."
echo ""
echo "Что Validate НЕ покрывает и остаётся за тобой: смоки, выбранные по диффу; golden, если diff трогает рендер; инварианты модели на конкретной конфигурации; и любой гейт, который требуют AC задачи."
else
echo "**Зелёного Validate на этом SHA (\`$short\`) нет** — прогон не найден, не завершён либо не success. Дешёвые гейты прогоняешь сам и называешь результат."
fi
echo 'EOF_NOTE'
} >> "$GITHUB_OUTPUT"
if [ -n "$row" ]; then echo "Validate на $short: зелёный"; else echo "Validate на $short: зелёного нет"; fi
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
- name: Установить зависимости
@@ -488,10 +441,8 @@ jobs:
правке — не тщательность, а потеря времени: полные наборы это
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
${{ steps.validated.outputs.note }}
Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые,
и в повторном раунде тоже: код изменился, а стоят они минуты:
Всегда, они дешёвые, и в повторном раунде тоже: код изменился,
а стоят они минуты:
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
копий бандла. Плюс `node scripts/check-docs.mjs`, если diff трогает
`src/**`: отпечаток скриншотов документации считается по всему
@@ -750,42 +701,14 @@ jobs:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
run: |
if [ -z "$BRANCH" ]; then
echo "::error::ветки задачи нет — сливать нечего"
echo "merged=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git fetch -q origin dev "$BRANCH"
# #312: сливается только проверенный код. Допустимые вершины ветки:
# сам SHA материала либо он же плюс ровно один коммит публикации
# документа ревью (дифф только docs/reviews/). Любой другой коммит —
# ветка уехала после ревью, вердикт к ней не применим: возврат в
# S6-in-progress через merged=false, как при конфликте.
actual=$(git rev-parse "origin/$BRANCH")
reviewed="$MATERIAL_SHA"
fresh=false
if [ "$actual" = "$reviewed" ]; then
fresh=true
elif [ "$(git rev-parse "$actual^" 2>/dev/null)" = "$reviewed" ] \
&& [ -z "$(git diff --name-only "$reviewed" "$actual" -- . ':!docs/reviews')" ]; then
fresh=true
fi
if [ "$fresh" != true ]; then
echo "merged=false" >> "$GITHUB_OUTPUT"
echo "::warning::ветка $BRANCH уехала после проверенного SHA $reviewed (сейчас $actual) — слияние отменено (#312)"
cat > /tmp/stale-verdict.md <<EOF
**Слияние отменено: ветка изменилась после проверенного материала (#312).**
Ревью выполнялось на \\`$(git rev-parse --short "$reviewed")\\`, а вершина ветки сейчас \\`$(git rev-parse --short "$actual")\\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.
Задача переведена в \\`S6-in-progress\\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \\`S7-code-review\\` — новый заход ревью проверит актуальный код.
EOF
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale-verdict.md
exit 0
fi
git checkout -q -B merge-into-dev "$actual"
git fetch -q origin dev
git checkout -q -B merge-into-dev "origin/$BRANCH"
if ! git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
rebase origin/dev; then
+10 -10
View File
@@ -1,4 +1,4 @@
name: Публикация пре-релиза (ручная)
name: Publish prerelease
run-name: Publish ${{ inputs.tag }}
on:
@@ -19,7 +19,6 @@ concurrency:
jobs:
gate:
name: "Гейт: зелёная Проверка и релизный контракт"
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.candidate.outputs.sha }}
@@ -62,7 +61,6 @@ jobs:
run: node scripts/release-gate.mjs "$SHA"
publish:
name: Публикация тега и релиза
needs: gate
runs-on: ubuntu-latest
outputs:
@@ -82,13 +80,17 @@ jobs:
set -euo pipefail
npm ci
npm run build
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
npm run bundle:budget
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
VERSION=${TAG#v}
grep -RFq "$VERSION" dist
grep -Fq "$VERSION" dist/houseplan-card.js
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
node scripts/verify-houseplan-zip.mjs houseplan.zip \
custom_components/houseplan/frontend "$VERSION"
unzip -l houseplan.zip | grep -q "manifest.json"
ZIP_VERSION=$(unzip -p houseplan.zip manifest.json | node -e \
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).version))")
test "$ZIP_VERSION" = "$VERSION" || {
echo "::error::houseplan.zip manifest version $ZIP_VERSION != $VERSION"
exit 1
}
test -s dist/houseplan-card.js
test -s houseplan.zip
- name: Create or verify the annotated tag
@@ -196,7 +198,6 @@ jobs:
# A manual step after a successful release is the worst kind: by the time it is
# due, the work already looks finished, which is exactly why it gets forgotten.
close-merged:
name: Закрытие вошедших issue
needs: [gate, publish]
if: ${{ needs.publish.outputs.newly_published == 'true' }}
runs-on: ubuntu-latest
@@ -244,7 +245,6 @@ jobs:
}
announce:
name: Комментарий о публикации
needs: [gate, publish]
if: ${{ needs.publish.outputs.newly_published == 'true' }}
uses: ./.github/workflows/announce.yml
+2 -3
View File
@@ -1,4 +1,4 @@
name: HACS-zip к релизу
name: Attach HACS zip to release
# hacs.json declares zip_release + filename=houseplan.zip, so every release
# (prereleases included) must carry the asset — HACS installs from it and
# GitHub's public download counter becomes a free per-version install metric
@@ -19,7 +19,6 @@ permissions:
contents: write
jobs:
zip:
name: Собрать houseplan.zip и приложить к релизу
runs-on: ubuntu-latest
steps:
- name: Resolve tag
@@ -34,7 +33,7 @@ jobs:
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
run: cd custom_components/houseplan && zip -qr ../../houseplan.zip .
- name: Sanity check
run: node scripts/verify-houseplan-zip.mjs houseplan.zip custom_components/houseplan/frontend
run: unzip -l houseplan.zip | grep -q "manifest.json"
- name: Upload asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3 -7
View File
@@ -1,4 +1,4 @@
name: "Релиз: ассеты после зелёной проверки"
name: Release
on:
release:
types: [published]
@@ -14,7 +14,6 @@ jobs:
# Needs a push with a token that has the `workflow` scope (the ordinary
# Personal Access Token used for `git push` refuses workflow file updates).
gate:
name: "Гейт: зелёная Проверка точного SHA тега"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -43,9 +42,8 @@ jobs:
run: |
set -euo pipefail
SHA=$(git rev-parse HEAD)
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Полные бенчмарки производительности"
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Full Performance"
build:
name: Сборка бандла и загрузка ассетов
needs: gate
runs-on: ubuntu-latest
steps:
@@ -67,14 +65,12 @@ jobs:
with:
name: continuity-screencast
path: artifacts/continuity-screencast
- name: Verify the complete committed frontend tree
run: node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
- run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
- name: Attach card to release
uses: softprops/action-gh-release@v3
with:
files: dist/houseplan-card.js
hacs-discovery:
name: HACS-видимость пре-релиза (порядок бет)
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
# sorting SemVer. A valid asset can therefore be invisible to beta users
# (beta.10 appeared after beta.9). Keep the release asset, but
+46 -169
View File
@@ -1,4 +1,4 @@
name: Проверка (CI)
name: Validate
on:
push:
@@ -21,42 +21,25 @@ concurrency:
cancel-in-progress: true
jobs:
# Четыре секундных скрипта жили четырьмя job, каждая со своим checkout и
# setup-node ради нескольких секунд работы (#336). Здесь они шаги одной job.
#
# Независимость при этом сохранена намеренно: у каждого шага
# `continue-on-error`, поэтому упавший провенанс не прячет результат
# процессного гейта, а вердикт в конце падает, если упал хоть один. Прежняя
# запись «job независимый: краснеет сам и не роняет остальные» продолжает
# действовать — на уровне шагов, с той же гранулярностью в логе.
preflight:
name: "Предполётные проверки: документация, провенанс, процесс"
docs:
runs-on: ubuntu-latest
steps:
# `blob:none` при полной истории (#345): этой job нужны сообщения
# коммитов, трейлеры и ИМЕНА изменённых файлов, а не содержимое старых
# ревизий. Полный клон стоит 215 МБ против 26 МБ, и 32% этого веса —
# скриншоты документации, переснятые 196 раз.
#
# Коммиты и деревья скачиваются целиком, поэтому диапазоны и `merge-base`
# работают как раньше. Единственная догрузка по требованию здесь —
# `git show origin/main:.github/workflows/process.yml`: один блоб.
- uses: actions/checkout@v7
with: { fetch-depth: 0, filter: 'blob:none' }
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: "Документация: гайды, ченджлоги, скриншот-индекс"
id: docs
continue-on-error: true
- name: Validate public documentation
run: node scripts/check-docs.mjs --external
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
- name: "Процесс: process.yml идентичен в main и dev"
id: workflow_sync
continue-on-error: true
# Конвейер читает `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) \
@@ -69,9 +52,14 @@ jobs:
exit 1
fi
- name: "Провенанс коммитов: трейлеры и эталоны"
id: provenance
continue-on-error: true
provenance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Validate commit trailers and hook mode
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
@@ -82,14 +70,19 @@ jobs:
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). Хуки ловят нарушение на
# машине автора, но их можно обойти `--no-verify`, а коммиты идут прямо в
# dev без PR — GitHub на своей стороне не блокирует ничего. Это последнее
# место, где нарушение правила №1 ловится машиной. Откат — удалить шаг,
# скрипт остаётся рабочим.
- name: "Процессный гейт: диапазон, трейлеры, статусы issue"
id: process_gate
continue-on-error: true
# Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на
# машине автора, но их можно обойти `--no-verify`, а коммиты идут прямо в dev
# без PR — GitHub на своей стороне не блокирует ничего. Это последнее место,
# где нарушение правила №1 ловится машиной. Job независимый: краснеет сам и
# не роняет остальные, откат — удалить его отсюда, скрипт остаётся рабочим.
process-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Process gate
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
@@ -103,33 +96,6 @@ jobs:
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
node scripts/process-gate.mjs --github-range --issues
# Вердикт обязан быть громким и перечислять всё упавшее сразу: иначе
# слияние job стоило бы автору лишнего круга «починил одно — узнал о
# втором».
- name: Вердикт предполётных проверок
env:
DOCS: ${{ steps.docs.outcome }}
WORKFLOW_SYNC: ${{ steps.workflow_sync.outcome }}
PROVENANCE: ${{ steps.provenance.outcome }}
PROCESS_GATE: ${{ steps.process_gate.outcome }}
run: |
fail=0
check() {
if [ "$2" = "success" ]; then
echo "ok $1"
else
echo "FAIL $1 ($2)"
echo "- **$1**: $2" >> "$GITHUB_STEP_SUMMARY"
fail=1
fi
}
echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY"
check "документация" "$DOCS"
check "process.yml в main и dev" "$WORKFLOW_SYNC"
check "провенанс коммитов" "$PROVENANCE"
check "процессный гейт" "$PROCESS_GATE"
exit $fail
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
# относящееся к ним. НА DEV ФИЛЬТРОВ НЕТ: гейт беты принимает «зелёный Validate
# на точном SHA», и если объём прогона зависит от diff, «зелёный» перестаёт
@@ -138,17 +104,14 @@ jobs:
# экономят на ветках задач, где Validate — ранний сигнал: настоящую приёмку
# там делает код-ревью, которое гоняет гейты само (#127).
changes:
name: Классификация изменённых файлов
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.classify.outputs.frontend }}
backend: ${{ steps.classify.outputs.backend }}
integration: ${{ steps.classify.outputs.integration }}
steps:
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
# блобы истории этой job не нужны ни на одном шаге (#345).
- uses: actions/checkout@v7
with: { fetch-depth: 0, filter: 'blob:none' }
with: { fetch-depth: 0 }
- id: classify
env:
EVENT_NAME: ${{ github.event_name }}
@@ -165,28 +128,10 @@ jobs:
zero=$(printf '%040d' 0)
base="$BEFORE_SHA"
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
# #347: непустой before, которого больше не существует, — это
# force-push (ребейз/аменд переписал историю ветки). Диапазон в
# этом случае НЕ угадывается: merge-base после переписанной истории
# уже выдавал «изменились только два doc-файла» при реальном диффе
# в custom_components/**, и тяжёлые job тихо скипались, а общий
# статус рана оставался success — ровно класс тихого пропуска
# #171/#207. Force-push редок, полный прогон дешевле ложного
# «зелёного».
if [ -n "$base" ] && [ "$base" != "$zero" ] \
&& ! git cat-file -e "$base" 2>/dev/null; then
echo "force-push: before=$base переписан — полный прогон без классификации"
{
echo "### Классификация изменённых файлов"
echo "\`before\` (=$base) переписан force-push'ем: диапазон недоказуем,"
echo "все тяжёлые job запускаются без фильтра путей (#347)."
} >> "$GITHUB_STEP_SUMMARY"
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
exit 0
fi
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
# иначе классифицировалась бы вся история.
if [ -z "$base" ] || [ "$base" = "$zero" ]; then
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
@@ -213,7 +158,6 @@ jobs:
# CARD_VERSION и package.json входят в фингерпринт, поэтому ключи кандидата
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
reuse:
name: "Переиспользование: это дерево уже проверено"
runs-on: ubuntu-latest
outputs:
smoke: ${{ steps.probe.outputs.smoke }}
@@ -293,7 +237,6 @@ jobs:
waive backend "$BACKEND"
hacs:
name: "HACS: валидация репозитория"
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
@@ -305,7 +248,6 @@ jobs:
category: integration
hassfest:
name: "Hassfest: манифест интеграции"
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
@@ -315,7 +257,6 @@ jobs:
uses: home-assistant/actions/hassfest@master
frontend:
name: "Фронтенд: типы, юниты, мутанты, синхрон бандла"
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
@@ -332,38 +273,11 @@ jobs:
run: npm test
- name: Build
run: npm run build
# Entry, manifest and every content-hashed chunk are one artifact (#337).
- name: Card bundle trees in sync
run: |
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
npm run bundle:budget
# Бандл собирается один раз и уезжает артефактом (#336). Прежде каждая из
# пяти браузерных job собирала его заново: `bundle:sync` — это `tsc
# --noEmit` плюс rollup, около минуты на job.
#
# Подмену артефакта ловить не нужно отдельной проверкой: `assertFreshDemoBundle`
# сверяет вшитый в бандл отпечаток с `sourceFingerprint` выкачанного
# дерева, и каждая браузерная job делает это перед первым кадром.
- name: Выложить бандл для браузерных job
uses: actions/upload-artifact@v7
with:
name: card-bundle
path: dist/
retention-days: 1
if-no-files-found: error
# Несколько браузерных контрактов импортируют чистые функции из
# скомпилированного тестового дерева. Оно создаётся `npm test`, но не
# входит в production-бандл и потому едет отдельным артефактом (#336).
- name: Выложить тестовые модули для смоков
uses: actions/upload-artifact@v7
with:
name: card-test-build
path: test-build/
retention-days: 1
if-no-files-found: error
# Копия стенда больше не коммитится (#255): сверяются две обязательные.
- name: Card bundle snapshots in sync
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
smoke:
name: Смоки в браузере (шард ${{ matrix.shard }} из 3)
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
needs: [frontend, reuse]
if: needs.reuse.outputs.smoke != 'true'
@@ -396,21 +310,8 @@ jobs:
- name: Install pinned Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
# сверяет вшитый отпечаток с исходниками этого дерева.
- name: Забрать собранный бандл
uses: actions/download-artifact@v7
with:
name: card-bundle
path: dist
- name: Забрать тестовые модули
uses: actions/download-artifact@v7
with:
name: card-test-build
path: test-build
- name: Разложить бандл по копиям
run: node scripts/bundle-sync.mjs
- name: Build a fresh bundle for the smokes
run: npm run bundle:sync
- name: Smoke suite (шард ${{ matrix.shard }} из 3)
env:
SHARD: ${{ matrix.shard }}
@@ -449,7 +350,6 @@ jobs:
# Маркер переиспользования пишется ОДИН раз и только когда прошли все шарды:
# частично прогнанная матрица не имеет права выглядеть как выполненная работа.
smoke_done:
name: "Смоки: все шарды зелёные"
needs: [smoke, reuse]
if: needs.reuse.outputs.smoke != 'true'
runs-on: ubuntu-latest
@@ -470,7 +370,6 @@ jobs:
key: reuse-smoke-${{ needs.reuse.outputs.smoke_key }}
golden:
name: Golden-кадры против принятых эталонов
# Deterministic visual correctness stays in every prerelease gate: it is
# inexpensive and catches a different class of regressions than timings.
needs: [frontend, reuse]
@@ -497,16 +396,8 @@ jobs:
- name: Install pinned Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
# сверяет вшитый отпечаток с исходниками этого дерева.
- name: Забрать собранный бандл
uses: actions/download-artifact@v7
with:
name: card-bundle
path: dist
- name: Разложить бандл по копиям
run: node scripts/bundle-sync.mjs
- name: Build the exact source under review
run: npm run bundle:sync
- name: Capture or verify golden matrix
id: golden
run: |
@@ -541,7 +432,6 @@ jobs:
key: reuse-golden-${{ needs.reuse.outputs.golden_key }}
performance_smoke:
name: "Перф-смок: бюджет времени кадра"
# Candidate-only catastrophic-regression guard for ordinary pushes and
# prereleases. The expensive same-runner comparison lives in performance.yml.
needs: [frontend, reuse]
@@ -572,26 +462,14 @@ jobs:
- name: Install pinned Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
# сверяет вшитый отпечаток с исходниками этого дерева.
- name: Забрать собранный бандл
uses: actions/download-artifact@v7
with:
name: card-bundle
path: dist
- name: Разложить бандл по копиям
run: node scripts/bundle-sync.mjs
- name: Build the exact candidate source
run: npm run bundle:sync
- 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
- name: Enforce absolute smoke ceilings
run: |
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
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
- name: Бюджеты ограничений стыков (#330)
run: npm run benchmark:junction-limits
- name: Upload performance smoke report
if: always()
uses: actions/upload-artifact@v7
@@ -616,7 +494,6 @@ jobs:
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
backend:
name: "Бэкенд: pytest в Home Assistant"
needs: [changes, reuse]
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
runs-on: ubuntu-latest
-2
View File
@@ -12,5 +12,3 @@ artifacts/
# она только росла — 364 версии по 1.16 МБ за семь недель (#255). Обязательных
# копий две: `dist/` (артефакт сборки) и `custom_components/` (её ставит HACS).
demo/srv/assets/houseplan-card.js
demo/srv/assets/houseplan-assets.json
demo/srv/assets/houseplan-assets/
+10 -19
View File
@@ -2,8 +2,7 @@
House Plan is one HACS package with two parts plus a demo harness:
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to
the entry, manifest and hashed chunks under `dist/`.
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to `dist/houseplan-card.js`.
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
@@ -41,16 +40,10 @@ task records: problem, scope, acceptance criteria and discussion.
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.
**The light track is the default, not a shortcut** (owner's decision 2026-08-27,
issue #338). `small` — the spec lives in the issue body and its review is a
comment. Analysis names the `small` criterion the task *fails* when it takes the
full track; "ordinary track" without a named criterion is not a justification.
The threshold itself did not move — only which side carries the proof. The full
track stays what it was for geometry, config migrations and public contracts,
where a criterion is broken plainly and saying which one is easy.
`trivial` — the short track: no spec stage at all, `S2-analysis` straight to
`S5-ready`, with the AC written into the issue body first. `trivial` requires a bug confined to one surface with no new UX
Two shortcuts exist for small work. `small` — the light track: the spec lives in
the issue body and its review is a comment. `trivial` — the short track: no spec
stage at all, `S2-analysis` straight to `S5-ready`, with the AC written into the
issue body first. `trivial` requires a bug confined to one surface with no new UX
contract, no migration, no i18n, no perf or touch impact, at most three checkable
AC, **and expected behaviour already on record** — nothing left to decide. Code
review is never skipped on either track; it is what stands in for testing.
@@ -94,7 +87,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/**` is no longer committed (#255): build the complete tree with `npm run bundle:sync` |
| **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` |
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`,
@@ -281,9 +274,7 @@ the owner splits the task, rejects it, or arbitrates.
On the light track (`small`: complexity ≤3, one surface, no config migration, no
new UX contract, no perf or touch impact — all at once) the spec lives in the issue
body and the spec review is a comment. Code review is never skipped. This track is
the default: taking the full one means naming the criterion above that the task
does not meet.
body and the spec review is a comment. Code review is never skipped.
## Specs
@@ -326,12 +317,12 @@ npm run inventory # the only correct way to get test counts
Never copy test counts into documents by hand; they go stale in days.
After building, keep the complete manifest-driven bundle trees in sync — CI
verifies every listed file byte-for-byte:
After building, keep all three bundle snapshots in sync — CI compares them
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)
npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
```
During the implementation cycle the fast gates always run. Since 2026-08-14 the
+3 -59
View File
@@ -9,44 +9,6 @@ User-visible changes go into **both** changelogs in the same commit:
`docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian). Entries
older than v1.42.0 exist only in the English file — no need to backfill them.
## Translations
A shipped UI language has three matching parts:
1. `src/i18n/<code>.json` for the card;
2. `custom_components/houseplan/translations/<code>.json` for the Home Assistant
integration;
3. one registry entry (code, native label and eager dictionary or lazy loader)
in `src/i18n/registry.ts`.
Use the canonical Home Assistant/BCP 47 language tag as `<code>` (for example,
`fr` or `pt-BR`) and use that exact spelling for both JSON filenames. Lookup is
case-insensitive and also accepts `_` from legacy locale sources.
The registry drives language resolution, the visual-editor selector and parity
tests. The tests reject missing or extra locale files; frontend dictionaries
also fail on mismatched keys, empty values and changed placeholders.
Placeholders such as `{name}` and `{n}` are a contract: do not translate, add
or remove them.
English and Russian are synchronous fallback/legacy locales. German is the
reference lazy third-locale implementation: its module carries the same build
fingerprint as the entry bundle, has one content-hashed retry URL and is listed
under `lazyLocaleFiles` in `houseplan-assets.json`. New sizeable locales should
follow that path unless a measured initial-bundle budget explicitly justifies
an eager import. Extend the runtime, manifest, file/key/placeholder parity and
regional-locale tests together; never bypass the registry by importing a locale
directly in a component.
The current `subst()` helper does not implement plural rules. Phrase strings so
their grammar does not depend on the numeric value (for example, use a neutral
label followed by `{n}` rather than an English singular/plural pair).
Adding a UI locale does not automatically create another full documentation
set; maintain the existing English and Russian documentation according to the
project's normal rules. Right-to-left layout is a separate product project,
because the plan canvas and editors cannot be mirrored by translations alone.
## Where to ask
Not sure whether something is a bug, or just want to discuss an idea before
@@ -66,7 +28,7 @@ issue, but they do not replace it or maintain a separate checklist.
## Five-minute setup
```bash
git clone --filter=blob:none https://github.com/Matysh/houseplan-card && cd houseplan-card
git clone https://github.com/Matysh/houseplan-card && cd houseplan-card
npm ci # frontend toolchain
npm run typecheck # tsc --noEmit (strict)
npm test # node:test — pure logic, i18n parity, tap-action security
@@ -75,24 +37,6 @@ pip install pytest voluptuous && python -m pytest tests_backend -q # pure back
npm install # also installs .githooks through the prepare script
```
### Why `--filter=blob:none` (#345)
A full clone is **215 MB of `.git`**; a blobless one is **26 MB** — measured, not
estimated. Both carry all 1611 commits and all 182 tags, so ranges, `merge-base`
and `git diff` across history work identically; `git diff origin/dev~3..origin/dev`
in a blobless clone takes about a second and grows `.git` by one megabyte.
The difference is that historical *file contents* are fetched only if something
actually asks for them. That matters here because 32% of the pack is documentation
screenshots — ten PNGs re-captured 196 times — and another sizeable share is the
committed bundle, one 1.16 MB file per product change. Almost nobody ever reads an
old revision of either.
Drop the flag if you work offline with history, or need `git log -p` over the whole
tree repeatedly. Do **not** replace it with `--depth=1`: a shallow clone is about
the same size but has no `merge-base`, so the process gate, `smoke-select` and
every `origin/dev..HEAD` range stop working.
The HA-harness backend tests (`tests_backend/test_ha_*.py`) need Python ≥3.13 and
`pytest-homeassistant-custom-component home-assistant-frontend`; CI runs them on
every push — locally they are skipped when `homeassistant` is not importable.
@@ -101,8 +45,8 @@ every push — locally they are skipped when `homeassistant` is not importable.
- **Docs in the same commit**: CHANGELOG entry for user-visible changes;
`docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new gotchas.
- Every UI string goes through `src/i18n/<lang>.json`; follow the
[Translations](#translations) flow for registry and backend parity.
- Every UI string goes through `src/i18n/<lang>.json` (tests enforce en/ru key parity).
Adding a language = adding one JSON file + registering it in `src/i18n.ts`.
- The built card must be committed in sync: `cp dist/houseplan-card.js
custom_components/houseplan/frontend/` (CI compares them byte-for-byte).
- Tap actions have a security model (locks/alarms never toggle from the plan) —
+4 -31
View File
@@ -101,9 +101,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
5. приоритет **P1/P2/P3**;
6. тип: баг / фича / техдолг;
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
8. трек — **по умолчанию `small`** (§5). Если задача идёт полным треком,
называется критерий §5, который она не проходит: «обычный трек» без
названного критерия обоснованием не является.
8. трек: обычный / `small` / `trivial` по критериям §5 и §5.1.
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
согласие**, несогласие он выражает правкой меток или комментарием, и это не
@@ -196,12 +194,6 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
Medium **вне скоупа** — отдельный issue (#202).
- **Вердикт привязан к SHA (#312).** Все числа и факты отчёта сверяются с
`git rev-parse HEAD` непосредственно перед подведением итогов, а не с SHA,
зафиксированным в начале разбора: во время ревью в ветку может прилететь
fix-up. Серверный стопор — шаг слияния конвейера сверяет вершину ветки с
SHA материала ревью (допустим только собственный doc-коммит публикации
поверх) и при расхождении отменяет слияние с возвратом в `S6-in-progress`.
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
@@ -289,9 +281,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
`issue/NN-slug`, а `User-Visible: yes` требует правок в **обоих** changelog в
том же коммите. После `cherry-pick -x` служебная строка `(cherry picked
from ...)` должна оставаться выше финального блока трейлеров: перед push
проверяем порядок через `git show -s --format=full HEAD`.
том же коммите.
11. **Документация — в том же коммите, что поведение.** Отдельным «допишу потом»
коммитом документация не бывает.
12. **Сгенерированное не коммитится само по себе.** Только релизный промоушен или
@@ -350,26 +340,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
---
## 5. Лёгкий трек (метка `small`) — путь по умолчанию
## 5. Лёгкий трек (метка `small`)
Умолчание изменено решением владельца 2026-08-27, issue #338. Прежде полный трек
был бесплатен, а выбор лёгкого требовал обоснования. Фактическая цена: **2.9
ревью-документа на задачу** в среднем и до шести на одну issue (#329, #316,
#290) — при том что Medium-находки всё равно чинятся в той же задаче, без
отдельного цикла.
**Порог не изменился.** Критерии ниже те же и по-прежнему обязательны все
одновременно. Изменилась сторона доказательства: теперь обосновывается не выбор
лёгкого трека, а отказ от него — в `S2-analysis` называется критерий, который
задача не проходит. Полный трек остаётся тем, чем был, для геометрии, миграций
конфига и публичных контрактов: там критерии нарушаются сами, и назвать
нарушенный несложно.
Инверсия умолчания не отменяет ничего из §5 ниже и ничего из §4: бюджет четырёх
циклов, арбитраж владельца, обязательность ТЗ на полном треке и правило «ревью
до мержа» остаются как были. Меняется только стоимость пути по умолчанию.
**Критерии — все одновременно; нарушенный называется явно:**
**Критерии — все одновременно:**
- сложность и риск ≤ 3;
- одна поверхность (один диалог, один модуль, один эндпоинт);
+7 -10
View File
@@ -1,6 +1,6 @@
# 🏠 House Plan — a live home map for Home Assistant
[![HACS Default](https://img.shields.io/badge/HACS-Default-41BDF5.svg)](https://github.com/hacs/integration)
[![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)
[![Live demo](https://img.shields.io/badge/demo-try_it_live-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
@@ -88,11 +88,10 @@ The [Background editor contract](docs/DECOR-EDITOR.md) and
[![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)
House Plan is in the HACS default catalog — no custom repository needed.
1. In HACS search for **House Plan** and install it.
2. Restart Home Assistant.
3. Open **Settings → Devices & services → Add integration → House Plan**.
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:
@@ -108,10 +107,8 @@ serve that path as a JavaScript module.
### Manual installation
Copy the complete `custom_components/houseplan` release folder to
`config/custom_components`, restart Home Assistant, and add the House Plan
integration. Do not copy only `houseplan-card.js`: the card also uses an
internal manifest and content-hashed modules from the same release.
Copy `custom_components/houseplan` to `config/custom_components`, restart Home
Assistant, and add the House Plan integration.
### Add the card
+7 -11
View File
@@ -1,6 +1,6 @@
# 🏠 House Plan — живой план дома для Home Assistant
[![HACS Default](https://img.shields.io/badge/HACS-Default-41BDF5.svg)](https://github.com/hacs/integration)
[![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)
[![Live demo](https://img.shields.io/badge/демо-попробовать-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
@@ -91,12 +91,10 @@ Assistant — и устройства появятся на плане авто
[![Открыть репозиторий в 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)
House Plan входит в основной каталог HACS — пользовательский репозиторий
добавлять не нужно.
1. Найдите **House Plan** в поиске HACS и установите.
2. Перезапустите Home Assistant.
3. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
1. В HACS откройте **⋮ → Пользовательские репозитории**.
2. Добавьте `https://github.com/Matysh/houseplan-card` с типом **Интеграция**.
3. Установите House Plan и перезапустите Home Assistant.
4. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
добавьте именно URL, который публикует интеграция:
@@ -112,10 +110,8 @@ resources:
### Вручную
Скопируйте всю папку `custom_components/houseplan` из релиза в
`config/custom_components`, перезапустите Home Assistant и добавьте интеграцию
House Plan. Одного `houseplan-card.js` недостаточно: карточке также нужны
внутренний манифест и хешированные модули из того же релиза.
Скопируйте `custom_components/houseplan` в `config/custom_components`,
перезапустите Home Assistant и добавьте интеграцию House Plan.
### Добавление карточки
+1 -4
View File
@@ -39,12 +39,10 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
hass.data.setdefault(DOMAIN, {})
hp_ws.async_register(hass)
from .http_api import HouseplanContentView, HouseplanImportPreviewView, HouseplanUploadView
from .frontend_assets import HouseplanFrontendAssetView
hass.http.register_view(HouseplanUploadView())
hass.http.register_view(HouseplanContentView())
hass.http.register_view(HouseplanImportPreviewView())
hass.http.register_view(HouseplanFrontendAssetView())
return True
@@ -100,8 +98,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
# NOTE (audit B1): plans and marker files are NO LONGER static.
# They are served by HouseplanContentView, which requires auth.
# Only the entry and manifest-gated JS chunks stay public —
# Lovelace modules must be loadable without an auth header.
# Only the card bundle stays public — Lovelace resources must be.
if static_paths:
await hass.http.async_register_static_paths(static_paths)
except ImportError: # very old HA versions
+2 -3
View File
@@ -7,7 +7,6 @@ STORAGE_VIRTUAL_LIGHTS_KEY = f"{DOMAIN}.virtual_lights"
STORAGE_VERSION = 1
STORAGE_MINOR_VERSION = 2
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
FRONTEND_ASSETS_URL = "/houseplan_files/houseplan-assets"
PLANS_URL = "/houseplan_files/plans"
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files"
@@ -47,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.69.0-beta.1"
VERSION = "1.67.0-rc.3"
# 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 = 9
PLAN_MODEL_VERSION = 7
EXPORT_VERSION = 1
MAX_EXPORT_BYTES = 8 * 1024 * 1024
IMPORT_PREVIEW_TTL_S = 10 * 60
@@ -136,10 +136,6 @@ def canonicalize_config_geometry(config: Any) -> Any:
_lattice_point(wall.get("a"))
_lattice_point(wall.get("b"))
for segment in _records(space.get("wall_segments")):
_lattice_point(segment.get("a"))
_lattice_point(segment.get("b"))
for opening in _records(space.get("openings")):
_lattice_fields(opening, ("x", "y"))
_scalar_fields(opening, ("angle", "length"))
@@ -1,113 +0,0 @@
{
"schema": 1,
"fingerprint": "420f1f87e66a879316f23c4dd573991c8cf2bfcf5ef013191e55e4216031f3be",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-BarpA18i.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 256012,
"lazyFiles": [
"houseplan-assets/de-L64iNsLu.js",
"houseplan-assets/editor-B45l6syb.js",
"houseplan-assets/houseplan-editor-runtime-D6vKsLjO.js",
"houseplan-assets/houseplan-onboarding-runtime-BUDnRMK5.js",
"houseplan-assets/space-deletion-05RPnoUh.js"
],
"lazyGzipBytes": 160359,
"lazyEditorFiles": [
"houseplan-assets/editor-B45l6syb.js",
"houseplan-assets/houseplan-editor-runtime-D6vKsLjO.js",
"houseplan-assets/space-deletion-05RPnoUh.js"
],
"lazyEditorGzipBytes": 131837,
"lazyOnboardingFiles": [
"houseplan-assets/houseplan-onboarding-runtime-BUDnRMK5.js",
"houseplan-assets/space-deletion-05RPnoUh.js"
],
"lazyOnboardingGzipBytes": 7198,
"lazyLocaleFiles": [
"houseplan-assets/de-L64iNsLu.js"
],
"lazyLocaleGzipBytes": 21878,
"files": [
{
"path": "houseplan-assets/de-L64iNsLu.js",
"sha256": "472ff7eb5679b16bacedce870ac7a23379c8a03ee3470b660c0c996a76f29b7e",
"rawBytes": 71477,
"gzipBytes": 21878,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-B45l6syb.js",
"sha256": "2df6678b9adc8c3423d6e250b111461f7f3a75f4377a36ed72e5cbee51ce4bf2",
"rawBytes": 3831,
"gzipBytes": 1583,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BarpA18i.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-BarpA18i.js",
"sha256": "b3276d12591e3face8174420b981e9d7626ca9ab02667982ad83943d93194ef1",
"rawBytes": 910025,
"gzipBytes": 255830,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-L64iNsLu.js",
"houseplan-assets/editor-B45l6syb.js",
"houseplan-assets/houseplan-editor-runtime-D6vKsLjO.js",
"houseplan-assets/houseplan-onboarding-runtime-BUDnRMK5.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-D6vKsLjO.js",
"sha256": "8cf01ac6ca2eb3ee4f8f1191ab15eb92884ee25013356ea4e12bfffdeb0b1fd2",
"rawBytes": 504959,
"gzipBytes": 129700,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BarpA18i.js",
"houseplan-assets/space-deletion-05RPnoUh.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-BUDnRMK5.js",
"sha256": "2439e9cdf2c7873b737d46261a8f489c398c5434bc1fecd71a481ca80519443d",
"rawBytes": 26356,
"gzipBytes": 6644,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BarpA18i.js",
"houseplan-assets/space-deletion-05RPnoUh.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/space-deletion-05RPnoUh.js",
"sha256": "2cf11df1b1bcd463ef90c1089b35ae89be7ce4d92370bb6a9ee3e94987a3d65f",
"rawBytes": 911,
"gzipBytes": 554,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "aa89ff5b93e00716f50c19cbb8efd81900451bca43220ddf3a74d9de7bf6a57e",
"rawBytes": 190,
"gzipBytes": 182,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-BarpA18i.js"
],
"dynamicImports": []
}
]
}
File diff suppressed because one or more lines are too long
@@ -1,14 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="420f1f87e66a879316f23c4dd573991c8cf2bfcf5ef013191e55e4216031f3be";import{c as e,l as o,t,d2 as s,A as a,d3 as i,d4 as l,d5 as n,E as r,b as c}from"./houseplan-card-BarpA18i.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>f[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(h.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="420f1f87e66a879316f23c4dd573991c8cf2bfcf5ef013191e55e4216031f3be";export{t as a,e as c,o as i,r as s,s as t};
+7527 -1
View File
File diff suppressed because one or more lines are too long
@@ -1,37 +0,0 @@
"""Pure manifest/path resolver for generated frontend assets."""
from __future__ import annotations
import json
from pathlib import Path
_MANIFEST = "houseplan-assets.json"
_ASSET_DIR = "houseplan-assets"
def resolve_frontend_asset(frontend_root: Path, filename: object) -> Path | None:
"""Resolve one generated JS asset only when the current manifest lists it."""
if not isinstance(filename, str) or not filename or filename != Path(filename).name:
return None
if "/" in filename or "\\" in filename or filename in {".", ".."}:
return None
if not filename.endswith(".js"):
return None
try:
manifest = json.loads((frontend_root / _MANIFEST).read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return None
if not isinstance(manifest, dict) or manifest.get("schema") != 1:
return None
expected = f"{_ASSET_DIR}/{filename}"
listed = {
item.get("path")
for item in manifest.get("files", [])
if isinstance(item, dict)
}
if expected not in listed:
return None
asset_root = (frontend_root / _ASSET_DIR).resolve()
candidate = (asset_root / filename).resolve()
if candidate.parent != asset_root or not candidate.is_file():
return None
return candidate
@@ -1,36 +0,0 @@
"""Public, manifest-gated JavaScript chunks for the Lovelace card."""
from __future__ import annotations
from pathlib import Path
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
try:
from homeassistant.components.http import KEY_HASS
except ImportError: # pragma: no cover - old HA compatibility
KEY_HASS = "hass" # type: ignore[assignment]
from .const import FRONTEND_ASSETS_URL
from .frontend_asset_manifest import resolve_frontend_asset
_FRONTEND_ROOT = Path(__file__).parent / "frontend"
class HouseplanFrontendAssetView(HomeAssistantView):
"""Serve only generated chunks named by the current build manifest."""
url = f"{FRONTEND_ASSETS_URL}/{{filename}}"
name = "api:houseplan:frontend-asset"
requires_auth = False
async def get(self, request: web.Request, filename: str) -> web.StreamResponse:
hass = request.app[KEY_HASS]
path = await hass.async_add_executor_job(
resolve_frontend_asset, _FRONTEND_ROOT, filename
)
if path is None:
raise web.HTTPNotFound()
return web.FileResponse(
path,
headers={"Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff"},
)
+11 -70
View File
@@ -36,10 +36,6 @@ from .const import (
VERSION,
)
from .store import HouseplanData
from .wall_segment_model import (
WallSegmentMigrationError,
commit_wall_segment_model,
)
from .validation import (
CONFIG_SCHEMA,
LAYOUT_SCHEMA,
@@ -68,8 +64,7 @@ _LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
_LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$")
_PLAN_ONLY_DASH = "—"
_IMPORT_ID_NAMESPACES = {
"space", "room", "marker", "partition", "wall", "opening", "decor", "draft",
"draft_segment", "column",
"space", "room", "marker", "partition", "opening", "decor", "draft", "column",
}
_MAX_IMPORT_LINEAGE_DEPTH = 16
_REPORT_EXAMPLE_LIMIT = 24
@@ -77,7 +72,6 @@ _REPORT_EXAMPLE_LIMIT = 24
_SPACE_PLAN_FIELDS = (
"id", "title", "cell_cm", "plan_url", "plan_aspect", "plan_x", "plan_y",
"plan_scale", "plan_scale_x", "plan_scale_y", "plan_angle", "view_box",
"zero_wall_style",
)
_SPACE_DISPLAY_FIELDS = (
"show_borders", "show_names", "room_color", "bg_color", "room_opacity",
@@ -86,7 +80,7 @@ _SPACE_DISPLAY_FIELDS = (
"label_lqi", "label_light", "card_font_scale", "north_deg", "bg_mode",
"sun_rays",
)
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly", "wall_ids")
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly")
_ROOM_DISPLAY_FIELDS = (
"fill_mode", "custom_fill", "glow", "name_scale", "label_scale",
)
@@ -255,7 +249,6 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
]
collections: tuple[tuple[str, tuple[str, ...]], ...] = (
("walls", ("key", "cm", "a", "b")),
("wall_segments", ("id", "a", "b", "cm")),
("room_drafts", ("id", "points", "segments")),
("partitions", ("id", "a", "b", "cm")),
("wall_columns", ("id", "shape", "center", "cm", "angle")),
@@ -269,7 +262,7 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
selected = _pick_fields(item, fields)
if name == "room_drafts" and "segments" in selected:
selected["segments"] = [
_pick_fields(segment, ("id", "cm"))
_pick_fields(segment, ("cm",))
for segment in selected.get("segments") or []
]
values.append(selected)
@@ -591,14 +584,7 @@ def parse_document(raw: bytes) -> dict[str, Any]:
if not isinstance(payload, dict):
raise ImportFailure("invalid_format", "Missing payload")
try:
config_candidate = _json_copy(payload.get("config"))
# The envelope is authoritative. Export deliberately omits this field
# inside payload.config, but v8 semantic validation still has to run
# before an import token can be issued.
if model > 0:
config_candidate["model_version"] = model
config = CONFIG_SCHEMA(config_candidate)
config.pop("model_version", None)
config = CONFIG_SCHEMA(_json_copy(payload.get("config")))
except (vol.Invalid, TypeError, ValueError) as err:
raise ImportFailure("invalid_config", str(err)) from err
if document["kind"] == "full":
@@ -1168,8 +1154,7 @@ def build_space_merge(
for sp in current_config.get("spaces") or []
for collection in (
(sp,), sp.get("rooms") or [], sp.get("room_drafts") or [],
sp.get("partitions") or [], sp.get("wall_segments") or [],
sp.get("wall_columns") or [],
sp.get("partitions") or [], sp.get("wall_columns") or [],
sp.get("openings") or [], sp.get("decor") or [],
)
for item in collection
@@ -1179,19 +1164,13 @@ def build_space_merge(
} | {
str(m.get("id")) for m in current_config.get("markers") or []
if m.get("id") is not None
} | {
str(segment.get("id"))
for sp in current_config.get("spaces") or []
for draft in sp.get("room_drafts") or []
for segment in draft.get("segments") or []
if isinstance(segment, dict) and segment.get("id") is not None
} | {
str(value)
for incoming_space in spaces
for collection in (
(incoming_space,), incoming_space.get("rooms") or [],
incoming_space.get("room_drafts") or [],
incoming_space.get("partitions") or [], incoming_space.get("wall_segments") or [],
incoming_space.get("partitions") or [],
incoming_space.get("wall_columns") or [],
incoming_space.get("openings") or [], incoming_space.get("decor") or [],
)
@@ -1202,12 +1181,6 @@ def build_space_merge(
} | {
str(m.get("id")) for m in incoming.get("markers") or []
if m.get("id") is not None
} | {
str(segment.get("id"))
for incoming_space in spaces
for draft in incoming_space.get("room_drafts") or []
for segment in draft.get("segments") or []
if isinstance(segment, dict) and segment.get("id") is not None
}
old_space_id = str(space.get("id"))
_record_bounded_lineage(
@@ -1221,7 +1194,6 @@ def build_space_merge(
id_maps["space"][old_space_id] = new_space_id
for collection, prefix in (
("rooms", "room"), ("room_drafts", "draft"), ("partitions", "partition"),
("wall_segments", "wall"),
("wall_columns", "column"), ("openings", "opening"), ("decor", "decor"),
):
for item in space.get(collection) or []:
@@ -1233,18 +1205,6 @@ def build_space_merge(
id_map[old] = _fresh(prefix, old, used)
id_maps[prefix][old] = id_map[old]
item["id"] = id_map[old]
for draft in space.get("room_drafts") or []:
for segment in draft.get("segments") or []:
if not isinstance(segment, dict) or segment.get("id") is None:
continue
old = str(segment["id"])
_record_bounded_lineage(
reference_report, bounded_seen, "draft_segment", old,
)
new = _fresh("draft_segment", old, used)
id_map[old] = new
id_maps["draft_segment"][old] = new
segment["id"] = new
old_room_ids = id_maps["room"]
for room in space.get("rooms") or []:
if room.get("open_to"):
@@ -1259,18 +1219,13 @@ def build_space_merge(
str(room.get("id", "?")), old_value,
)
room["open_to"] = remapped_open_to
if room.get("wall_ids"):
room["wall_ids"] = [
id_maps["wall"].get(str(value), str(value))
for value in room["wall_ids"]
]
# Opening ownership is part of the same space-local id graph. Remap the
# nested reference together with the partition itself; otherwise the
# invariant validator correctly rejects a copied space whose host.id still
# names the source partition.
for opening in space.get("openings") or []:
host = opening.get("host") if isinstance(opening, dict) else None
if isinstance(host, dict) and host.get("kind") in ("partition", "wall"):
if isinstance(host, dict) and host.get("kind") == "partition":
old_host_id = str(host.get("id"))
if old_host_id in id_map:
host["id"] = id_map[old_host_id]
@@ -1636,22 +1591,6 @@ def _materialize_import_candidate(
imported_config = prepared["payload"]["config"]
if confirmation_required:
_detach_missing(imported_config, content)
incoming_model = int(prepared.get("model_version", 0) or 0)
target_model = int(current_config.get("model_version", 0) or 0)
requires_v8 = incoming_model >= 8 or target_model >= 8
if requires_v8:
try:
# A v8 target cannot be downgraded by a v7 backup, while an
# incoming v8 graph must retain its already-persisted IDs. When
# both sides are v7 the accepted compatibility contract keeps the
# import v7 until a later structural write or explicit Optimize.
imported_config["model_version"] = incoming_model
imported_config, _ = commit_wall_segment_model(imported_config)
prepared["payload"]["config"] = imported_config
if prepared["kind"] == "space":
current_config, _ = commit_wall_segment_model(current_config)
except WallSegmentMigrationError as err:
raise ImportFailure(err.code, str(err)) from err
if prepared["kind"] == "space":
config, layout, details = build_space_merge(
prepared, current_config, current_layout, duplicate_policy,
@@ -1659,9 +1598,11 @@ def _materialize_import_candidate(
)
else:
config = imported_config
if not requires_v8 and incoming_model > 0:
config["model_version"] = incoming_model
_materialize_global_background(config)
model_version = prepared.get("model_version", 0)
if isinstance(model_version, int) and not isinstance(model_version, bool) \
and model_version > 0:
config["model_version"] = model_version
layout = _json_copy(prepared["payload"]["layout"])
if not same_source:
settings = config.get("settings") or {}
@@ -1,431 +0,0 @@
"""Issue #329 — wall junction limits, Python mirror of src/junction-limits.ts.
The card refuses a WRITE that would ADD a junction violation; this module is
the backend half of that contract for `config/set` and `plan/optimize` (#333)
— the two commands a client can use to write arbitrary geometry. Import and
backup restore are DELIBERATELY outside the gate: #329 §3 promises that a
restore is never blocked, so a crafted import can still persist violations —
they are then inherited, never legalised as new ones. It mirrors П1–П4 exactly, including the two legal
incidences of П4 (a shared node and a T-joint into the middle of a foreign
wall) and the П3 rule that measures the collinear same-thickness WALL RUN
rather than one catalogue atom.
П5 (the room keeps at least 25 cm² of interior) is deliberately NOT mirrored:
it is a statement about the rendered wall bodies, and reproducing the mitre /
inset pipeline in Python would be a second geometry implementation whose drift
is a larger risk than the rule it guards. A document that violates only П5 is
ugly, not corrupt — see docs/specs/329-junction-limits.md §5.
"""
from __future__ import annotations
import math
import logging
from .wall_segment_model import (
WALL_SEGMENT_MODEL_VERSION, WallSegmentMigrationError, commit_wall_segment_model,
)
_LOGGER = logging.getLogger(__name__)
MIN_JUNCTION_ANGLE_DEG = 15.0
MAX_JUNCTION_VALENCE = 6
MIN_SEGMENT_LENGTH_CM = 20.0
MIN_NODE_DISTANCE_CM = 5.0
GRID_STEP_N = 1 / 240
_EPS = 1e-9
# #331 §2.1: below this two points are ONE node / a node is ON the wall —
# floating debris of pre-canonicalisation arithmetic, not a near miss.
_INCIDENT_EPS = 2e-7
_KEY_FACTOR = 1e7
class JunctionLimitError(ValueError):
"""A write introduced a wall-junction violation (#329)."""
def __init__(self, space_id: str, rule: str, subject: str,
actual: float, limit: float) -> None:
self.space_id = space_id
self.rule = rule
self.subject = subject
self.actual = actual
self.limit = limit
self.code = f"junction_limit_{rule}"
super().__init__(
f"space={space_id}; rule={rule}; subject={subject}; "
f"actual={actual:.12g}; limit={limit:.12g}"
)
def _finite_point(point: object) -> bool:
return (
isinstance(point, (list, tuple))
and len(point) >= 2
and all(isinstance(value, (int, float)) and math.isfinite(value)
for value in point[:2])
)
def _quantize_key_coord(value: float) -> float:
"""#331 §2.1: the canonicalisation formula — sign*floor(|v|*f+0.5)/f.
Native round() is banker's rounding and parts ways with JS Math-style
rounding on .5 ticks; coordinate canonicalisation already encodes this
parity lesson, and node keys must follow it. -0 normalises to 0.
"""
rounded = math.copysign(
math.floor(abs(value) * _KEY_FACTOR + 0.5), value,
) / _KEY_FACTOR
return 0.0 if rounded == 0 else rounded
def _key(point) -> str:
return f"{_quantize_key_coord(point[0])},{_quantize_key_coord(point[1])}"
def _length(a, b) -> float:
return math.hypot(b[0] - a[0], b[1] - a[1])
def cm_to_units(cm: float, cell_cm: float, grid_pitch: float = GRID_STEP_N) -> float:
return (cm / (cell_cm or 1)) * grid_pitch
def limit_segments(space: dict) -> list[dict]:
"""Every wall the limits judge: contour atoms, partitions, draft segments."""
segments: list[dict] = []
for segment in space.get("wall_segments") or []:
if _finite_point(segment.get("a")) and _finite_point(segment.get("b")):
segments.append({
"id": str(segment.get("id") or ""),
"a": segment["a"], "b": segment["b"],
"cm": float(segment.get("cm") or 0),
})
for partition in space.get("partitions") or []:
if _finite_point(partition.get("a")) and _finite_point(partition.get("b")):
segments.append({
"id": str(partition.get("id") or ""),
"a": partition["a"], "b": partition["b"],
"cm": float(partition.get("cm") or 0),
})
for draft in space.get("room_drafts") or []:
points = draft.get("points") or []
drafted = draft.get("segments") or []
for index in range(max(len(points) - 1, 0)):
if not (_finite_point(points[index]) and _finite_point(points[index + 1])):
continue
piece = drafted[index] if index < len(drafted) else {}
segments.append({
"id": str((piece or {}).get("id")
or f"{draft.get('id') or 'draft'}-{index}"),
"a": points[index], "b": points[index + 1],
"cm": float((piece or {}).get("cm") or 0),
})
return [segment for segment in segments
if _length(segment["a"], segment["b"]) > _EPS]
def check_nodes(segments: list[dict]) -> list[tuple[str, str, float, float]]:
"""П1 + П2: valence of a node and the narrowest wedge in it."""
rays: dict[str, list[float]] = {}
for segment in segments:
for start, end in ((segment["a"], segment["b"]), (segment["b"], segment["a"])):
rays.setdefault(_key(start), []).append(
math.atan2(end[1] - start[1], end[0] - start[0])
)
violations: list[tuple[str, str, float, float]] = []
for node, angles in rays.items():
if len(angles) > MAX_JUNCTION_VALENCE:
violations.append(
("valence", node, float(len(angles)), float(MAX_JUNCTION_VALENCE))
)
if len(angles) < 2:
continue
ordered = sorted(angles)
smallest = math.inf
for index, value in enumerate(ordered):
delta = ordered[(index + 1) % len(ordered)] - value
if index == len(ordered) - 1:
delta += math.pi * 2
degrees = (delta * 180) / math.pi
# A ~0° pair is NOT a violation — and cannot be (#331 revision 4,
# learned in the field): a shared wall of two adjacent rooms is
# two co-located owner atoms ON ONE LINE, so every shared-wall
# node carries a legitimate 0° pair by construction. An exact
# duplicate stays a KNOWN LIMITATION of П1 at this level.
if _EPS < degrees < smallest:
smallest = degrees
if smallest < MIN_JUNCTION_ANGLE_DEG - 1e-9:
violations.append(("angle", node, smallest, MIN_JUNCTION_ANGLE_DEG))
return violations
def _axis_degrees(segment: dict) -> float:
degrees = math.degrees(math.atan2(
segment["b"][1] - segment["a"][1], segment["b"][0] - segment["a"][0]
))
return (degrees % 180 + 180) % 180
def _collinear(left: dict, right: dict, tolerance_deg: float = 1.0) -> bool:
delta = abs(_axis_degrees(left) - _axis_degrees(right))
return min(delta, 180 - delta) <= tolerance_deg
def _build_node_index(segments: list[dict]) -> dict[str, list[dict]]:
by_node: dict[str, list[dict]] = {}
for item in segments:
for point in (item["a"], item["b"]):
by_node.setdefault(_key(point), []).append(item)
return by_node
def collinear_run_length_units(
segment: dict, segments: list[dict],
by_node_index: dict[str, list[dict]] | None = None,
) -> float:
"""Length of the WALL a segment belongs to, not of the atom.
#331 §2.3/§2.4: an iterative edge walk over the collinear component — no
recursion (a 10 000-atom chain must answer, not overflow), no
combinatorial DFS (every atom joins at most once, O(E)), no silently
dropped fork. Collinearity is measured against the BASE segment's axis,
so an arc of small per-atom turns cannot pose as one straight wall.
#330 §4.3: the per-check caller builds the node index once.
"""
by_node = by_node_index if by_node_index is not None else _build_node_index(segments)
visited = {id(segment)}
total = _length(segment["a"], segment["b"])
frontier = [segment["a"], segment["b"]]
base_cm = float(segment.get("cm") or 0)
while frontier:
node = frontier.pop()
for candidate in by_node.get(_key(node), ()):
if id(candidate) in visited:
continue
if not _collinear(candidate, segment):
continue
if float(candidate.get("cm") or 0) != base_cm:
continue
visited.add(id(candidate))
total += _length(candidate["a"], candidate["b"])
frontier.append(candidate["a"])
frontier.append(candidate["b"])
return total
def check_segment_lengths(
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
) -> list[tuple[str, str, float, float]]:
"""П3: a wall is at least 20 cm and never shorter than its own thickness."""
violations: list[tuple[str, str, float, float]] = []
by_node = _build_node_index(segments)
for segment in segments:
units = collinear_run_length_units(segment, segments, by_node)
cm = (units / grid_pitch) * (cell_cm or 1)
thickness = float(segment.get("cm") or 0)
limit = max(MIN_SEGMENT_LENGTH_CM, thickness if thickness > 0 else 0.0)
if cm < limit - 1e-9:
violations.append((
"length", str(segment.get("id") or _key(segment["a"])), cm, limit,
))
return violations
def _distance_to_segment(point, a, b) -> float:
dx, dy = b[0] - a[0], b[1] - a[1]
length_sq = dx * dx + dy * dy
t = 0.0 if length_sq <= _EPS else max(0.0, min(1.0, (
(point[0] - a[0]) * dx + (point[1] - a[1]) * dy
) / length_sq))
return math.hypot(point[0] - (a[0] + dx * t), point[1] - (a[1] + dy * t))
def check_node_distances(
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
) -> list[tuple[str, str, float, float]]:
"""П4: non-incident nodes and node-to-foreign-wall clearance.
#330 §4.5: the all-pairs form cost 372 ms on 576 atoms. Nodes and padded
segment bounding boxes are hashed into a grid with the threshold as cell
size; each node is compared only against its 9-cell neighbourhood.
Verdicts are identical — pinned by the equivalence unit and the parity
suite. Pair order inside the subject follows the lexicographic key order,
which replaces the i<j of the all-pairs loop.
"""
nodes: dict[str, list] = {}
for segment in segments:
nodes[_key(segment["a"])] = segment["a"]
nodes[_key(segment["b"])] = segment["b"]
min_units = cm_to_units(MIN_NODE_DISTANCE_CM, cell_cm, grid_pitch)
size = min_units if min_units > _EPS else 1.0
node_grid: dict[tuple[int, int], list] = {}
for node_key, point in nodes.items():
node_grid.setdefault(
(int(point[0] // size), int(point[1] // size)), []
).append((node_key, point))
segment_grid: dict[tuple[int, int], list] = {}
for segment in segments:
x0 = min(segment["a"][0], segment["b"][0]) - min_units
x1 = max(segment["a"][0], segment["b"][0]) + min_units
y0 = min(segment["a"][1], segment["b"][1]) - min_units
y1 = max(segment["a"][1], segment["b"][1]) + min_units
for cx in range(int(x0 // size), int(x1 // size) + 1):
for cy in range(int(y0 // size), int(y1 // size) + 1):
segment_grid.setdefault((cx, cy), []).append(segment)
violations: list[tuple[str, str, float, float]] = []
for node_key, point in nodes.items():
cx, cy = int(point[0] // size), int(point[1] // size)
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for other_key, other in node_grid.get((cx + dx, cy + dy), ()):
if node_key >= other_key:
continue
distance = _length(point, other)
# #331 §2.1: raw debris within the incidence quantum is ONE
# node on two neighbouring keys — never a near miss.
if distance <= _INCIDENT_EPS:
continue
if distance < min_units - 1e-9:
violations.append((
"distance", f"{node_key} ↔ {other_key}",
(distance / grid_pitch) * (cell_cm or 1),
MIN_NODE_DISTANCE_CM,
))
for segment in segment_grid.get((cx, cy), ()):
# A node that belongs to the wall is a legal corner or T-joint.
if _key(segment["a"]) == node_key or _key(segment["b"]) == node_key:
continue
distance = _distance_to_segment(point, segment["a"], segment["b"])
# Sitting exactly ON the wall is the other legal incidence.
if distance <= _INCIDENT_EPS:
continue
if distance < min_units - 1e-9:
violations.append((
"distance",
f"{node_key} → {segment.get('id') or _key(segment['a'])}",
(distance / grid_pitch) * (cell_cm or 1),
MIN_NODE_DISTANCE_CM,
))
return violations
def space_violations(space: dict) -> list[tuple[str, str, float, float]]:
"""П1–П4 over one space, in the frontend's order."""
segments = limit_segments(space)
cell_cm = float(space.get("cell_cm") or 1)
return [
*check_nodes(segments),
*check_segment_lengths(segments, cell_cm),
*check_node_distances(segments, cell_cm),
]
def _migrated_spaces(config: dict | None, *, side: str = "previous") -> dict[str, dict]:
"""Spaces of one document AFTER the wall-segment migration, by id.
The limits read `wall_segments`, so a document that predates the catalogue
reports NO walls at all — a legacy space would answer "no violations"
regardless of its geometry. Comparing such a baseline against a candidate
the client already migrated counts every inherited violation as new and
refuses an unrelated edit (spec §3 forbids exactly that). Both sides are
therefore judged after the SAME migration, mirroring the frontend barrier.
A document that cannot be migrated is not a reason to refuse the write:
the wall-model barrier owns that verdict and reports it with its own code.
Here it simply means there is no baseline to inherit from.
"""
if not isinstance(config, dict):
return {}
# #330 §4.6: a document that already carries the current catalogue is
# used as-is. The migration's purpose here is to never compare a
# catalogue-less legacy baseline against a migrated candidate; a
# current-version document has the catalogue, so the purpose is met with
# zero work — while a no-op re-migration costs 0.8 s of _atomize on a
# 576-atom plan. Equivalence "as-is == migrated" for current-version
# documents is pinned by a parity case.
if int(config.get("model_version") or 0) >= WALL_SEGMENT_MODEL_VERSION:
migrated = config
else:
try:
migrated, _ = commit_wall_segment_model(config)
except (WallSegmentMigrationError, ValueError) as error:
# The wall-model barrier owns this verdict and reports it with its
# own code; here it merely means "no catalogue to read".
_LOGGER.debug("junction limits: %s migration fell back: %s", side, error)
migrated = config
except Exception:
# #331 §2.6 (AC6): a genuine migration bug on the CANDIDATE side
# must surface as an honest WS error, not a silent "no
# violations". The PREVIOUS side keeps the wide fallback — an
# unprovable inheritance is no reason to block an unrelated write
# (the exact P1 symptom this task fixes).
if side == "candidate":
raise
_LOGGER.debug("junction limits: previous-side migration bug swallowed by design")
migrated = config
return {
str(space.get("id")): space
for space in (migrated or {}).get("spaces") or []
if isinstance(space, dict)
}
def space_violation_counts(spaces: dict[str, dict]) -> dict[str, dict[str, int]]:
"""Violation counts per space per rule — the shape the barrier consumes
and the rev cache (#330 §4.2) stores. Documents are NOT retained."""
result: dict[str, dict[str, int]] = {}
for space_id, space in spaces.items():
counts: dict[str, int] = {}
for rule, _subject, _actual, _limit in space_violations(space):
counts[rule] = counts.get(rule, 0) + 1
result[space_id] = counts
return result
def validate_junction_limits(
config: dict, previous: dict | None = None, *,
baseline_counts: dict[str, dict[str, int]] | None = None,
) -> dict[str, dict[str, int]]:
"""Refuse a write that ADDS a junction violation; inherit the rest.
Counted per RULE, not per subject: a structural write re-atomises and
re-keys contour segments, so subject identity is not stable across the
barrier and matching by it would report an inherited violation as new
(the mistake that refused legitimate resizes on the frontend). Spec §3:
a write may keep existing violations, it may never add one.
A LEGACY document still goes through `commit_wall_segment_model` first,
so a catalogue-less baseline is compared in the same terms as the
candidate; a current-version document is used as-is (#330 §4.6).
`baseline_counts` (#330 §4.2) replaces the previous document entirely:
the caller caches the counts of the stored document by config_rev, so a
repeated write does not even touch `previous`. Returns the candidate's
counts — the value to cache after a successful write.
"""
if baseline_counts is not None:
old_counts = baseline_counts
else:
old_counts = space_violation_counts(_migrated_spaces(previous))
new_spaces = _migrated_spaces(config, side="candidate")
candidate_counts: dict[str, dict[str, int]] = {}
for space_id, space in new_spaces.items():
# A brand-new space has nothing to inherit from — but neither is a
# first write allowed to arrive already broken.
before = old_counts.get(space_id) or {}
after: dict[str, list[tuple[str, str, float, float]]] = {}
for violation in space_violations(space):
after.setdefault(violation[0], []).append(violation)
candidate_counts[space_id] = {
rule: len(items) for rule, items in after.items()
}
for rule, items in after.items():
if len(items) > before.get(rule, 0):
_, subject, actual, limit = items[0]
raise JunctionLimitError(space_id, rule, subject, actual, limit)
return candidate_counts
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.69.0-beta.1"
"version": "1.67.0-rc.3"
}
-5
View File
@@ -101,11 +101,6 @@ class HouseplanData:
# Parsed import candidates are short-lived, user-bound and memory-only.
# dict keeps insertion order, which lets the preview service evict oldest.
import_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
# #330 §4.2: junction-limit violation counts of the STORED document,
# keyed by its rev — (rev, {space_id: {rule: count}}). One slot,
# memory-only, invalidated by a rev mismatch; the previous document is
# not re-judged on every write. Counts only — documents are never held.
junction_baseline: tuple[int, dict[str, dict[str, int]]] | None = None
HouseplanConfigEntry = ConfigEntry[HouseplanData]
@@ -1,30 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "House Plan",
"data": {
"admin_only": "Nur Administratoren dürfen den Grundriss bearbeiten"
}
}
},
"abort": {
"single_instance_allowed": "Bereits konfiguriert – es ist nur eine Instanz zulässig."
}
},
"options": {
"step": {
"init": {
"data": {
"admin_only": "Nur Administratoren dürfen den Grundriss bearbeiten"
}
}
}
},
"issues": {
"broken_plan": {
"title": "Grundrissbild fehlt",
"description": "Die Plandatei `{file}` für den Bereich `{space}` wurde in `config/houseplan/plans/` nicht gefunden. In den Bereichseinstellungen der House-Plan-Karte den Plan erneut hochladen."
}
}
}
+24 -333
View File
@@ -5,7 +5,6 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
from __future__ import annotations
from collections import Counter
import copy
import math
import re
@@ -78,137 +77,6 @@ class PartitionOpeningJambMarginError(ValueError):
)
class WallModelClientOutdatedError(ValueError):
"""A stale client tried to replace an already-migrated v8 document."""
code = "wall_model_client_outdated"
def _legacy_wall_model_projection(config: dict) -> dict:
"""Structural view an older client can faithfully round-trip."""
projected = copy.deepcopy(config)
projected.pop("model_version", None)
for space in projected.get("spaces") or []:
space.pop("wall_segments", None)
for room in space.get("rooms") or []:
room.pop("wall_ids", None)
for draft in space.get("room_drafts") or []:
for segment in draft.get("segments") or []:
segment.pop("id", None)
for opening in space.get("openings") or []:
host = opening.get("host")
if isinstance(host, dict) and host.get("kind") == "wall":
opening.pop("host", None)
return projected
def _catalog_coupled_wall_geometry_projection(config: dict) -> list[dict]:
"""Legacy geometry whose edit must also update the v8 contour catalogue.
Drafts, partitions and columns carry their own ids. Openings carry an
explicit v8 host and are checked against it by CONFIG_SCHEMA. None of those
independent objects requires a different contour wall_segments catalogue.
"""
result: list[dict] = []
for space in config.get("spaces") or []:
rooms = []
for room in space.get("rooms") or []:
rooms.append({key: copy.deepcopy(room.get(key)) for key in (
"id", "poly", "x", "y", "w", "h", "open_to"
) if key in room})
result.append({
"id": space.get("id"),
"rooms": rooms,
"walls": copy.deepcopy(space.get("walls") or []),
"open_spans": copy.deepcopy(space.get("open_spans") or []),
})
return result
def _wall_catalog_projection(config: dict) -> list[dict]:
"""Authoritative part a current v8 structural writer must update."""
return [{
"id": space.get("id"),
"wall_segments": copy.deepcopy(space.get("wall_segments")),
} for space in config.get("spaces") or []]
def _restore_wall_model_fields(config: dict, previous: dict) -> None:
"""Hydrate only v8 identity fields after an exact legacy round-trip."""
config["model_version"] = previous["model_version"]
old_spaces = {str(space.get("id")): space for space in previous.get("spaces") or []}
for space in config.get("spaces") or []:
old_space = old_spaces.get(str(space.get("id")))
if old_space is None:
continue
space["wall_segments"] = copy.deepcopy(old_space.get("wall_segments") or [])
old_rooms = {str(room.get("id")): room for room in old_space.get("rooms") or []}
for room in space.get("rooms") or []:
old_room = old_rooms.get(str(room.get("id")))
if old_room is not None and "wall_ids" in old_room:
room["wall_ids"] = copy.deepcopy(old_room["wall_ids"])
old_drafts = {str(draft.get("id")): draft for draft in old_space.get("room_drafts") or []}
for draft in space.get("room_drafts") or []:
old_draft = old_drafts.get(str(draft.get("id")))
if old_draft is None:
continue
old_segments = old_draft.get("segments") or []
for index, segment in enumerate(draft.get("segments") or []):
if index < len(old_segments) and old_segments[index].get("id"):
segment["id"] = old_segments[index]["id"]
old_openings = {
str(opening.get("id")): opening for opening in old_space.get("openings") or []
}
for opening in space.get("openings") or []:
old_opening = old_openings.get(str(opening.get("id")))
old_host = (old_opening or {}).get("host")
if isinstance(old_host, dict) and old_host.get("kind") == "wall":
opening["host"] = copy.deepcopy(old_host)
def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
"""Do not let omission of model_version bypass v8 semantic validation."""
try:
old_model = int((previous or {}).get("model_version", 0))
new_model = int(config.get("model_version", 0))
except (TypeError, ValueError):
return # CONFIG_SCHEMA owns malformed values.
previous = previous or {}
contour_geometry_changed = (
_catalog_coupled_wall_geometry_projection(config)
!= _catalog_coupled_wall_geometry_projection(previous)
)
if old_model >= 8 and new_model < 8:
if not contour_geometry_changed:
_restore_wall_model_fields(config, previous or {})
# Re-run semantic parity after hydration; previous identity is
# accepted only when it still matches the submitted projections.
validated = CONFIG_SCHEMA(config)
config.clear()
config.update(validated)
return
raise WallModelClientOutdatedError(
f"stored model={old_model}; submitted model={new_model}"
)
if old_model >= 8 and 8 <= new_model <= old_model and contour_geometry_changed:
# The realistic stale-client case echoes model_version and the unknown
# catalogue verbatim while changing room/contour geometry. Let the
# frontend show the dedicated reload guidance instead of a generic
# schema error. Independent drafts, partitions, columns and explicitly
# hosted openings are validated by CONFIG_SCHEMA without requiring a
# contour catalogue change (#314).
#
# A submitted model ABOVE the stored one is excluded on purpose (#319):
# a stale client can only echo the stored version, never raise it. The
# first write of a newer client legitimately drops legacy projection
# fields (an orphan open_span/open_to) without touching the catalogue;
# treating that as "outdated" wedged every structural write forever.
if _wall_catalog_projection(config) == _wall_catalog_projection(previous):
raise WallModelClientOutdatedError(
f"stored model={old_model}; unchanged wall catalogue"
)
# 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.
@@ -652,39 +520,34 @@ def validate_partition_opening_hosts(
for opening in space.get("openings") or []:
opening_id = str(opening.get("id", ""))
old = old_openings.get(opening_id)
old_host = (old or {}).get("host")
host = opening.get("host")
if (old and isinstance(old_host, dict)
and old_host.get("kind") == "partition"):
same_partition = (
isinstance(host, dict) and host.get("kind") == "partition"
and str(host.get("id", "")) == str(old_host.get("id", ""))
)
residual_rehost = (
isinstance(host, dict) and host.get("kind") == "partition"
and not same_partition
)
proved = same_partition or (
allow_optimize_rehost and old_space is not None
and (_safe_optimize_residual_rehost(
space, old_space, opening, old
) if residual_rehost else _safe_optimize_partition_rehost(
space, old_space, opening, old
))
)
if not proved:
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 changed"
f"space={space_id}; opening={opening_id}; host removed"
)
host = opening.get("host")
if host is None:
continue
if host.get("kind") != "partition":
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
@@ -1047,11 +910,7 @@ MAX_ROOMS = 400
MAX_MARKERS = 2000
MAX_OPENINGS = 500
MAX_DECOR = 1000
# v8 atomises room boundaries. The 2 MiB wire cap is the practical bound; this
# structural cap mirrors MAX_ROOMS * MAX_POLY_POINTS without depending on the
# later constant declaration.
MAX_WALLS = 200_000
MAX_WALL_SEGMENTS = 200_000
MAX_WALLS = 500
MAX_ROOM_DRAFTS = 200
MAX_DRAFT_SEGMENTS = 2000
MAX_PARTITIONS = 2000
@@ -1216,10 +1075,6 @@ ROOM_SCHEMA = vol.All(
vol.Optional("w"): _EXTENT,
vol.Optional("h"): _EXTENT,
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
vol.Optional("wall_ids"): vol.All(
[vol.All(str, vol.Length(min=1, max=64))],
vol.Length(min=3, max=MAX_POLY_POINTS),
),
},
extra=vol.ALLOW_EXTRA,
),
@@ -1391,26 +1246,6 @@ WALL_SCHEMA = vol.All(
)
def _wall_segment_nonzero(value: dict) -> dict:
if value["a"] == value["b"]:
raise vol.Invalid("wall segment endpoints must differ")
return value
WALL_SEGMENT_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("a"): POINT,
vol.Required("b"): POINT,
vol.Required("cm"): vol.All(_finite, vol.Range(min=0, max=100)),
},
extra=vol.ALLOW_EXTRA,
),
_wall_segment_nonzero,
)
def _room_draft_segments(value: dict) -> dict:
"""An open draft has exactly one thickness per consecutive edge."""
if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1):
@@ -1426,10 +1261,7 @@ ROOM_DRAFT_SCHEMA = vol.All(
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("points"): vol.All([POINT], vol.Length(min=2, max=500)),
vol.Required("segments"): vol.All(
[vol.Schema({
vol.Optional("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("cm"): vol.All(_finite, vol.Range(min=0, max=100)),
},
[vol.Schema({vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100))},
extra=vol.ALLOW_EXTRA)],
vol.Length(min=1, max=499),
),
@@ -1451,7 +1283,7 @@ PARTITION_SCHEMA = vol.All(
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("a"): POINT,
vol.Required("b"): POINT,
vol.Required("cm"): vol.All(_finite, vol.Range(min=0, max=100)),
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
},
extra=vol.ALLOW_EXTRA,
),
@@ -1493,22 +1325,11 @@ PARTITION_OPENING_HOST_SCHEMA = vol.Schema(
extra=vol.PREVENT_EXTRA,
)
WALL_OPENING_HOST_SCHEMA = vol.Schema(
{
vol.Required("kind"): vol.Equal("wall"),
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,
)
OPENING_HOST_SCHEMA = vol.Any(PARTITION_OPENING_HOST_SCHEMA, WALL_OPENING_HOST_SCHEMA)
def _space_geometry_invariants(value: dict) -> dict:
"""All stored geometry shares ids; draft segments also have a space cap."""
seen: set[str] = set()
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns", "wall_segments"):
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
for item in value.get(key, []):
item_id = item.get("id")
if not item_id:
@@ -1521,14 +1342,6 @@ def _space_geometry_invariants(value: dict) -> dict:
)
if draft_segments > MAX_DRAFT_SEGMENTS:
raise vol.Invalid("too many saved room-draft segments")
for draft in value.get("room_drafts", []):
for segment in draft.get("segments", []):
segment_id = segment.get("id")
if not segment_id:
continue
if segment_id in seen:
raise vol.Invalid("geometry object ids must be unique within a space")
seen.add(segment_id)
partitions = {
item.get("id"): item for item in value.get("partitions", []) if item.get("id")
}
@@ -1537,8 +1350,6 @@ def _space_geometry_invariants(value: dict) -> dict:
host = opening.get("host")
if host is None:
continue
if host["kind"] != "partition":
continue
partition = partitions.get(host["id"])
if partition is None:
raise vol.Invalid("partition opening host must exist in the same space")
@@ -1619,7 +1430,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
vol.Optional("invert"): bool,
vol.Optional("flip_h"): bool,
vol.Optional("flip_v"): bool,
vol.Optional("host"): OPENING_HOST_SCHEMA,
vol.Optional("host"): PARTITION_OPENING_HOST_SCHEMA,
},
extra=vol.ALLOW_EXTRA,
)
@@ -1628,10 +1439,6 @@ SPACE_SCHEMA = vol.All(vol.Schema(
# (midpoint + direction), thickness always in centimetres. Optional —
# a space without `walls` validates and renders exactly as before.
vol.Optional("walls"): vol.All([WALL_SCHEMA], vol.Length(max=MAX_WALLS)),
vol.Optional("wall_segments"): vol.All(
[WALL_SEGMENT_SCHEMA], vol.Length(max=MAX_WALL_SEGMENTS)
),
vol.Optional("zero_wall_style"): vol.In(["dashed", "solid"]),
vol.Optional("room_drafts"): vol.All(
[ROOM_DRAFT_SCHEMA], vol.Length(max=MAX_ROOM_DRAFTS)
),
@@ -1760,124 +1567,9 @@ MARKER_SCHEMA = vol.Schema(
},
extra=vol.ALLOW_EXTRA,
)
def _canonical_segment_key(a: list, b: list) -> tuple:
"""Endpoint-order-independent exact key after the shared canonicalizer."""
pa = (round(float(a[0]), 12), round(float(a[1]), 12))
pb = (round(float(b[0]), 12), round(float(b[1]), 12))
return (pa, pb) if pa <= pb else (pb, pa)
def _config_wall_segment_invariants(value: dict) -> dict:
"""Fail closed when a v8+ writer sends stale ids or wall projections."""
try:
model = int(value.get("model_version", 0))
except (TypeError, ValueError):
raise vol.Invalid("model_version must be an integer") from None
if model < 8:
return value
for space in value.get("spaces", []):
if model >= 9:
if "open_spans" in space or any("open_to" in room for room in space.get("rooms", [])):
raise vol.Invalid("v9 config must not contain legacy open boundaries")
segments = space.get("wall_segments")
if segments is None:
raise vol.Invalid("v8+ space requires wall_segments")
by_id = {segment["id"]: segment for segment in segments}
if len(by_id) != len(segments):
raise vol.Invalid("wall segment ids must be unique")
owners: dict[str, set[str]] = {segment_id: set() for segment_id in by_id}
for room in space.get("rooms", []):
poly = room.get("poly")
wall_ids = room.get("wall_ids")
if not poly or not wall_ids or len(poly) != len(wall_ids):
raise vol.Invalid("v8+ room wall_ids must match poly edges")
room_id = room["id"]
for index, segment_id in enumerate(wall_ids):
segment = by_id.get(segment_id)
if segment is None:
raise vol.Invalid("room wall id must reference the same space")
edge_key = _canonical_segment_key(poly[index], poly[(index + 1) % len(poly)])
if edge_key != _canonical_segment_key(segment["a"], segment["b"]):
raise vol.Invalid("room wall reference geometry must match its edge")
owners[segment_id].add(room_id)
if any(len(room_ids) not in (1, 2) for room_ids in owners.values()):
raise vol.Invalid("wall segment must have one or two room owners")
expected_walls = {
_canonical_segment_key(segment["a"], segment["b"]): float(segment["cm"])
for segment in segments if float(segment["cm"]) > 0
}
actual_walls: dict[tuple, float] = {}
for wall in space.get("walls", []):
if "a" not in wall or "b" not in wall:
raise vol.Invalid("wall compatibility projection requires exact endpoints")
key = _canonical_segment_key(wall["a"], wall["b"])
if key in actual_walls:
raise vol.Invalid("wall compatibility projection must be unique")
actual_walls[key] = float(wall["cm"])
if actual_walls != expected_walls:
raise vol.Invalid("wall compatibility projection must match wall_segments")
for draft in space.get("room_drafts", []):
if any(not segment.get("id") for segment in draft.get("segments", [])):
raise vol.Invalid("v8+ draft wall segments require ids")
partitions = {item["id"]: item for item in space.get("partitions", [])}
wall_opening_intervals: dict[str, list[tuple[float, float]]] = {}
for opening in space.get("openings", []):
host = opening.get("host")
if host is None:
# #316 §3.3: a degraded migration may leave a contour opening
# unhosted. It is inert (no body/tunnel/cut) and excluded from
# the interval checks; a later edit can re-place it.
continue
if host["kind"] == "partition":
if host["id"] not in partitions:
raise vol.Invalid("partition opening host must exist in the same space")
if float(partitions[host["id"]]["cm"]) <= 0:
raise vol.Invalid("opening host must have positive thickness")
continue
segment = by_id.get(host["id"])
if segment is None:
raise vol.Invalid("wall opening host must exist in the same space")
if float(segment["cm"]) <= 0:
raise vol.Invalid("opening host must have positive thickness")
t = float(host["t"])
x = float(segment["a"][0]) + (float(segment["b"][0]) - float(segment["a"][0])) * t
y = float(segment["a"][1]) + (float(segment["b"][1]) - float(segment["a"][1])) * t
if abs(x - float(opening["x"])) > 2e-8 or abs(y - float(opening["y"])) > 2e-8:
raise vol.Invalid("wall opening geometry must match its host")
expected_angle = math.degrees(math.atan2(
float(segment["b"][1]) - float(segment["a"][1]),
float(segment["b"][0]) - float(segment["a"][0]),
))
if _angle_delta_mod_180(float(opening["angle"]), expected_angle) > 8:
raise vol.Invalid("wall opening angle must match its host")
span = ((segment["b"][0] - segment["a"][0]) ** 2
+ (segment["b"][1] - segment["a"][1]) ** 2) ** 0.5
along = t * span
length = float(opening["length"])
if length > span or along - length / 2 < -1e-9 or along + length / 2 > span + 1e-9:
raise vol.Invalid("wall opening must fit inside its host")
lo, hi = along - length / 2, along + length / 2
occupied = wall_opening_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("wall openings must not overlap")
occupied.append((lo, hi))
return value
CONFIG_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional("model_version"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=1_000_000)
),
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(
@@ -1912,5 +1604,4 @@ CONFIG_SCHEMA = vol.All(
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
),
canonicalize_config_geometry,
_config_wall_segment_invariants,
)
@@ -1,684 +0,0 @@
"""Deterministic persisted contour-wall identity (model v9, issues #282/#306).
This is the backend twin of ``src/wall-segment-model.ts``. Import preview is
a server-side structural writer, so it cannot depend on a browser being open
to upgrade v7 backups before validating/remapping them.
"""
from __future__ import annotations
import base64
import copy
import hashlib
import math
import uuid
from typing import Any
from .coordinate_canonicalization import canonicalize_config_geometry
WALL_SEGMENT_MODEL_VERSION = 9
GRID_STEP_N = 1 / 240
EPS = 1e-9
class WallSegmentMigrationError(ValueError):
"""The candidate cannot be upgraded without guessing ownership."""
code = "wall_model_migration_blocked"
def __init__(self, reason: str, detail: str = "") -> None:
self.reason = reason
super().__init__(f"{reason}: {detail}" if detail else reason)
def _point_key(point: list[float]) -> str:
return f"{float(point[0]):.12f},{float(point[1]):.12f}"
def _span_key(a: list[float], b: list[float]) -> str:
ka, kb = _point_key(a), _point_key(b)
return f"{ka}|{kb}" if ka < kb else f"{kb}|{ka}"
def _canonical_span(a: list[float], b: list[float]) -> tuple[list[float], list[float]]:
left, right = ([float(a[0]), float(a[1])], [float(b[0]), float(b[1])])
return (left, right) if _point_key(left) <= _point_key(right) else (right, left)
def _length(a: list[float], b: list[float]) -> float:
return math.hypot(float(b[0]) - float(a[0]), float(b[1]) - float(a[1]))
def _project_t(point: list[float], a: list[float], b: list[float]) -> float:
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
denominator = dx * dx + dy * dy
if denominator <= EPS * EPS:
return 0
return ((float(point[0]) - float(a[0])) * dx
+ (float(point[1]) - float(a[1])) * dy) / denominator
def _distance_to_segment(point: list[float], a: list[float], b: list[float]) -> float:
t = max(0.0, min(1.0, _project_t(point, a, b)))
return math.hypot(
float(point[0]) - (float(a[0]) + (float(b[0]) - float(a[0])) * t),
float(point[1]) - (float(a[1]) + (float(b[1]) - float(a[1])) * t),
)
def _collinear_overlap(
a: list[float], b: list[float], c: list[float], d: list[float], epsilon: float = EPS,
) -> float:
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
length = math.hypot(dx, dy)
if length <= epsilon:
return 0
cross_c = abs((float(c[0]) - float(a[0])) * dy
- (float(c[1]) - float(a[1])) * dx) / length
cross_d = abs((float(d[0]) - float(a[0])) * dy
- (float(d[1]) - float(a[1])) * dx) / length
if cross_c > epsilon or cross_d > epsilon:
return 0
tc, td = _project_t(c, a, b), _project_t(d, a, b)
return max(0.0, min(1.0, max(tc, td)) - max(0.0, min(tc, td))) * length
def _room_poly(room: dict[str, Any]) -> list[list[float]]:
poly = room.get("poly")
if isinstance(poly, list) and len(poly) >= 3:
return [[float(point[0]), float(point[1])] for point in poly]
if all(isinstance(room.get(key), (int, float)) and not isinstance(room.get(key), bool)
for key in ("x", "y", "w", "h")):
x, y, width, height = (float(room[key]) for key in ("x", "y", "w", "h"))
return [[x, y], [x + width, y], [x + width, y + height], [x, y + height]]
return []
def _shared_boundaries(first: list[list[float]], second: list[list[float]]) -> list[list[float]]:
result: list[list[float]] = []
epsilon = GRID_STEP_N * 0.04
for index, start in enumerate(first):
end = first[(index + 1) % len(first)]
dx, dy = end[0] - start[0], end[1] - start[1]
length = math.hypot(dx, dy)
if length < epsilon:
continue
ux, uy = dx / length, dy / length
for other_index, other_start in enumerate(second):
other_end = second[(other_index + 1) % len(second)]
tolerance = max(epsilon, length * 1e-6)
distances = [
abs((point[0] - start[0]) * uy - (point[1] - start[1]) * ux)
for point in (other_start, other_end)
]
if max(distances) > tolerance:
continue
t1 = (other_start[0] - start[0]) * ux + (other_start[1] - start[1]) * uy
t2 = (other_end[0] - start[0]) * ux + (other_end[1] - start[1]) * uy
lo, hi = max(0.0, min(t1, t2)), min(length, max(t1, t2))
if hi - lo > epsilon:
result.append([
start[0] + ux * lo, start[1] + uy * lo,
start[0] + ux * hi, start[1] + uy * hi,
])
return result
def _wall_direction(a: list[float], b: list[float]) -> tuple[float, float]:
dx, dy = b[0] - a[0], b[1] - a[1]
length = math.hypot(dx, dy)
if length < 1e-12:
return 1.0, 0.0
dx, dy = dx / length, dy / length
if dx < -1e-12 or (abs(dx) <= 1e-12 and dy < 0):
dx, dy = -dx, -dy
return dx, dy
def _js_round(value: float) -> int:
return math.floor(value + 0.5)
def _quantize(value: float, pitch: float = GRID_STEP_N) -> float:
return _js_round(value / pitch) * pitch
def _wall_key(a: list[float], b: list[float]) -> str:
midpoint_x = _quantize((float(a[0]) + float(b[0])) / 2)
midpoint_y = _quantize((float(a[1]) + float(b[1])) / 2)
dx, dy = _wall_direction(a, b)
angle = math.atan2(dy, dx)
if angle < 0:
angle += math.pi
angle = _js_round(angle * 1800) / 1800
return f"{midpoint_x:.6f},{midpoint_y:.6f}@{angle:.4f}"
def deterministic_wall_segment_id(
space_id: str, a: list[float], b: list[float], owners: list[str], salt: str = "",
) -> str:
ca, cb = _canonical_span(a, b)
seed = (f"{space_id}|{_point_key(ca)}|{_point_key(cb)}|"
f"{','.join(sorted(owners))}{salt}")
encoded = base64.b32encode(hashlib.sha256(seed.encode()).digest()).decode().lower()
return f"wall-{encoded[:20]}"
def _translated_segment_delta(
a: list[float], b: list[float], previous: dict[str, Any],
) -> tuple[float, float] | None:
def matches(pa: list[float], pb: list[float]) -> tuple[float, float] | None:
dx, dy = a[0] - pa[0], a[1] - pa[1]
if abs((b[0] - pb[0]) - dx) <= EPS and abs((b[1] - pb[1]) - dy) <= EPS:
return dx, dy
return None
return matches(previous["a"], previous["b"]) or matches(previous["b"], previous["a"])
def _atomize(
space: dict[str, Any], old: dict[str, dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
rooms = space.get("rooms") or []
room_polys = {str(room.get("id", "")): _room_poly(room) for room in rooms}
global_breaks: list[list[float]] = []
explicit_spans = space.get("open_spans") or []
legacy_segments: list[list[float]] = []
for span in explicit_spans:
if isinstance(span, dict) and isinstance(span.get("a"), list) and isinstance(span.get("b"), list):
global_breaks.extend((span["a"], span["b"]))
legacy_segments.append([
float(span["a"][0]), float(span["a"][1]),
float(span["b"][0]), float(span["b"][1]),
])
if not legacy_segments:
linked = lambda first, second: (
str(second.get("id", "")) in (first.get("open_to") or [])
or str(first.get("id", "")) in (second.get("open_to") or [])
)
for first_index, first in enumerate(rooms):
for second in rooms[first_index + 1:]:
if not linked(first, second):
continue
for shared in _shared_boundaries(
room_polys.get(str(first.get("id", "")), []),
room_polys.get(str(second.get("id", "")), []),
):
legacy_segments.append(shared)
global_breaks.extend(([shared[0], shared[1]], [shared[2], shared[3]]))
canonical_zero_segments: list[list[float]] = []
for room in rooms:
poly = room_polys.get(str(room.get("id", ""))) or []
wall_ids = room.get("wall_ids") or []
if len(wall_ids) != len(poly):
continue
for index, segment_id in enumerate(wall_ids):
previous = old.get(str(segment_id)) if isinstance(segment_id, str) else None
if previous is None or float(previous.get("cm", 0)) != 0:
continue
following = (index + 1) % len(poly)
canonical_zero_segments.append([
float(poly[index][0]), float(poly[index][1]),
float(poly[following][0]), float(poly[following][1]),
])
for segment in canonical_zero_segments:
global_breaks.extend((segment[:2], segment[2:]))
zero_segments = canonical_zero_segments + legacy_segments
# #316 §3.1: a legacy open_spans/open_to cut never zeroes the atom that
# carries an existing contour opening; its edges become atom boundaries so
# the zero run continues on both sides of the opening.
legacy_era_openings: list[dict[str, Any]] = []
if legacy_segments:
for opening in space.get("openings") or []:
if not isinstance(opening, dict):
continue
host = opening.get("host")
if isinstance(host, dict) and host.get("kind") == "partition":
continue
try:
centre = [float(opening["x"]), float(opening["y"])]
angle = float(opening["angle"])
half = float(opening["length"]) / 2
except (KeyError, TypeError, ValueError):
continue
if half <= 0:
continue
# Only an opening that actually stands on a legacy cut changes the
# atomization; unrelated openings must not churn the catalogue.
if not any(
_distance_to_segment(centre, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
for cut in legacy_segments
):
continue
legacy_era_openings.append(
{"centre": centre, "angle": angle, "half": half}
)
direction = (math.cos(math.radians(angle)), math.sin(math.radians(angle)))
global_breaks.extend((
[centre[0] - direction[0] * half, centre[1] - direction[1] * half],
[centre[0] + direction[0] * half, centre[1] + direction[1] * half],
))
def _atom_carries_opening(a: list[float], b: list[float]) -> bool:
for entry in legacy_era_openings:
if not _angle_matches(a, b, entry["angle"]):
continue
if _distance_to_segment(entry["centre"], a, b) > GRID_STEP_N * 0.02:
continue
span = _length(a, b)
tc = _project_t(entry["centre"], a, b) * span
if tc + entry["half"] >= -EPS and tc - entry["half"] <= span + EPS:
return True
return False
for wall in space.get("walls") or []:
if isinstance(wall, dict) and isinstance(wall.get("a"), list) and isinstance(wall.get("b"), list):
global_breaks.extend((wall["a"], wall["b"]))
atoms_by_key: dict[str, dict[str, Any]] = {}
next_rooms: list[dict[str, Any]] = []
epsilon = GRID_STEP_N * 0.04
for raw_room in rooms:
room_id = str(raw_room.get("id", ""))
original = room_polys.get(room_id) or []
if not room_id or len(original) < 3:
raise WallSegmentMigrationError("invalid-room", room_id)
breaks = list(global_breaks)
for other_id, other_poly in room_polys.items():
if other_id == room_id:
continue
for shared in _shared_boundaries(original, other_poly):
breaks.extend(([shared[0], shared[1]], [shared[2], shared[3]]))
poly: list[list[float]] = []
parents: list[int] = []
for index, a in enumerate(original):
b = original[(index + 1) % len(original)]
poly.append(list(a))
parents.append(index)
length = _length(a, b)
if length < epsilon * 2:
raise WallSegmentMigrationError("zero-length", room_id)
gap = min(0.499, epsilon * 2 / length)
positions: list[float] = []
for breakpoint in breaks:
if _distance_to_segment(breakpoint, a, b) > epsilon:
continue
t = _project_t(breakpoint, a, b)
if t <= gap or t >= 1 - gap:
continue
if any(abs(existing - t) * length <= epsilon * 2 for existing in positions):
continue
positions.append(t)
for t in sorted(positions):
poly.append([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t])
parents.append(index)
old_ids = raw_room.get("wall_ids") if isinstance(raw_room.get("wall_ids"), list) else []
indexed_lineage = len(old_ids) == len(original)
rigid_delta: tuple[float, float] | None = None
rigid_indexed_lineage = indexed_lineage
if rigid_indexed_lineage:
for index, a in enumerate(original):
previous = old.get(old_ids[index])
delta = _translated_segment_delta(
a, original[(index + 1) % len(original)], previous,
) if previous else None
if delta is None or (rigid_delta is not None and (
abs(delta[0] - rigid_delta[0]) > EPS
or abs(delta[1] - rigid_delta[1]) > EPS
)):
rigid_indexed_lineage = False
break
rigid_delta = delta
wall_keys: list[str] = []
for index, a in enumerate(poly):
b = poly[(index + 1) % len(poly)]
key = _span_key(a, b)
atom = atoms_by_key.setdefault(key, {
"key": key, "a": _canonical_span(a, b)[0], "b": _canonical_span(a, b)[1],
"owners": set(), "preferred": set(), "positional": set(),
"preferred_carriers": {},
"parent_keys": set(), "zero_wall": False,
})
atom["owners"].add(room_id)
if len(atom["owners"]) > 2:
raise WallSegmentMigrationError("third-owner", key)
parent_index = parents[index]
atom["parent_keys"].add(_wall_key(original[parent_index], original[(parent_index + 1) % len(original)]))
midpoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
covered_by = lambda cuts: any(
_distance_to_segment(midpoint, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
for cut in cuts
)
# Canonical cm:0 atoms stay zero; a LEGACY cut spares the atom
# that carries an opening (#316 §3.1).
atom["zero_wall"] = atom["zero_wall"] or covered_by(canonical_zero_segments) or (
covered_by(legacy_segments) and not _atom_carries_opening(a, b)
)
if indexed_lineage and isinstance(old_ids[parent_index], str) and old_ids[parent_index]:
previous = old.get(old_ids[parent_index])
if rigid_indexed_lineage or previous is None or _collinear_overlap(
a, b, previous["a"], previous["b"]
) > EPS:
atom["preferred"].add(old_ids[parent_index])
else:
atom["positional"].add(old_ids[parent_index])
atom["preferred_carriers"][old_ids[parent_index]] = {
"a": list(original[parent_index]),
"b": list(original[(parent_index + 1) % len(original)]),
}
wall_keys.append(key)
next_room = {key: copy.deepcopy(value) for key, value in raw_room.items() if key != "wall_ids"}
next_room["poly"] = poly
next_room["wall_ids"] = wall_keys
next_rooms.append(next_room)
return sorted(atoms_by_key.values(), key=lambda atom: atom["key"]), next_rooms
def _thickness(space: dict[str, Any], atom: dict[str, Any], previous: dict[str, Any] | None) -> float:
if atom.get("zero_wall"):
return 0.0
candidates: list[float] = []
query_key = _wall_key(atom["a"], atom["b"])
query_length = _length(atom["a"], atom["b"])
for wall in space.get("walls") or []:
try:
cm = float(wall.get("cm", 0))
except (TypeError, ValueError):
continue
if cm <= 0:
continue
exact_key = wall.get("key") == query_key or wall.get("key") in atom["parent_keys"]
covers = False
if isinstance(wall.get("a"), list) and isinstance(wall.get("b"), list):
overlap = _collinear_overlap(atom["a"], atom["b"], wall["a"], wall["b"])
covers = overlap >= query_length - EPS
if exact_key or covers:
candidates.append(max(1.0, min(100.0, cm)))
unique = {round(value, 9) for value in candidates}
if len(unique) > 1:
raise WallSegmentMigrationError("thickness-conflict", atom["key"])
if candidates:
return candidates[0]
if previous is not None and float(previous.get("cm", 0)) > 0:
return float(previous["cm"])
return 0.0
def _non_catalog_ids(space: dict[str, Any]) -> set[str]:
result: set[str] = set()
for name in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
for item in space.get(name) or []:
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]:
result.add(item["id"])
for draft in space.get("room_drafts") or []:
for segment in draft.get("segments") or []:
if isinstance(segment, dict) and isinstance(segment.get("id"), str) and segment["id"]:
result.add(segment["id"])
return result
def _fresh_wall_segment_id(used: set[str]) -> str:
for _attempt in range(1000):
segment_id = f"wall-{uuid.uuid4()}"
if segment_id not in used:
return segment_id
raise WallSegmentMigrationError("duplicate-id", "id factory exhausted")
def _assign_lineage(
space: dict[str, Any], atoms: list[dict[str, Any]], old: dict[str, dict],
initial_migration: bool,
) -> None:
old_by_key = {_span_key(segment["a"], segment["b"]): segment for segment in old.values()}
host_counts: dict[str, int] = {}
for opening in space.get("openings") or []:
host = opening.get("host") if isinstance(opening, dict) else None
if isinstance(host, dict) and host.get("kind") == "wall":
host_counts[str(host.get("id"))] = host_counts.get(str(host.get("id")), 0) + 1
proposals: dict[str, dict[str, Any]] = {}
for atom in atoms:
if len(atom["preferred"]) > 1:
raise WallSegmentMigrationError("duplicate-id", ",".join(sorted(atom["preferred"])))
preferred_id = next(iter(atom["preferred"]), None)
preferred = old.get(preferred_id) if preferred_id else None
if preferred:
proposals[atom["key"]] = preferred
continue
carrier = atom["preferred_carriers"].get(preferred_id) if preferred_id else None
if preferred_id and carrier:
proposals[atom["key"]] = {
"id": preferred_id, "a": carrier["a"], "b": carrier["b"], "cm": 0,
}
continue
if atom["key"] in old_by_key:
proposals[atom["key"]] = old_by_key[atom["key"]]
continue
overlaps = [segment for segment in old.values()
if _collinear_overlap(atom["a"], atom["b"], segment["a"], segment["b"]) > EPS]
overlaps.sort(key=lambda segment: (
-host_counts.get(str(segment["id"]), 0),
-_length(segment["a"], segment["b"]), str(segment["id"]),
))
if overlaps:
proposals[atom["key"]] = overlaps[0]
elif len(atom["positional"]) == 1:
positional = old.get(next(iter(atom["positional"])))
if positional:
proposals[atom["key"]] = positional
by_id: dict[str, list[dict[str, Any]]] = {}
for atom in atoms:
proposal = proposals.get(atom["key"])
if proposal:
by_id.setdefault(str(proposal["id"]), []).append(atom)
for segment_id, candidates in by_id.items():
old_segment = old.get(segment_id) or proposals.get(candidates[0]["key"])
if old_segment is None:
raise WallSegmentMigrationError("duplicate-id", segment_id)
midpoint = [
(old_segment["a"][0] + old_segment["b"][0]) / 2,
(old_segment["a"][1] + old_segment["b"][1]) / 2,
]
candidates.sort(key=lambda atom: (
0 if _distance_to_segment(midpoint, atom["a"], atom["b"]) <= EPS else 1,
0 if _distance_to_segment(old_segment["a"], atom["a"], atom["b"]) <= EPS else 1,
atom["key"],
))
candidates[0]["id"] = segment_id
used = _non_catalog_ids(space)
for atom in atoms:
if not atom.get("id"):
continue
if atom["id"] in used:
raise WallSegmentMigrationError("duplicate-id", atom["id"])
used.add(atom["id"])
unassigned = [atom for atom in atoms if not atom.get("id")]
if initial_migration:
seeds: list[tuple[str, str, str, dict[str, Any]]] = []
for atom in unassigned:
ca, cb = _canonical_span(atom["a"], atom["b"])
seed = (f"{space.get('id', '')}|{_point_key(ca)}|{_point_key(cb)}|"
f"{','.join(sorted(atom['owners']))}")
digest = base64.b32encode(hashlib.sha256(seed.encode()).digest()).decode().lower()
seeds.append((digest, atom["key"], seed, atom))
full_digests: dict[str, str] = {}
for digest, _key, seed, atom in sorted(seeds):
if digest in full_digests and full_digests[digest] != seed:
raise WallSegmentMigrationError("duplicate-id", digest)
full_digests[digest] = seed
base = f"wall-{digest[:20]}"
suffix, segment_id = 1, base
while segment_id in used:
suffix += 1
segment_id = f"{base}-{suffix}"
atom["id"] = segment_id
used.add(segment_id)
else:
for atom in unassigned:
atom["id"] = _fresh_wall_segment_id(used)
used.add(atom["id"])
def _angle_matches(a: list[float], b: list[float], angle: float) -> bool:
dx, dy = _wall_direction(a, b)
wall_angle = math.degrees(math.atan2(dy, dx))
difference = abs((wall_angle - angle + 90) % 180 - 90)
return difference <= 8
def _host_openings(
space: dict[str, Any], segments: list[dict[str, Any]], initial_migration: bool,
) -> None:
for opening in space.get("openings") or []:
host = opening.get("host")
if isinstance(host, dict) and host.get("kind") == "partition":
continue
try:
centre = [float(opening["x"]), float(opening["y"])]
angle, half = float(opening["angle"]), float(opening["length"]) / 2
except (KeyError, TypeError, ValueError):
if initial_migration:
# #316 §3.4: the initial migration never throws over an opening.
opening.pop("host", None)
continue
raise WallSegmentMigrationError("opening-host", str(opening.get("id", ""))) from None
def eligible(segment: dict[str, Any]) -> bool:
if float(segment.get("cm", 0)) <= 0:
return False
t = _project_t(centre, segment["a"], segment["b"])
span = _length(segment["a"], segment["b"])
return (-EPS <= t <= 1 + EPS
and _distance_to_segment(centre, segment["a"], segment["b"])
<= GRID_STEP_N * 0.02
and _angle_matches(segment["a"], segment["b"], angle)
and half >= 0 and t * span - half >= -EPS
and t * span + half <= span + EPS)
def materialize(carrier: dict[str, Any]) -> None:
opening["host"] = {
"kind": "wall", "id": carrier["id"],
"t": max(0.0, min(1.0, _project_t(centre, carrier["a"], carrier["b"]))),
}
current = None
if isinstance(host, dict) and host.get("kind") == "wall":
current = next((segment for segment in segments if segment["id"] == host.get("id")), None)
candidates = [current] if current is not None and eligible(current) else [
segment for segment in segments if eligible(segment)
]
if len(candidates) == 1:
materialize(candidates[0])
continue
# #316 §3.3: an unhosted opening is a valid degraded v9 state. A later
# write keeps it, may self-heal it, and never fails over it.
if host is None and not initial_migration:
continue
if not initial_migration:
raise WallSegmentMigrationError("opening-host", str(opening.get("id", "")))
def pick(pool: list[dict[str, Any]]) -> dict[str, Any] | None:
if not pool:
return None
if current is not None and any(item is current for item in pool):
return current
return sorted(pool, key=lambda segment: (
_distance_to_segment(centre, segment["a"], segment["b"]),
-float(segment.get("cm", 0)),
str(segment.get("id", "")),
))[0]
# #316 §3.2 tie-break. No distant fallback pool (CODE-REVIEW-316-r1
# H1): a host away from the opening's own x/y would violate the
# geometry-match invariant of CONFIG_SCHEMA and wedge the write on the
# schema layer; without an in-place carrier the opening goes straight
# to the unhosted degraded state.
carrier = pick([segment for segment in segments if eligible(segment)])
if carrier is not None:
materialize(carrier)
else:
opening.pop("host", None)
def _migrate_space(space: dict[str, Any], initial_migration: bool) -> int:
old: dict[str, dict[str, Any]] = {}
for segment in space.get("wall_segments") or []:
segment_id = str(segment.get("id", ""))
if not segment_id or segment_id in old:
raise WallSegmentMigrationError("duplicate-id", segment_id)
old[segment_id] = segment
atoms, rooms = _atomize(space, old)
_assign_lineage(space, atoms, old, initial_migration)
segments = []
for atom in atoms:
previous = old.get(atom["id"])
segment = copy.deepcopy(previous) if previous else {}
segment.update({
"id": atom["id"], "a": list(atom["a"]), "b": list(atom["b"]),
"cm": _thickness(space, atom, previous),
})
segments.append(segment)
id_by_key = {atom["key"]: atom["id"] for atom in atoms}
for room in rooms:
room["wall_ids"] = [id_by_key[key] for key in room["wall_ids"]]
space["rooms"] = rooms
space["wall_segments"] = segments
walls = [{
"key": _wall_key(segment["a"], segment["b"]),
"cm": segment["cm"], "a": list(segment["a"]), "b": list(segment["b"]),
} for segment in segments if float(segment["cm"]) > 0]
if walls:
space["walls"] = walls
else:
space.pop("walls", None)
space.pop("open_spans", None)
for room in space.get("rooms") or []:
room.pop("open_to", None)
draft_used = {
str(item["id"])
for name in ("rooms", "openings", "decor", "room_drafts", "partitions",
"wall_columns", "wall_segments")
for item in space.get(name) or []
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]
}
for draft in space.get("room_drafts") or []:
for index, segment in enumerate(draft.get("segments") or []):
if isinstance(segment.get("id"), str) and segment["id"]:
if segment["id"] in draft_used:
raise WallSegmentMigrationError("duplicate-id", segment["id"])
draft_used.add(segment["id"])
continue
try:
a, b = draft["points"][index], draft["points"][index + 1]
except (KeyError, IndexError, TypeError):
raise WallSegmentMigrationError("zero-length", str(draft.get("id", ""))) from None
if initial_migration:
base = deterministic_wall_segment_id(
str(space.get("id", "")), a, b, [f"draft:{draft.get('id', '')}"],
)
suffix, segment["id"] = 1, base
while segment["id"] in draft_used:
suffix += 1
segment["id"] = f"{base}-{suffix}"
else:
segment["id"] = _fresh_wall_segment_id(draft_used)
draft_used.add(segment["id"])
_host_openings(space, segments, initial_migration)
return sum(1 for segment in segments if segment["id"] not in old)
def commit_wall_segment_model(config: Any) -> tuple[Any, int]:
"""Return one migrated deep copy and the count of newly assigned wall ids."""
candidate = canonicalize_config_geometry(copy.deepcopy(config))
if not isinstance(candidate, dict):
raise WallSegmentMigrationError("invalid-room")
migrated = 0
initial_migration = int(candidate.get("model_version", 0) or 0) < WALL_SEGMENT_MODEL_VERSION
for space in candidate.get("spaces") or []:
migrated += _migrate_space(space, initial_migration)
candidate["model_version"] = WALL_SEGMENT_MODEL_VERSION
return canonicalize_config_geometry(candidate), migrated
+10 -107
View File
@@ -24,7 +24,6 @@ from .const import (
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
MAX_SIGN_PATHS,
PLANS_DIR, PLANS_URL,
VERSION,
)
from .coordinate_canonicalization import (
canonicalize_config_geometry,
@@ -61,20 +60,13 @@ from .virtual_lights import (
)
from .registry_snapshot import import_registry_snapshot
from .projection import project_config, project_layout
from .wall_segment_model import (
WALL_SEGMENT_MODEL_VERSION,
WallSegmentMigrationError,
commit_wall_segment_model,
)
from .junction_limits import JunctionLimitError, validate_junction_limits
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
PartitionOpeningHostError, PartitionOpeningJambMarginError,
WallModelClientOutdatedError, sanitize_filename,
PartitionOpeningHostError, PartitionOpeningJambMarginError, sanitize_filename,
validate_opening_passages, validate_partition_opening_hosts,
validate_marker_controls, validate_marker_light_entities,
validate_marker_value_badges, validate_wall_model_transition, valid_space_id,
validate_marker_value_badges, valid_space_id,
)
@@ -1159,9 +1151,6 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
"can_write": may_write(hass, getattr(connection, "user", None)),
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
"undo_kind": _undo_kind(data, layout_data),
# #295: the card compares this against its own version to decide
# whether the «update House Plan» preflight hint can actually help.
"integration_version": VERSION,
},
)
@@ -1270,9 +1259,7 @@ def _missing_internal_attachments(
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/config/set",
# Semantic stale-client detection needs the stored v8 document and
# therefore runs inside the write lock before CONFIG_SCHEMA.
vol.Required("config"): dict,
vol.Required("config"): CONFIG_SCHEMA,
vol.Optional("expected_rev"): int,
}
)
@@ -1321,41 +1308,18 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
# Marker-to-marker light links are a semantic graph layered on top of
# the lossless controls array. Validate only edges introduced by this
# write so an unrelated edit can still round-trip a legacy broken ref.
# #330 §4.1/§4.2: the CPU part of validation runs in the executor —
# schema + semantic validators cost seconds on a large plan and the
# event loop must not carry that (HA guideline: >50 ms). write_lock
# is still held across the await: writes stay serialised, only the
# loop is freed. The junction baseline of the STORED document is
# cached by rev, so a repeated write does not re-judge `previous`.
baseline = rt.junction_baseline
baseline_counts = baseline[1] if baseline and baseline[0] == current_rev else None
def _validate_config_cpu():
validate_wall_model_transition(msg["config"], data.get("config"))
checked = CONFIG_SCHEMA(msg["config"])
msg["config"].clear()
msg["config"].update(checked)
try:
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"))
return validate_junction_limits(
msg["config"], data.get("config"),
baseline_counts=baseline_counts,
)
try:
candidate_counts = await hass.async_add_executor_job(_validate_config_cpu)
except (
JunctionLimitError, MarkerControlError, OpeningPassageError,
PartitionOpeningHostError, PartitionOpeningJambMarginError,
WallModelClientOutdatedError,
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError,
) as err:
connection.send_error(msg["id"], err.code, str(err))
return
except vol.Invalid as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
# An internal plan url must name a file that exists. The card can pick a
# plan and then delete it from the same dialog, and two clients can do
# the same thing in either order — the lock serialises them but says
@@ -1391,9 +1355,6 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
new_rev,
previous_rev=int(current_rev),
)
# The candidate just became the stored document: its counts are the
# next write's baseline (#330 §4.2).
rt.junction_baseline = (int(new_rev), candidate_counts)
try:
await _discard_optimizer_snapshot(rt)
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
@@ -1616,7 +1577,7 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plan/optimize",
vol.Required("config"): dict,
vol.Required("config"): CONFIG_SCHEMA,
vol.Required("layout"): LAYOUT_SCHEMA,
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
@@ -1660,34 +1621,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
# Optimization is a normal configuration write with an additional
# layout transaction. It must enforce the same marker-link semantics
# as config/set; otherwise a crafted client can persist a new cycle.
# #330 §4.1: the same executor treatment as config/set — Optimize
# carries schema + a possible full migration, the costliest CPU path
# of all writers, and it used to run on the event loop.
def _validate_optimize_cpu():
validate_wall_model_transition(msg["config"], config_data.get("config"))
try:
submitted_model = int(msg["config"].get("model_version", 0) or 0)
except (TypeError, ValueError):
submitted_model = WALL_SEGMENT_MODEL_VERSION
# Optimize is an explicit structural writer and therefore the
# server-side v7 -> v8 barrier as well. Current v8 candidates are
# validated verbatim: the backend must never invent lineage for a
# graph already authored by a current client.
candidate_config = CONFIG_SCHEMA(msg["config"])
# Keep the established public validation contract ahead of the
# structural migration barrier. A malformed passage can also make
# wall-host materialisation impossible, but callers must still get
# the actionable `invalid_passage_fields` code rather than the
# generic wall-model blocker.
validate_opening_passages(candidate_config, config_data.get("config"))
if submitted_model < WALL_SEGMENT_MODEL_VERSION:
candidate_config, _ = commit_wall_segment_model(candidate_config)
checked = CONFIG_SCHEMA(candidate_config)
migrated_size = len(json.dumps(checked, separators=(",", ":")))
if migrated_size > MAX_CONFIG_BYTES:
return None, migrated_size
msg["config"].clear()
msg["config"].update(checked)
try:
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"))
@@ -1696,38 +1630,12 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
msg["config"], config_data.get("config"),
allow_optimize_rehost=True,
)
# #333: Optimize is a normal configuration write for the junction
# limits too — an honest client's optimization never ADDS a
# violation (#329 AC10), so this gate is a no-op for legitimate
# flows and refuses only a crafted payload that used this command
# as a side door around config/set. Inheritance is counted per
# rule exactly as in config/set: repairing a legacy plan that
# already carries violations still passes.
return validate_junction_limits(
msg["config"], config_data.get("config"),
), None
try:
optimize_counts, oversize = await hass.async_add_executor_job(
_validate_optimize_cpu,
)
if oversize is not None:
connection.send_error(
msg["id"], "too_large",
f"Configuration is {oversize // 1024} KB, "
f"the limit is {MAX_CONFIG_BYTES // 1024} KB",
)
return
except (
JunctionLimitError, MarkerControlError, OpeningPassageError,
PartitionOpeningHostError, PartitionOpeningJambMarginError,
WallModelClientOutdatedError,
WallSegmentMigrationError,
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError,
) as err:
connection.send_error(msg["id"], err.code, str(err))
return
except vol.Invalid as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
missing = await hass.async_add_executor_job(
_missing_internal_plans,
@@ -1776,11 +1684,6 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
new_config_rev,
previous_rev=config_rev,
)
# The optimized candidate is now the stored document: its junction
# counts are the next write's baseline (#333 AC3, symmetric with
# config/set — otherwise the next save re-judges `previous` for
# nothing and the #330 cache loses its point).
rt.junction_baseline = (int(new_config_rev), optimize_counts or {})
await async_save_layout_state(
rt, layout_data, msg["layout"], new_layout_rev,
metadata={_OPTIMIZE_BACKUP: backup},
+1 -1
View File
@@ -87,7 +87,7 @@ const viewport = { width: 1280, height: 900 };
const { page, browser } = await launch(
viewport, 1,
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
{}, resolve(targetRoot, 'demo/srv'), targetRoot,
{}, resolve(targetRoot, 'demo/srv'),
);
await page.addScriptTag({
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
-169
View File
@@ -1,169 +0,0 @@
/**
* #330 §5: the performance contract of the junction limits (#329).
*
* Guards the CLASS of regression — a quadratic path returning — not runner
* noise: budgets are 2-3x the measured post-fix numbers. Measures the exact
* code the write barrier runs: the pure П1-П5 checks over a 576-atom plan
* (the S2 reproduction grid of #330), for both mirrors. The python half runs
* through a child process with the real custom_components module.
*
* Runs on test-build/ like benchmark:wall-model — no browser, no bundle.
*/
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
checkNodes, checkSegmentLengths, checkNodeDistances, checkRoomClearance,
cmToUnits,
} from '../test-build/junction-limits.js';
import { commitWallSegmentModel } from '../test-build/wall-segment-model.js';
import { GRID_STEP_N } from '../test-build/space-geometry.js';
import {
innerContourForRoom, multiWallNodesForGeometry, wallBodiesGeometry,
} from '../test-build/wall-thickness.js';
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const CELL = 5;
const GRID_N = 12; // 576 contour atoms — the #330 S2 grid
const WARMUPS = 2;
const SAMPLES = 5;
// Budgets are calibrated from the SLOWEST machine observed, not the
// author's: the CI review runner measured tsFullCandidateMs at 169-171 ms
// where the dev sandbox saw 88 (code-review 330-r1 H2). Each budget keeps
// the spec's 2-3x allowance over that worst observation, so the bench turns
// red for the O(n²) class (which costs seconds), not for a slower runner.
const BUDGETS = {
tsSegmentLengthsMs: 60,
tsNodeDistancesMs: 80,
tsFullCandidateMs: 400,
pyWarmValidateMs: 300,
pyColdValidateMs: 5000,
};
const u = (cm) => cmToUnits(cm, CELL, GRID_STEP_N);
const rooms = []; const walls = []; let k = 0;
for (let i = 0; i < GRID_N; i++) {
for (let j = 0; j < GRID_N; j++) {
const x0 = u(310) * i; const y0 = u(310) * j;
const poly = [[x0, y0], [x0 + u(300), y0], [x0 + u(300), y0 + u(300)], [x0, y0 + u(300)]];
rooms.push({ id: `r${i}-${j}`, name: `r${i}${j}`, area: null, poly });
for (let e = 0; e < 4; e++) {
walls.push({ key: `w${k++}`, a: poly[e], b: poly[(e + 1) % 4], cm: 15 });
}
}
}
const legacy = {
spaces: [{
id: 's', title: 's', cell_cm: CELL, view_box: [0, 0, 1, 1],
rooms, walls, openings: [], room_drafts: [], partitions: [], wall_columns: [],
}],
markers: [], settings: {},
};
const { config: v9 } = commitWallSegmentModel(JSON.parse(JSON.stringify(legacy)));
const space = v9.spaces[0];
const segments = (space.wall_segments || []).map((item) => ({
id: item.id, a: item.a, b: item.b, cm: Number(item.cm),
}));
/** The same П1-П5 set the card's write barrier computes for one candidate. */
const fullCandidate = () => {
const violations = [
...checkNodes(segments),
...checkSegmentLengths(segments, CELL, GRID_STEP_N),
...checkNodeDistances(segments, CELL, GRID_STEP_N),
];
// #330 §4.7: shared passes exactly as the card computes them.
const sharedNodes = multiWallNodesForGeometry(
space.rooms || [], space.walls || [], [], GRID_STEP_N, CELL, GRID_STEP_N, 1,
);
let sharedRoomGeometry;
if (sharedNodes.nodes.length) {
const geometry = wallBodiesGeometry(
space.rooms || [], space.walls || [], [], [], GRID_STEP_N, CELL, GRID_STEP_N, 1,
);
sharedRoomGeometry = geometry?.status === 'ok' || geometry?.status === 'degraded-extra'
? geometry.roomGeom : undefined;
}
for (const room of space.rooms || []) {
let inner = null;
try {
inner = innerContourForRoom(
space.rooms || [], String(room.id), space.walls || [], [],
GRID_STEP_N, CELL, GRID_STEP_N, 1, sharedRoomGeometry, sharedNodes,
);
} catch { inner = null; }
violations.push(...checkRoomClearance(String(room.id), inner, CELL, GRID_STEP_N));
}
return violations.length;
};
const median = (fn) => {
for (let i = 0; i < WARMUPS; i++) fn();
const times = [];
for (let i = 0; i < SAMPLES; i++) {
const start = performance.now();
fn();
times.push(performance.now() - start);
}
times.sort((left, right) => left - right);
return times[Math.floor(times.length / 2)];
};
const tsSegmentLengthsMs = median(() => checkSegmentLengths(segments, CELL, GRID_STEP_N));
const tsNodeDistancesMs = median(() => checkNodeDistances(segments, CELL, GRID_STEP_N));
const tsFullCandidateMs = median(fullCandidate);
// Python mirror: cold (legacy both sides) and warm (v9 + baseline counts).
const pyScript = `
import importlib.util, sys, types, os, json, time
root = ${JSON.stringify(REPO_ROOT)}
pr = os.path.join(root, 'custom_components'); hr = os.path.join(pr, 'houseplan')
pkg = types.ModuleType('custom_components'); pkg.__path__ = [pr]; sys.modules['custom_components'] = pkg
p2 = types.ModuleType('custom_components.houseplan'); p2.__path__ = [hr]; sys.modules['custom_components.houseplan'] = p2
spec = importlib.util.spec_from_file_location('custom_components.houseplan.junction_limits', os.path.join(hr, 'junction_limits.py'))
jl = importlib.util.module_from_spec(spec); sys.modules[spec.name] = jl; spec.loader.exec_module(jl)
legacy = json.load(sys.stdin)
from custom_components.houseplan.wall_segment_model import commit_wall_segment_model
v9, _ = commit_wall_segment_model(json.loads(json.dumps(legacy)))
prev = json.loads(json.dumps(v9)); cand = json.loads(json.dumps(v9))
cand['spaces'][0]['rooms'][0]['name'] = 'x'
t0 = time.perf_counter()
jl.validate_junction_limits(json.loads(json.dumps(legacy)), json.loads(json.dumps(legacy)))
cold = 1000 * (time.perf_counter() - t0)
counts = jl.validate_junction_limits(json.loads(json.dumps(cand)), json.loads(json.dumps(prev)))
best = None
for _ in range(3):
t0 = time.perf_counter()
jl.validate_junction_limits(json.loads(json.dumps(cand)), None, baseline_counts=counts)
warm = 1000 * (time.perf_counter() - t0)
best = warm if best is None else min(best, warm)
print(json.dumps({'pyColdValidateMs': cold, 'pyWarmValidateMs': best}))
`;
const python = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
const pyRun = spawnSync(python, ['-c', pyScript], {
input: JSON.stringify(legacy), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
});
if (pyRun.status !== 0) {
console.error('python mirror failed:', (pyRun.stderr || '').slice(-2000));
process.exit(1);
}
const py = JSON.parse(pyRun.stdout.trim().split('\n').pop());
const results = {
tsSegmentLengthsMs, tsNodeDistancesMs, tsFullCandidateMs,
pyWarmValidateMs: py.pyWarmValidateMs, pyColdValidateMs: py.pyColdValidateMs,
};
const pass = Object.entries(BUDGETS).every(([name, budget]) => results[name] <= budget);
console.log(JSON.stringify({
issue: 330,
fixture: { atoms: segments.length, grid: `${GRID_N}x${GRID_N}` },
warmups: WARMUPS, samples: SAMPLES,
results: Object.fromEntries(Object.entries(results).map(
([name, value]) => [name, Math.round(value * 10) / 10],
)),
budgets: BUDGETS,
pass,
}, null, 2));
if (!pass) process.exitCode = 1;
+1 -2
View File
@@ -43,7 +43,6 @@ const { page, browser } = await launch(
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
{},
resolve(targetRoot, 'demo/srv'),
targetRoot,
);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.addStyleTag({
@@ -346,7 +345,7 @@ try {
target: null,
};
card._rszEdgeDown(quietEvent, room.id, 1);
const plan = card._resize?.plan;
const plan = card._rszDrag?.plan;
if (!plan) throw new Error('large-house resize plan was not created');
const target = [
plan.a[0] + plan.n[0] * card._gridPitch,
+4 -27
View File
@@ -1,6 +1,5 @@
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { relative, resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
@@ -14,39 +13,17 @@ const fingerprintForTree = async (root) => {
return module.sourceFingerprint(root);
};
const verifyManifestTree = (root, expected) => {
const assetRoot = resolve(root, 'demo/srv/assets');
const manifestPath = resolve(assetRoot, 'houseplan-assets.json');
// Comparative benchmarks can target releases from before the multi-asset
// contract. Their own embedded fingerprint remains the legacy authority.
if (!existsSync(manifestPath)) return;
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (manifest?.schema !== 1 || manifest.fingerprint !== expected
|| !Array.isArray(manifest.files)) {
throw new Error('demo bundle manifest is stale or malformed; run npm run bundle:sync');
}
for (const file of manifest.files) {
const path = resolve(assetRoot, String(file?.path || ''));
const rel = relative(assetRoot, path);
if (!rel || rel.startsWith('..') || rel.includes(':') || !existsSync(path)) {
throw new Error(`demo bundle manifest asset is missing or unsafe: ${file?.path}`);
}
const hash = createHash('sha256').update(readFileSync(path)).digest('hex');
if (hash !== file.sha256) throw new Error(`demo bundle asset hash mismatch: ${file.path}`);
}
};
/** Refuse measurements/screenshots made by a committed bundle from old source. */
export async function assertFreshDemoBundle(page, root = process.cwd()) {
// A comparative performance run may load an older tree whose fingerprint
// contract is intentionally different from the candidate's. Validate that
// tree with the implementation that built it, not with today's algorithm.
const expected = await fingerprintForTree(root);
verifyManifestTree(root, expected);
const loaded = await page.evaluate(() => globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__ ?? null);
if (loaded !== expected) {
throw new Error(
'demo/srv/assets bundle tree is stale. Run npm run bundle:sync first. '
'demo/srv/assets/houseplan-card.js is stale. Run npm run build and copy '
+ 'dist/houseplan-card.js to demo/srv/assets/houseplan-card.js first. '
+ `Expected ${expected}, loaded ${loaded || 'no fingerprint'}.`,
);
}
+8 -55
View File
@@ -1,13 +1,8 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// #337: a clean CI checkout has no ignored demo bundle. Materialize the whole
// manifest-owned tree before launching Chromium; copying only the stable entry
// leaves every content-hashed import at 404.
import '../../scripts/bundle-sync.mjs';
import { visualFingerprint } from '../../scripts/source-fingerprint.mjs';
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
@@ -16,50 +11,12 @@ 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');
/**
* Перепаковка кадра без потерь (#345).
*
* Замер на этом наборе: 2096 КБ превращаются в 1689 КБ, минус 19.4%, и все
* десять кадров остаются ПИКСЕЛЬНО идентичными — декодированные RGBA совпадают
* по sha256. Это выбор фильтров строки и уровня сжатия, а не квантование:
* визуального решения здесь нет вовсе.
*
* Почему внутри съёмки, а не отдельным проходом по закоммиченным файлам.
* Манифест хранит `imageSha256` каждого кадра, поэтому оптимизировать файлы в
* репозитории руками нельзя — `check-docs` покраснеет; а если жать после
* подсчёта хешей, следующая же съёмка вернёт неоптимизированные байты.
*
* Отсутствие инструмента не ошибка: локальная съёмка и без него полезна для
* глаз, а приёмка всё равно идёт только из артефакта CI, где `oxipng` стоит
* пином (`.github/workflows/docs-screenshots.yml`). Но молчать об этом нельзя —
* байты кадра зависят от того, был ли инструмент, поэтому его версия попадает
* в манифест рядом с версией браузера, по той же причине.
*/
const oxipngVersion = (() => {
const probe = spawnSync('oxipng', ['--version'], { encoding: 'utf8' });
if (probe.status !== 0) {
console.log('oxipng не найден: кадры пишутся как есть, без перепаковки');
return null;
}
return String(probe.stdout || '').trim().split('\n')[0];
})();
/** Пожать файл на месте и вернуть его новые байты. */
const shrinkPng = (path, before) => {
if (!oxipngVersion) return before;
const run = spawnSync('oxipng', ['-o', '4', '--strip', 'safe', '--quiet', path]);
if (run.status !== 0) {
throw new Error(`oxipng не смог обработать ${path}: код ${run.status}`
+ `${run.stderr ? ` · ${run.stderr}` : ''}`);
}
const after = readFileSync(path);
console.log(` ${(before.length / 1024).toFixed(0)} КБ -> ${(after.length / 1024).toFixed(0)} КБ`);
return after;
};
const roomCardClip = (page) => page.evaluate(() => {
const card = window.__goldenCard;
@@ -146,6 +103,8 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
}, scenario);
mkdirSync(OUTPUT, { recursive: true });
copyFileSync(BUNDLE, DEMO_BUNDLE);
copyFileSync(BUNDLE, INTEGRATION_BUNDLE);
const { page, browser } = await launch();
const browserErrors = [];
@@ -170,18 +129,14 @@ try {
const image = await page.screenshot({
...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css',
});
const imagePath = resolve(OUTPUT, scenario.file);
writeFileSync(imagePath, image);
// Хеш считается ПОСЛЕ перепаковки: манифест обязан описывать те байты,
// которые лежат на диске, иначе приёмка отвергнет свой же кандидат.
const stored = shrinkPng(imagePath, image);
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(stored),
imageSha256: sha256(image),
};
console.log(`captured ${scenario.id} -> docs/images/${scenario.file}`);
}
@@ -192,8 +147,6 @@ try {
// Кто снимал. Смена браузера переписывает все картинки без содержательных
// изменений (#246), поэтому окружение съёмки — часть доказательства.
chromium: browser.version(),
// Чем жали — тоже часть доказательства: без инструмента байты другие.
oxipng: oxipngVersion,
sourceFingerprint: fingerprint,
captureScriptSha256: sha256(readFileSync(SCRIPT)),
command: 'npm run build && node demo/docs/capture.mjs',
-86
View File
@@ -1,86 +0,0 @@
/**
* #314 anonymised dense-plan fixture.
*
* It preserves the structural shape of the owner's report without names,
* markers, layout, or original coordinates: 13 rooms, 44 catalogue walls,
* 24 intentional partitions, and one pre-existing two-point draft. The last
* object is known debt and must remain the only invariant finding before and
* after drawing another room.
*/
import { WALL_KEY_PITCH } from './wall-key.mjs';
export function makeV8DraftRegressionFixture() {
const step = WALL_KEY_PITCH;
const point = (x, y) => [x * step, y * step];
const segmentIds = new Map();
const wallSegments = [];
const keyOf = (a, b) => {
const left = `${a[0]},${a[1]}`;
const right = `${b[0]},${b[1]}`;
return left < right ? `${left}|${right}` : `${right}|${left}`;
};
const wallId = (a, b) => {
const key = keyOf(a, b);
let id = segmentIds.get(key);
if (!id) {
id = `wall-314-${String(wallSegments.length + 1).padStart(3, '0')}`;
segmentIds.set(key, id);
wallSegments.push({ id, a: [...a], b: [...b], cm: 15 });
}
return id;
};
const rooms = Array.from({ length: 13 }, (_, index) => {
const x0 = 4 + index * 12;
const x1 = x0 + 12;
const y0 = 4;
const y1 = 16;
const poly = [point(x0, y0), point(x1, y0), point(x1, y1)];
// Four split top edges make the catalogue population exactly 44 while
// keeping all rooms valid and non-overlapping.
if (index < 4) poly.push(point(x0 + 6, y1));
poly.push(point(x0, y1));
return {
id: `room-314-${String(index + 1).padStart(2, '0')}`,
name: `Room ${index + 1}`,
poly,
wall_ids: poly.map((a, edge) => wallId(a, poly[(edge + 1) % poly.length])),
};
});
const partitions = Array.from({ length: 24 }, (_, index) => {
const row = Math.floor(index / 8);
const x = 4 + (index % 8) * 18;
const y = 28 + row * 8;
return {
id: `partition-314-${String(index + 1).padStart(2, '0')}`,
a: point(x, y),
b: point(x + 8, y + 2),
cm: 15 + (index % 3) * 5,
};
});
if (wallSegments.length !== 44) {
throw new Error(`fixture catalogue drifted: expected 44, got ${wallSegments.length}`);
}
return {
model_version: 8,
spaces: [{
id: 'space-314-dense',
title: 'Dense regression fixture',
cell_cm: 5,
view_box: [0, 0, 400, 400],
rooms,
wall_segments: wallSegments,
partitions,
room_drafts: [{
id: 'draft-314-known-debt',
points: [point(4, 56), point(14, 64)],
segments: [{ id: 'wall-314-known-draft', cm: 15 }],
}],
}],
markers: [],
settings: {},
};
}
+5 -59
View File
@@ -28,20 +28,6 @@ radial spokes visible instead of hiding them under a translucent room fill.
- `accept` requires `--reviewed`, a complete candidate report and current
source fingerprint. It validates the whole set before copying anything and
is the only command allowed to update baselines.
- `scripts/golden-accept.mjs` wraps `accept` and additionally requires the
reviewer to declare intent, with two separate flags because they assert two
different things. `--expect-change=<id,id>` means "I know why this existing
baseline moved"; `--expect-new=<id,id>` means "I have looked at this new
frame". Anything that differs, or arrives without a baseline, and is not named
refuses the whole acceptance before a single file is copied; naming a scenario
under the wrong flag refuses it too. Only the named scenarios are written:
everything else keeps its reviewed bytes and its manifest hash, because
`passed` means "within threshold", not "byte-identical", and copying every
candidate let sub-threshold drift ratchet the baselines to the newest
environment unseen (#351). The first flag is what makes a local
capture admissible (see below) and blocks the one-command "accept everything so
CI turns green"; the second stops an empty or clipped frame from becoming the
contract unseen (#350).
## Workflow
@@ -57,57 +43,17 @@ Review `artifacts/golden/actual/` and, when existing references are present,
`artifacts/golden/diff/`. If every image is intentional:
```bash
node scripts/golden-accept.mjs --reviewed --expect-change=wall-junctions-plan-t-dark
npm run golden:accept -- --reviewed
npm run golden:verify
```
Never accept images merely to make CI green. A matrix/framing change increments
`GOLDEN_MATRIX_VERSION`; a normal rendering fix does not. The first canonical
Linux baseline was reviewed and accepted during the v1.60.3-beta.1 gate.
## Capturing candidates without a second CI round trip (#334)
Accepting from the `golden-images` CI artifact still works and is still the
safest route: unpack it and pass `--from=...`. It costs two full CI runs per
visual fix, though — one to produce the artifact and one to verify the accepted
baseline — and at matrix version 48 that toll is paid often.
A local capture is admissible instead, because admissibility is now *proved*
rather than assumed. Desktop font rasterisation can differ from the runner, but
it cannot differ quietly: it would move every text-bearing scenario, not only
the ones under edit. So the rule is simply that the capture must reproduce every
accepted baseline the reviewer did not intend to change:
```bash
node scripts/golden-container.mjs # capture in the pinned image
node scripts/golden-accept.mjs --reviewed --expect-change=<the scenarios you changed>
```
If the environment is not pixel-equivalent, unrelated scenarios come out
`different`, the wrapper names them and refuses. A wrong container tag or a
mismatched font set therefore cannot corrupt baselines — it can only fail.
`scripts/golden-container.mjs` derives the image tag from the `playwright`
version locked in `package-lock.json`, so the container Chromium equals the
runner's; `--image=` overrides it when a distro-specific tag is needed
(`...:v1.62.0-jammy`). The host `node_modules` is shadowed by an anonymous
volume: the repository copy may be built for Windows, and `npm ci` inside the
container would otherwise replace it with Linux binaries. The run does write
`dist/` and the three bundle copies, exactly as a local `npm run bundle:sync`
would.
Docker is not a requirement of the rule, only a convenience: any Linux
environment that satisfies the parity condition qualifies, WSL included. The
`chromium` string recorded in the manifest keeps the browser build itself
pinned, and `golden:verify` rejects a manifest captured by a different build.
`scripts/golden-accept.mjs` deliberately wraps `demo/golden/accept.mjs` instead
of replacing its checks: every `.mjs` under `demo/golden` belongs to
`sourceFingerprint`, so editing the acceptance tool itself would declare the
committed bundle, the documentation screenshot manifest and the baseline
manifest stale — the very double round trip this change removes. Narrowing that
corpus is worthwhile but separate: `scripts/source-fingerprint.mjs` is itself a
build input, so any change to it forces one bundle rebuild.
Future updates must still use the `golden-images` artifact produced by the Linux
CI job as the review set: desktop font rasterisation can differ from the CI
environment even with the same pinned Chromium. Pass its unpacked root via
`--from=...` when accepting it locally.
Scenarios may also declare a semantic pixel region (for example, a receiving
room that must contain warm light). `golden:capture` and `golden:verify` reject
+11 -50
View File
@@ -6,21 +6,11 @@ import { fileURLToPath } from 'node:url';
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
import { GOLDEN_BASELINE_MANIFEST } from './policy.mjs';
import {
goldenAcceptancePlan, goldenAcceptanceRefusal, goldenSilentDeclarations,
} from '../../scripts/golden-acceptance.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const reviewed = process.argv.includes('--reviewed');
const fromArg = process.argv.find((arg) => arg.startsWith('--from='));
const from = resolve(fromArg ? fromArg.slice('--from='.length) : resolve(ROOT, 'artifacts/golden'));
const list = (name) => {
const found = process.argv.find((arg) => arg.startsWith(`--${name}=`));
return (found ? found.slice(name.length + 3) : '')
.split(',').map((id) => id.trim()).filter(Boolean);
};
const declared = list('expect-change');
const declaredNew = list('expect-new');
if (!reviewed) throw new Error('refusing to replace baselines without explicit --reviewed');
const reportPath = resolve(from, 'golden-report.json');
@@ -34,29 +24,11 @@ if (typeof report.chromium !== 'string' || !report.chromium)
throw new Error('candidate report does not identify its Chromium build');
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
const refusal = goldenAcceptanceRefusal(report.results, declared, declaredNew);
if (refusal) throw new Error(refusal);
const byId = new Map(report.results.map((result) => [result.id, result]));
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
mkdirSync(baselineRoot, { recursive: true });
/**
* Прежний индекс: источник хешей для сцен, которые остаются как были (#351).
*
* `passed` не значит «байт в байт» — он значит «в пределах порога». Прежняя
* версия копировала кандидата поверх КАЖДОГО эталона, поэтому подпороговый
* дрейф уезжал в контракт молча, и накапливался: каждая приёмка подтягивала
* эталон к последней среде, порог не пересекался никогда, а эталон уходил.
* Так `1e341c60` заменил 22 картинки, объявив четыре. Владелец делал эту работу
* руками (`ad3f9981`: «nine unrelated baselines … were restored to their
* reviewed versions»); теперь её делает инструмент.
*/
const manifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST);
const previous = existsSync(manifestPath)
? JSON.parse(readFileSync(manifestPath, 'utf8')).scenarios || {}
: {};
// Кандидат проверяется целиком, до всякого решения о замене: сломанный отчёт
// не имеет права оставить каталог эталонов половинным.
const hashes = {};
const candidates = [];
for (const scenario of GOLDEN_SCENARIOS) {
const result = byId.get(scenario.id);
const candidate = resolve(from, 'actual', `${scenario.id}.png`);
@@ -64,20 +36,16 @@ for (const scenario of GOLDEN_SCENARIOS) {
throw new Error(`review candidate has an invalid run status: ${scenario.id} (${result?.status || 'missing'})`);
if (!result?.actualSha256 || !existsSync(candidate))
throw new Error(`review candidate missing: ${scenario.id}`);
const digest = createHash('sha256').update(readFileSync(candidate)).digest('hex');
const bytes = readFileSync(candidate);
const digest = createHash('sha256').update(bytes).digest('hex');
if (digest !== result.actualSha256) throw new Error(`candidate changed after capture: ${scenario.id}`);
candidates.push({ scenario, candidate });
hashes[scenario.id] = digest;
}
const plan = goldenAcceptancePlan({
scenarioIds: GOLDEN_SCENARIOS.map((scenario) => scenario.id),
results: report.results,
previousHashes: previous,
declared,
declaredNew,
});
const hashes = plan.hashes;
for (const id of plan.replace) {
copyFileSync(resolve(from, 'actual', `${id}.png`), resolve(baselineRoot, `${id}.png`));
}
// Validate the complete set first: a broken report must never leave a half-
// updated baseline directory behind.
for (const { scenario, candidate } of candidates)
copyFileSync(candidate, resolve(baselineRoot, `${scenario.id}.png`));
writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify({
schema: 1,
matrixVersion: GOLDEN_MATRIX_VERSION,
@@ -86,11 +54,4 @@ writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify
chromium: report.chromium,
scenarios: hashes,
}, null, 2)}\n`, 'utf8');
const silent = goldenSilentDeclarations(report.results, declared);
if (silent.length) {
console.log(`Объявлены как изменённые, но совпали с эталоном: ${silent.join(', ')}.`);
}
console.log(`Заменено эталонов: ${plan.replace.length}`
+ `${plan.replace.length ? ` (${[...plan.replace].sort().join(', ')})` : ''}.`);
console.log(`Сохранено без изменений: ${plan.keep.length}.`);
console.log(`Индекс перезаписан на ${GOLDEN_SCENARIOS.length} сцен.`);
console.log(`Accepted ${GOLDEN_SCENARIOS.length} reviewed golden baselines.`);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

+107 -114
View File
@@ -1,145 +1,138 @@
{
"schema": 1,
"matrixVersion": 49,
"acceptedAt": "2026-08-28T09:41:50.717Z",
"sourceFingerprint": "b6d9eb672c772bca7fa08bf25b4525cc8bbf3084320f383f16ab86a535090444",
"matrixVersion": 46,
"acceptedAt": "2026-08-25T20:12:50.448Z",
"sourceFingerprint": "e28606975682347f90f12971053825ce474085cbddd56082e867370b1039d14d",
"chromium": "151.0.7922.34",
"scenarios": {
"hidden-wall-diagnostics-plan-light": "37ba151aa6b1ea7b1b270d461ccca348444c5eeb14a5592bf045a0e69b0fb83e",
"hidden-wall-diagnostics-plan-dark": "565d5481f744704a2a6f18658b9b6fa65e7cdbfcd11b50a2bedbc0daae2d8488",
"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",
"span-over-door-migrated-dark": "a817f5baacf5ff80d1f236ffaa176cb6331b75423393dae5b16e54f67f37c7e0",
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "48062a49e12728c377a7c4352148381b1be2a5606b6792d754ae055b04c22be1",
"split-corner-wall-thick-dark": "a5d795eec4561e3a1aa9c37843fadddcb04d888d5a49e6ffacf29c17b7798568",
"split-corner-wall-thin-dark": "17e49394c300887bb5349bf477292571e96e209c9515db6853348883d95a1422",
"split-corner-wall-thick-dark": "f7864a6b2ab58586540f836183c9e59f09f9f57165ec0e0b9cd8444ada71153c",
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
"isometric-geometry-view-dark": "0d8427299c77e1e3bb86f37f53128a6640b6666e0b682e5f96085c4f07a657ec",
"isometric-geometry-view-light": "a3e592448ad3ad4ff65266f1fd6f31259c01a9baf47392f6b8be9811d74f8dce",
"isometric-live-layers-dark": "90fa1c0b64a12626e481c5f34e515c53b3e06cd657f5976bb8a72127aec3e1e9",
"isometric-no-borders-dark": "a0abf9fdc31092980de3bcbf8598844b6e3d152363694e9bd070d45af73c26d9",
"isometric-touch-kiosk-dark": "d3ae363165c4f0aba65bcbac9bd6bdca79f29aca03bba997f3abec1860eb1e55",
"isometric-large-warm-remount-dark": "299401bd4b499876d40678593308b42ac8aa4d3328113f27ad5940fc3621184a",
"geometry-view-dark-fit": "2d96e776ea9fc8a136896b58df30abb13a8a53e6c40e5b4f4e12c6dba522380f",
"geometry-view-light-fit": "60e8a908fc1c3be3ca5c9b82bf432120d808d913f743c04683b4a2c501d84659",
"room-label-parity-view-dark": "b35675251b85144bca1bc15ed7bf27a4dd1eaa0bfc288e3e3b0d1cdefac79ae5",
"room-label-parity-plan-dark": "34c1010481e2799b8598e3c6f659ca7ec8e695a064eecacf3562000639f44d6a",
"room-label-parity-view-light": "789f8f3b6211d35e70021094f2063dc9c6ac57599d9d64743a33b7ae9dd868c2",
"room-label-parity-plan-light": "272dc35d85b1f07efc4c2a05c31f6adbf7d10c648e3c678e7235e35167f83a7d",
"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": "be3b891520637857b7f89dc88284753a15601ebae4db7db750714b9535348d33",
"day-cycle-day-dark": "1b791b8a773a5910a71338351ab12fdc36c22742a358b647ff4f295431fa4cfd",
"day-cycle-dusk-dark": "14182ad89301cb260ba21ee5077134ed408f038ea878ed4539b84f987182f910",
"day-cycle-night-dark": "407632fc54e13e6362b7e583d6e2b7bdd31b4093823846e4e630de0c8f542120",
"geometry-plan-editor-dark": "40a0083511c1ff9d0b8f1ec419af63e2a28f1f9a0ec3768387a10c89efe73905",
"safe-resize-handles-clamp-light": "f47a5bb09fa4415416d01ca5f4164014cc1d8dc7be54ea888e5399277104e94c",
"safe-resize-handles-clamp-dark": "3efd0ca2db8a06aa475e81e3635b878c3038d4763f840e6e856661b04a4fed96",
"space-tab-drop-before-light": "7024186821b13feb63ba254aee841379788bca1a5c01bfd07c42e15fe6b81179",
"space-tab-drop-after-dark": "5a2199e8b8364da9e85e0887334144e426f43b8022f2918bf130ea13cd099f5d",
"plan-snap-endpoint-light": "2185d5ec6726e07097ba1a911984a11d5ca9b460a1cd1e7b2a719255b7d0ed0b",
"plan-snap-line-gaps-dark": "276eb10f32be938640bd68a1cf837494f11cce8d10703721831360f3a1100d23",
"wall-junctions-plan-preview-light": "965860eb4302b60864b5e3191514c60b97bbcd6073960c437a973b4a880ecddb",
"wall-junctions-plan-t-dark": "258869fb3c5af9a223d63518bc6127bfde78c2c039d39583a965d68b225338ba",
"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": "90acdf0658772c2883e775dd6972141ad699e165afcd9bf55382fbd99c203717",
"junction-patch-resilience-view-dark": "afd83241d9a1e1f19ab59c3f1e6fa7af058c3e42a7fafa5ba271ff92ccea8f91",
"wall-union-isolation-view-light": "106f8ac97b82107fed36aa4456c0b9f7a39eeb44be86864b1d4f083497cdbd6e",
"wall-union-isolation-view-dark": "914451a813546189bdc9ac22dcf158bbc822b4403080a3c2fd4c6b02bb7780a4",
"multiwall-junction-bevel-view-dark": "b80c4584a649722897c206d242a9df88d80169806a73db579b45595173b698b8",
"orthogonal-strip-cell-5-view-dark": "66a288f7e7fc6228c3c63f10e1df8df0656ca0cd36681d998b126b03acb70b5a",
"orthogonal-strip-cell-1-view-dark": "c23e6d0410b8f5c2b7fb0707f6bea40408730af08f9e7a0b166aa8c0a3d6c85f",
"wall-key-roundtrip-view-dark": "d7b0749d0f6c0cfa74ad4d9f76d8a7b7962fadf4e2648260b5647548a988341c",
"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": "c21c2af812b847e7fd9a3a5b9df832db95266f81eba3d7b48c97d2b32619e7fe",
"opening-placement-passage-thick-wall-dark": "90f9a837898169c2d9ff9ec3b44b2a3d247ea034fd84b1753519da7b0c89ebdb",
"opening-placement-passage-thick-wall-light": "dd17884027d85f5727450cd08f3fe6daaed290770dbd04fe5ce10c4c2b9d43db",
"opening-symbol-room-wall-light": "403f1c6e1c59122331fbe99019a3ebd80de4106dc6d33f5f5f33feb748229645",
"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": "6d780d1d06909e6e5a7ebb35987b9681d6902d78408e974dfe2fe587521990ce",
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
"geometry-devices-editor-dark": "acc779a2a21d5c2a26f40d1a23495cafaffc460c57c1100660fb3cde554303f1",
"device-inbox-desktop-en-light": "c04254626f613b11e1b9165abf6bfad4b04a2670acab2887e38062d7d16b4533",
"device-inbox-desktop-ru-dark": "2e7e7175ed770fea657828beda68b62d6eb657a4b72222b1977df3d7d692b0d6",
"device-inbox-narrow-ru-dark": "5263f518d4a415caedbb7917c79f9acbb03de234feec11e3128c235d2bd5b39f",
"geometry-decor-editor-dark": "2a0d4368e49b51fc4fcf5ee72679efb7b7e094fd8b6f47682b0696ec909c423e",
"tray-wide-selection-en": "056f3b54d60bba83ef313f9c12fe697ea6a0b3d48db0470735394ee0d3f72c39",
"tray-wide-tool-ru": "f8e050c96cb3e78f1ab8d0b8b276b3d154950e795c5b620148b9e395330cffe9",
"tray-medium-group-en": "4513abc13f1bc24e4d04f14445069c8ce9785dc3fddcdc99bcee41b5eadb8d53",
"tray-medium-selection-ru": "bd97bee66778a65ebb849331724a307d0b089d6cdf5f3b85c6c905c413d5290f",
"tray-narrow-palette-en": "10073be9fdf658275f77643505c3523bcddce6a1890deda5b1820958b90613d1",
"tray-narrow-tool-ru": "95fa0f072efb595e1f9fe3f3287e7ce346fdee640e90dd70e4f5e5b31049ef29",
"geometry-diagonal-45-opening-dark": "ba9462322da34b20fff1ee1f950c516855dd7ac56131e13863f576e2d0ba5177",
"openings-thick-wall-dark": "539cdb9cb1460cd42c2e51901ea50f496e8214d02633e110e2780c3f177154db",
"openings-filled-tunnel-dark": "bb2a4343a285e2fd867c6090108589549facf87b1a9471f3ae331d743af6653c",
"decor-over-opaque-hover-light": "eae79026256367a4cfaa5f99393ec9c66dbf23908c50ccc0f541c139d1baeda0",
"decor-over-glow-base-dark": "72a83f08f832c219d8e0e1b6f94d689c250342aa95d30e8c1f79cf9f9548ccab",
"openings-hidden-view-dark": "19ab4fd2ffce0fea0fe609b007a3e60f7abd84b40935cbf3c5fa9dc1fda21046",
"lighting-glow-sun-dark": "08e2964df6fd03abad4b9a81f1dbd0fe3812fb6b5f7908aeb027ad2953b2f88c",
"device-value-badge-positions-dark": "75ab0210d697f952b8a8977649e84308de3ce3b59c84ec93071179a1d6cb015d",
"device-icon-state-table-light": "85c785824ccafc2d5f50e834ad2d746319d64ecb292d1b9cf7fab8661d5d410d",
"device-icon-state-table-dark": "03a90523132c3dbe6c78d44d01fe89df93e2d0474f91ef84e7107e6e4bc7d337",
"device-text-shell-long-light": "97a113b5164f373b4af2922c8e71bc1f063d8c2761b72c3182697fcb1aac3cab",
"device-text-shell-long-dark": "c8fe32aced704c7e7562f0c8499183fad848a977063ab731401cc01e7f20ef38",
"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": "e45d44496c83ad92f8d5bd058821e6d71c97c48a2b66c1933206535aea2655c1",
"lighting-fill-temp-axis-split-dark": "895e1e0beee38bf659b1313ed9951353a7792d2e8ae3a8620c4cf7a4f6a84a4d",
"lighting-fill-lqi-axis-split-dark": "4a029b17419aaf60feab23f7f0cb385d97353e3aec0e39ed67e9ac94f6c08029",
"lighting-temp-glow-dark": "3ce504d83fda2ba5d772c25f8cbb47c379b335addd8650f6933b58f22f5655a7",
"lighting-temp-glow-light": "e9f2db0a686a9e20bf71564c10bab826a2a473c73d572a6b67ef02a735d61e8e",
"lighting-custom-glow-dark": "94cc6135573aa9462de9e7f309dea62e236668b84970bae35d48f1aad338f8f4",
"lighting-opaque-glow-two-doorways-dark": "ee3c53a408f6b62aa6b770925fa6948934e0c1c6e73877e379695bc260b0ceac",
"lighting-custom-glow-light": "aa9477aaff4d137abfcb6d67a5cf62b91f41b3631383ee8edb7499802a5c9b87",
"lighting-temp-glow-no-sources-dark": "cd992af54d1c9dcc58bbd0b21b15b475be6f19eaabcf9ea866bfa82f205460c9",
"lighting-temp-glow-room-override-dark": "66a418829b51379d024c542f8b9a12bd42d72afa81a6bf66561d6498ac4cdb45",
"lighting-manual-auto-spill-overlap-dark": "0dc780335317d6dd577c8d5539f33223228074e1c95e936e4666f50a8db09739",
"hover-over-glow-dark": "49136f6073662482ebf49a28f8f22b698d2c20a9cb64b5667d111b9835717968",
"hover-nested-room-dark": "b546f923fd31d28d2881f074e3a67c5d9d3f9450169f9118502ecafc98c4467d",
"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": "49192516aabc14e84a2da4d1361c1be57d24e5c947b199cdf8b6c55fd7318504",
"junction-star5-equal15-dark": "2fd822f73f486173700405208168300d27d4e360b17cb278c7ee7bc1cda3769e",
"junction-y-60-equal50-dark": "7a61dbfc36437674c2c2bf5f450a64797e21a124f42c0e45bfb56e3236e0b53e",
"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": "e7e657137e8dcf6294d400f3114497318c055405dbc0d867603514b56c42b907",
"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",
"sharp-apex-legacy-dark": "93a46c66f7263a0325d0c4d50fa0677f3c902647ea5880960b99dc8fa04a01a5",
"junction-owner-repro-dark": "f5407d420449ebf897f94fcfcdaaf4630e818c043d3076b090f7d1944df5296b",
"large-house-zoom-040-dark": "736af1c340646c4c1cbab3d2270f67a535995e1ea1e2abe49c91d2c396052097",
"large-house-zoom-250-dark": "0a74099e265b38f376bb7bfd86d4a57c5d493ac3870478a7d55c48cf99e8f892",
"large-house-warm-remount-dark": "1c14dba8cdbc4628984091d395c1ac14faead1d43338e44aa3cb57976e519df6",
"device-dialog-desktop-en": "e142521eae1bd40861c1d92eadbcfd6d88aefa689cd5ee9b4c1b6378c8ce712c",
"device-dialog-mobile-ru": "7921b7d5e2c082fc4dd5053607a8228c2e63e0e72a401d86153effd54c2c6ca0",
"device-dialog-desktop-de": "ac3f657e072006f5c1686d89da61df922991a04f69d2d7994a03d1ba93532681",
"german-view-mobile-light": "9496071b11f510f1c851a9c60583a96bfe768c1fd1e12a2b1b9183dee7e453a8",
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
"toggle-entity-dialog-mobile-ru": "77e8fc03817a784888993490cdfba675482b8c56483199c1efa512db8531516b",
"device-help-popover-light-ru": "259a50065b7f09d07db656b6cfe79d1de34397665ff8dd8a8e361f6f29a7ea6a",
"decor-color-popover-mobile-ru": "51725859afc6a1e2d1b2ae8be270ab584608901623ef7e2d283b6b195644fd16",
"decor-color-popover-desktop-en": "98d769f5393afb2bad3fb5143a562da5b8bb9a5c8049f4c707706bbcb4f99f8d",
"general-color-popover-desktop-en": "008865918113e05847a7ad8ae0fdc068b5326acf1c591d855806b333084978a3",
"device-ripple-color-popover-mobile-ru": "4f2365818f46fe1e2b401e5835f5d29ea9c41b288d894025d7512db92f579762",
"space-room-color-popover-desktop-ru": "946c550232ff3819bb44058eff6c5deadfccf85527c23065137ee02fa457aed9",
"backup-full-preview-desktop-en": "6b68188269fdbaddaf4f2ce8fc0d10ea41e050f0c08cdbd9507548871a3786ef",
"backup-plan-only-export-desktop-en": "805fb7cd15959a7eee0b17150c4417e335f1b1030a438f7051c2b006a5f7d4f4",
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
"optimize-preflight-dialog-dark-en": "0473d7f16dd830aaddbec5c33f2b423fde48e2053a1094dbab052e6a2cbad9d4",
"optimize-preflight-dialog-light-ru": "a4eb6ef9930652bd7e55fbb725fd1bcc5a0e5b71e8060dbf6f772dd62cec5d36",
"optimize-orphan-references-dark-en": "f40417298e126fa804ddafb91d62b728c86a56c5a28b4fad12b732070faa4ad6",
"optimize-orphan-references-light-ru": "2d0319f681cfbe472ad5dede05d145a995dedee577d8d816e2fe46a519e019f5",
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
"junction-309-hump-dark": "abae96fd96ed8515d7b1e33b84858bdf08ac28a888898549874baa8c98202e7a"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 347 KiB

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

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

After

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

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: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

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