Compare commits

..
Author SHA1 Message Date
claude[bot] 814dd82db3 docs: review document for #204
Issue: #204
User-Visible: no
2026-08-19 17:44:44 +00:00
Sergey Matyunin 12c4f7784b fix: show honest new-space display defaults
Issue: #204
User-Visible: yes
2026-08-19 20:35:36 +03:00
claude[bot] ec0ccaffba docs: review document for #204
Issue: #204
User-Visible: no
2026-08-19 17:19:39 +00:00
Sergey Matyunin f7032428d2 docs: specify space creation display defaults
Issue: #204
User-Visible: no
2026-08-19 20:11:53 +03:00
452 changed files with 4020 additions and 56210 deletions
-84
View File
@@ -1,84 +0,0 @@
# Скриншоты документации снимаются здесь и только здесь (#246).
#
# Съёмка на машине исполнителя даёт байтово разный PNG при одинаковом кадре:
# сглаживание и хинтинг зависят от окружения. Измерено на истории — пересъёмка
# в #231 изменила два файла из девяти на 7–8 байт, набор с беты все девять
# целиком. Одно окружение убирает этот шум насовсем.
#
# Джоба ничего не коммитит: она публикует артефакт, который человек принимает
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
# попадают в репозиторий через явное решение, а не через бота.
name: Docs screenshots
on:
workflow_dispatch:
inputs:
ref:
description: Ветка или SHA, с которого снимать
required: false
default: dev
permissions:
contents: read
jobs:
capture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
# Тот же кэш и тот же отказ от --with-deps, что в smoke/golden (#175, #206):
# системные библиотеки Chromium уже в образе раннера.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install pinned Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install chromium
- name: Build the bundle the screenshots must come from
run: npm run build
- name: Capture
run: node demo/docs/capture.mjs
# Вердикт до всякой приёмки. Само число изменившихся файлов ничего не
# говорит: набор, снятый другим браузером, меняет их все, и это нормально
# ровно один раз — при переходе на канонический прогон. Сравнивать надо
# браузер: тот же Chromium и десять изменившихся картинок означают, что
# изменился продукт (или что-то не так), другой Chromium — ожидаемую
# разницу рендеринга.
- name: Вердикт
run: |
git status --porcelain docs/images
changed=$(git diff --name-only docs/images | grep -c png || true)
before=$(git show HEAD:docs/images/screenshots.json | node -e \
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).chromium||'')}catch{console.log('')}})")
after=$(node -e "console.log(require('./docs/images/screenshots.json').chromium)")
echo "--- изменившихся PNG: $changed"
echo "--- Chromium: было «${before:-не записан}», стало «$after»"
if [ "$before" = "$after" ] && [ "$changed" -gt 0 ]; then
echo "ВЕРДИКТ: тот же браузер, а картинки изменились — изменился продукт."
echo "Смотрите на кадры: если изменение ожидаемое, принимайте."
elif [ "$before" != "$after" ]; then
echo "ВЕРДИКТ: браузер другой, поэтому переписаны все кадры сразу."
echo "Это ожидаемо один раз — при переходе на канонический прогон."
echo "Если Chromium сменился неожиданно, сверьте закреплённую версию в package-lock."
else
echo "ВЕРДИКТ: ничего не изменилось, принимать нечего."
fi
- name: Upload candidate
uses: actions/upload-artifact@v7
with:
name: docs-screenshots
path: |
docs/images/*.png
docs/images/screenshots.json
if-no-files-found: error
+30 -158
View File
@@ -39,7 +39,6 @@ jobs:
outputs:
stage: ${{ steps.decide.outputs.stage }}
cycle: ${{ steps.decide.outputs.cycle }}
spent: ${{ steps.decide.outputs.spent }}
limit: ${{ steps.decide.outputs.limit }}
steps:
- id: decide
@@ -64,41 +63,19 @@ jobs:
limit=4
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
# Считаются ДВЕ РАЗНЫЕ величины, и это не педантизм (#227).
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
# первое код-ревью получило r2/4. На задаче с двумя циклами ТЗ второе
# код-ревью упиралось бы в review-4 после одной правки.
#
# `attempt` — сколько раз ревью уже отработало на этом этапе. Он нужен
# только для имени документа и метки: два захода с одинаковым номером
# означают, что второй документ перезапишет первый и артефакт ревью
# исчезнет.
#
# `spent` — сколько циклов израсходовано из бюджета §4. Цикл — это
# «отправка на ревью → вердикт с блокирующими находками → возврат
# автору», поэтому бюджет тратят ТОЛЬКО жёлтые и красные вердикты.
# Зелёный ничего на правки не вернул и цикла не образует.
#
# Раньше обе роли исполнял один счётчик всех вердиктов, и конвейер
# наказывал за то, что предписывал сам: при неудавшемся слиянии он
# велит вернуть S7-code-review после ребейза, и этот заход добивал
# бюджет. На #225 (лёгкий трек, лимит 2) последовательность
# жёлтый → зелёный → ребейз дала review-4 на задаче с зелёным ревью и
# зелёным CI: работа встала, хотя после вердикта не было ни одной
# правки продуктового кода.
#
# Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал
# цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по
# имени документа в теле комментария; документа нет — вердикт не
# посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы
# работу досрочно: из двух ошибок выбрана обратимая.
attempt=1; spent=0; spent_list=""
# Этап опознаётся по имени документа в теле комментария. Если документа
# нет, вердикт не посчитается — недосчёт даёт лишний цикл, а перерасчёт
# остановил бы работу досрочно; из двух ошибок выбрана обратимая.
done_cycles=0
if [ -n "$stage" ]; then
comments=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json comments)
of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))]"
# Блокирующим считается вердикт, у которого в строке вердикта стоит
# «жёлтый» или «красный». Регистр и окружение слова не важны.
blocking="$of_stage | map(select(.body | test(\"Вердикт:[^\\n]*(жёлт|красн)\"; \"i\")))"
attempt=$(( $(printf '%s' "$comments" | jq -r "$of_stage | length") + 1 ))
spent=$(printf '%s' "$comments" | jq -r "$blocking | length")
spent_list=$(printf '%s' "$comments" | jq -r "$blocking | map(\"- \" + .url) | join(\"\\n\")")
done_cycles=$(gh issue view "$NUM" --repo "${{ github.repository }}" \
--json comments \
-q "[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))] | length")
fi
# Отказ обязан быть виден в issue, а не только в логе прогона.
@@ -132,33 +109,19 @@ jobs:
refuse "стоит blocked — конвейер не запускается" \
"на issue стоит \`blocked\` — задача ждёт внешнего решения. Снять метку, когда решение принято."
elif [ "$EXHAUSTED" = "true" ]; then
# Метку снимает владелец, а не конвейер: автоматика, отменяющая
# остановку работы, дороже ручного снятия. Но пересчёт печатается —
# метка могла остаться от прежнего правила, когда бюджет тратил и
# зелёный вердикт (#227).
stale=""
if [ "$spent" -lt "$limit" ]; then
stale=" Пересчёт по действующему правилу: блокирующих циклов $spent из $limit — метка могла остаться от прежнего правила, когда бюджет тратил любой вердикт. Снять её может владелец."
fi
refuse "стоит review-4 — решение за владельцем" \
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4).$stale"
elif [ "$spent" -ge "$limit" ]; then
echo "блокирующих циклов этапа $stage: $spent из $limit — лимит исчерпан"
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
elif [ "$done_cycles" -ge "$limit" ]; then
echo "циклов этапа $stage пройдено $done_cycles из $limit — лимит исчерпан"
gh issue edit "$NUM" --repo "${{ github.repository }}" --add-label review-4
# Перечень учтённого обязателен: иначе владельцу приходится читать
# всю ленту, чтобы понять, из чего сложился счёт.
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
"Лимит циклов ревью исчерпан: блокирующих циклов $spent из $limit на этапе \`$stage\` (заход $attempt). Следующего захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4).
Учтены вердикты с блокирующими находками — зелёные бюджет не тратят:
$spent_list"
"Лимит циклов ревью исчерпан ($done_cycles из $limit на этапе \`$stage\`). Пятого захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
stage=""
else
echo "этап $stage, заход $attempt, блокирующих циклов $spent из $limit"
echo "этап $stage, цикл $((done_cycles + 1)) из $limit"
fi
echo "stage=$stage" >> "$GITHUB_OUTPUT"
echo "cycle=$attempt" >> "$GITHUB_OUTPUT"
echo "spent=$spent" >> "$GITHUB_OUTPUT"
echo "cycle=$((done_cycles + 1))" >> "$GITHUB_OUTPUT"
echo "limit=$limit" >> "$GITHUB_OUTPUT"
review:
@@ -255,10 +218,6 @@ jobs:
- name: Review
id: review
uses: anthropics/claude-code-action@v1
env:
# Вне рабочей копии: восстановление дерева ревьюером не должно
# уничтожать его собственный артефакт (#220).
REVIEW_DOC: ${{ runner.temp }}/review-document.md
with:
# Подписка, а не отдельный счёт API: токен выпускается через
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
@@ -271,42 +230,6 @@ jobs:
Этап: ${{ needs.guard.outputs.stage }}
spec — ревью ТЗ (PROCESS.md §2.4)
code — код-ревью (PROCESS.md §2.7)
Заход: r${{ needs.guard.outputs.cycle }} · блокирующих циклов израсходовано ${{ needs.guard.outputs.spent }} из ${{ needs.guard.outputs.limit }}
Бюджет §4 тратят только жёлтые и красные вердикты: зелёный
ничего не вернул на правки и цикла не образует (#227).
Номер захода нужен для имени документа — два документа с
одинаковым номером затёрли бы друг друга.
**Если цикл не первый — объём разбора по дельте, а не заново**
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
всех раундов, и повторный цикл заново выводил продуктовую рамку и
перепроверял AC, которых правка не касалась: r2 по #150 стоил
полного прогона ради одной строки в тестовой фикстуре.
Порядок для r2 и дальше:
1. найди вердикт предыдущего раунда в комментариях issue и SHA,
на котором он получен. SHA в вердикте не назван — это находка;
2. объяви дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла
ТЗ или тела issue для spec. Дельта — предмет этого раунда;
3. по каждой находке предыдущего раунда покажи, чем именно она
закрыта: строка кода или текста, а не заявление автора;
4. заново проверяй только те AC, чьё доказательство дельта
задевает. Остальные наследуй;
5. в документе обязателен раздел «Унаследовано из r<N-1>»: что
принято без повторной проверки, со ссылкой на документ того
раунда и SHA, на котором вывод получен. Без этого перечня
сокращение — молчаливое доверие, а такой тихий успех уже
дважды стоил дня (#171, #207).
Разбор остаётся ПОЛНЫМ, если дельта не локальна: ребейз на ушедший
вперёд dev (после ребейза это другой код, §7.2), смена контракта
поведения, задета новая подсистема, либо объём дельты сопоставим с
исходной задачей. Сомневаешься — разбирай полностью и скажи почему.
Сокращается объём РАЗБОРА, а не строгость: правка по замечанию
способна сломать AC, который предыдущий раунд признал выполненным —
так появилась регрессия #102. Поэтому граница не «только находки», а
«находки плюс всё, до чего дотягивается дельта».
Прочитай в этом порядке, прежде чем судить:
1. docs/SCOPE.md — зачем продукт существует и для кого. Он
@@ -350,29 +273,14 @@ jobs:
правке — не тщательность, а потеря времени: полные наборы это
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
Всегда, они дешёвые, и в повторном раунде тоже: код изменился,
а стоят они минуты:
Всегда, они дешёвые:
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
копий бандла. Плюс `node scripts/check-docs.mjs`, если diff трогает
`src/**`: отпечаток скриншотов документации считается по всему
`src/**`, поэтому любая правка фронтенда делает его устаревшим —
выбирать тут нечего. Пропуск этого шага в #230 и #234 оставил `dev`
с красным job `docs` до следующей задачи (#237).
копий бандла.
По необходимости, и «необходимость» определяется diff'ом и AC:
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс те,
что печатает `node scripts/smoke-select.mjs --base <base> --head <head>`.
Их 167; прогон всех уместен только когда задача действительно
задевает всё. Выбирать по теме недостаточно: регресс #234 поймал
`smoke_wall_junctions`, который по названию про стыки стен, а не
про толщину отрезка. Инструмент печатает три вида ответа, и они
разные: «прямое совпадение» — смок называет изменённый символ,
«зарегистрированная связь» — смок проверяет следствие контракта,
не называя его, «НЕОПРЕДЕЛЁННОСТЬ» — связь не доказана, и это не
разрешение ничего не прогонять. Вывод инструмента прикладывается
к комментарию ревью вместе с решением по каждой строке: прогнал
либо не прогнал и почему. Слабые связи (одно распространённое
имя) — повод посмотреть, а не обязанность прогонять;
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс
относящиеся к тронутым поверхностям. Их 127; прогон всех уместен
только когда задача действительно задевает всё;
- `npm run golden:verify` — если diff может изменить видимый
результат: рендер, геометрия, стили, слои;
- `python -m pytest tests_backend -q` — если тронут
@@ -406,30 +314,16 @@ jobs:
тип, приоритет, S1-new. «Оставили в тексте ревью» закрытием не
считается и прямо запрещено §12.
Напиши полный документ ревью в файл, путь которого лежит в
переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория).
Почему не в docs/reviews: документ там был некоммитнутым файлом того
же дерева, которое ты мутируешь, проверяя «умеет ли тест падать». На
#220 три раунда подряд документ исчезал — восстановление дерева
(`git checkout -- .`, `git clean -fd`) сносит собственный артефакт
ревью, потому что он untracked. В репозиторий его положит шаг
публикации, взяв из REVIEW_DOC; тебе трогать docs/reviews не нужно.
В самом репозитории не создавай файлов вообще: любые изменения в
рабочей копии будут отброшены. Имя документа в docs/reviews шаг
публикации соберёт сам — SPEC-REVIEW для этапа spec, CODE-REVIEW для
code, с номером issue и заходом.
Содержание документа: скоуп, как проверялось, находки с
воспроизведением, что проверено и корректно, чего не проверял. Для
r2 и дальше добавь два раздела: «Закрытие раунда r<N-1>» — таблица
«находка | чем закрыта | где это видно», и «Унаследовано из r<N-1>» —
что принято без повторной проверки, с документом и SHA.
Напиши полный документ ревью в файл
docs/reviews/<SPEC|CODE>-REVIEW-${{ github.event.issue.number }}-r${{ needs.guard.outputs.cycle }}.md
(SPEC для этапа spec, CODE для code): скоуп, как проверялось,
находки с воспроизведением, что проверено и корректно, чего не
проверял. Каталог docs/reviews/ создай, если его нет. Больше не
пиши ничего: любой файл вне docs/reviews/ опубликован не будет.
Затем оставь в issue краткий комментарий: вердикт, ключевые находки
и ссылка на документ. Первой строкой — вердикт в формате §7.2:
`Вердикт: зелёный/жёлтый/красный · заход r${{ needs.guard.outputs.cycle }} · блокирующих циклов ${{ needs.guard.outputs.spent }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…`
`Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…`
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору жёлтым)
Затем верни JSON по схеме. Это последнее действие и оно обязательно:
@@ -448,7 +342,6 @@ jobs:
NUM: ${{ github.event.issue.number }}
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
SOURCE: ${{ runner.temp }}/review-document.md
run: |
# Ветки задачи может не быть: у задач, размеченных до появления
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
@@ -462,29 +355,8 @@ jobs:
marker=CODE-REVIEW
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
# Рабочая копия отбрасывается ДО того, как документ попадёт в дерево:
# ревьюер правит код, проверяя «умеет ли тест падать», и его правки
# публиковаться не должны.
git checkout -- . 2>/dev/null || true
# docs/reviews исключён из уборки: ревьюер мог написать документ по
# старому пути, и клин не должен его съесть до `git add` — ровно так
# оба пути остаются работоспособными.
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
# Документ приезжает извне репозитория (#220). Три раунда подряд он
# терялся, пока лежал некоммитнутым файлом в том же дереве, которое
# ревьюер мутирует и затем восстанавливает: `git checkout -- .` плюс
# `git clean -fd` сносят собственный артефакт ревью, потому что он
# untracked. Теперь его место — RUNNER_TEMP, и уборка дерева ему не
# страшна.
if [ -f "$SOURCE" ]; then
mkdir -p docs/reviews
cp "$SOURCE" "$doc"
echo "документ взят из $SOURCE ($(wc -c < "$doc") байт)"
else
# Совместимость: ревьюер мог написать по старому пути, если промпт
# ещё не обновился в этой ветке.
echo "::warning::$SOURCE не найден — ищу документ в рабочей копии"
fi
git add docs/reviews 2>/dev/null || true
if git diff --cached --quiet; then
# Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит
@@ -500,7 +372,7 @@ jobs:
# остались только комментариями, метки переставились, и пропажу
# заметило лишь следующее ревью — issue #171. Падение ДО шага с
# меткой сохраняет инвариант «метка не сменилась = прогон упал».
echo "::error::вердикт есть, а документа нет: ни $SOURCE, ни $doc в рабочей копии, ни $doc в $target — ревью без артефакта (#171, #220)"
echo "::error::вердикт есть, а документа $doc нет ни в рабочей копии, ни в $target — ревью без артефакта (#171)"
exit 1
fi
git -c user.name="claude[bot]" \
+5 -164
View File
@@ -116,98 +116,6 @@ jobs:
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
} >> "$GITHUB_OUTPUT"
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
# сборки) ПЛЮС оснастка именно этой job. Маркер в кэше пишет только успешный
# прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми
# же входами уже завершилась успешно.
#
# Это НЕ фильтр путей из job `changes` (на dev они отключены намеренно): там
# объём прогона угадывается по путям и «зелёный» начинает значить разное,
# здесь эквивалентность входов доказана хешем.
#
# Свойство, снимающее главный риск: релизный кандидат бампает версию, а
# CARD_VERSION и package.json входят в фингерпринт, поэтому ключи кандидата
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
reuse:
runs-on: ubuntu-latest
outputs:
smoke: ${{ steps.probe.outputs.smoke }}
golden: ${{ steps.probe.outputs.golden }}
performance_smoke: ${{ steps.probe.outputs.performance_smoke }}
backend: ${{ steps.probe.outputs.backend }}
smoke_key: ${{ steps.keys.outputs.smoke }}
golden_key: ${{ steps.keys.outputs.golden }}
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
backend_key: ${{ steps.keys.outputs.backend }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Ключи переиспользования
id: keys
run: |
for job in smoke golden performance_smoke backend; do
key=$(node scripts/gate-reuse.mjs --job="$job")
echo "$job=$key" >> "$GITHUB_OUTPUT"
echo "$job: $key"
done
# lookup-only: маркер только проверяется, но не восстанавливается —
# сохранять его в этой job нечего, она ничего не прогоняла.
- name: Маркер smoke
id: m_smoke
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-smoke-${{ steps.keys.outputs.smoke }}
lookup-only: true
- name: Маркер golden
id: m_golden
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-golden-${{ steps.keys.outputs.golden }}
lookup-only: true
- name: Маркер performance_smoke
id: m_perf
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
lookup-only: true
- name: Маркер backend
id: m_backend
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-backend-${{ steps.keys.outputs.backend }}
lookup-only: true
- name: Что переиспользуем
id: probe
env:
SMOKE: ${{ steps.m_smoke.outputs.cache-hit }}
GOLDEN: ${{ steps.m_golden.outputs.cache-hit }}
PERF: ${{ steps.m_perf.outputs.cache-hit }}
BACKEND: ${{ steps.m_backend.outputs.cache-hit }}
run: |
# Пропуск обязан быть громким: молчаливый skip — тот самый тихий
# успех, который уже дважды стоил нам дня (#171, #207).
waive() {
if [ "$2" = "true" ]; then
echo "$1=true" >> "$GITHUB_OUTPUT"
echo "::notice::$1 не прогоняется: входы побайтово те же, что в предыдущем успешном прогоне (#208)"
echo "- **$1** переиспользована: входы не менялись" >> "$GITHUB_STEP_SUMMARY"
else
echo "$1=false" >> "$GITHUB_OUTPUT"
echo "- $1: прогоняется" >> "$GITHUB_STEP_SUMMARY"
fi
}
echo "### Переиспользование гейтов (#208)" >> "$GITHUB_STEP_SUMMARY"
waive smoke "$SMOKE"
waive golden "$GOLDEN"
waive performance_smoke "$PERF"
waive backend "$BACKEND"
hacs:
needs: changes
if: needs.changes.outputs.integration == 'true'
@@ -252,8 +160,7 @@ jobs:
smoke:
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
needs: [frontend, reuse]
if: needs.reuse.outputs.smoke != 'true'
needs: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -299,28 +206,11 @@ jobs:
with:
name: smoke-logs
path: /tmp/smoke-logs
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
run: |
printf '%s\n' "smoke прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
- uses: actions/cache/save@v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-smoke-${{ needs.reuse.outputs.smoke_key }}
golden:
# Deterministic visual correctness stays in every prerelease gate: it is
# inexpensive and catches a different class of regressions than timings.
needs: [frontend, reuse]
if: needs.reuse.outputs.golden != 'true'
needs: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -361,28 +251,11 @@ jobs:
with:
name: golden-images
path: artifacts/golden
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
run: |
printf '%s\n' "golden прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
- uses: actions/cache/save@v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-golden-${{ needs.reuse.outputs.golden_key }}
performance_smoke:
# Candidate-only catastrophic-regression guard for ordinary pushes and
# prereleases. The expensive same-runner comparison lives in performance.yml.
needs: [frontend, reuse]
if: needs.reuse.outputs.performance_smoke != 'true'
needs: frontend
runs-on: ubuntu-latest
# 15 минут не хватало, когда установка браузера шла через apt: замер
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
@@ -423,26 +296,10 @@ jobs:
with:
name: performance-smoke
path: artifacts/performance-smoke
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
run: |
printf '%s\n' "performance_smoke прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
- uses: actions/cache/save@v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
backend:
needs: [changes, reuse]
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
needs: changes
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -455,19 +312,3 @@ jobs:
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
- name: Backend unit tests (pure + HA harness)
run: python -m pytest tests_backend/ -q
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
run: |
printf '%s\n' "backend прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
> .reuse-marker
- uses: actions/cache/save@v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-backend-${{ needs.reuse.outputs.backend_key }}
+3 -7
View File
@@ -358,13 +358,9 @@ complete Linux CI artifact; never accept a partial scenario or images merely to
CI green. See `demo/golden/README.md`.
**Freshness contract**: the embedded fingerprint covers `src/` plus Rollup,
TypeScript and package-lock build inputs. Every browser check must verify it
before trusting a result — benchmarks, golden runs and documentation captures
call `assertFreshDemoBundle` themselves, and smokes get it from `launch()` in
`demo/serve.mjs` (#236). A missing or mismatched fingerprint is a hard failure,
not a warning; `HP_ALLOW_STALE_BUNDLE=1` skips the check for debugging and says
so out loud. A smoke against a stale bundle does not fail cleanly: part of its
assertions go red and part stay green, which reads as a logic defect.
TypeScript and package-lock build inputs. Benchmark and golden tooling must call
`assertFreshDemoBundle` before recording any result; a missing or mismatched
fingerprint is a hard failure, not a warning.
**CI is pinned to an exact SHA.** The release gate accepts only a `completed
success` run for the candidate's SHA, not "the last green one"; a new push cancels
+10 -83
View File
@@ -138,7 +138,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
(решение владельца 2026-08-19, #202: отдельный issue дороже правки на месте).
Low либо правится, либо снимается решением ревьюера с записью.
- **Выход:** «Готово к разработке» либо возврат в «ТЗ в работе» — не более
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
4 циклов (§4).
### 2.5 Готово к разработке (DoR)
@@ -195,7 +195,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
Medium **вне скоупа** — отдельный issue (#202).
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
4 циклов (§4).
### 2.8 Закрытие после выпуска беты
@@ -216,42 +216,6 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
- **Отклонено:** закрытие с записанной причиной (вне скоупа, дубликат, цена не
оправдана). Тихое закрытие без причины запрещено.
### 2.10 Повторный раунд ревью — объём по дельте
Решение владельца 2026-08-19 (issue #214). Относится и к ревью ТЗ, и к
код-ревью, начиная со второго цикла.
**Предмет повторного раунда — дельта, а не задача целиком.** Раньше объём
разбора не был оговорён, промпт ревьюера для всех раундов был одинаковым, и
повторный цикл заново выводил продуктовую рамку и перепроверял AC, которых
правка не касалась: r2 по #150 стоил полного прогона конвейера ради одной
строки в тестовой фикстуре.
Порядок:
1. найти вердикт предыдущего раунда и **SHA, на котором он получен**; SHA в
вердикте не назван — это находка;
2. объявить дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла ТЗ либо тела
issue для этапа ТЗ;
3. по каждой находке предыдущего раунда показать, **чем именно она закрыта** —
строкой кода или текста, а не заявлением автора;
4. заново проверять только те AC, чьё доказательство дельта задевает;
5. **раздел «Унаследовано из r<N−1>»** обязателен: что принято без повторной
проверки, со ссылкой на документ того раунда и SHA. Без перечня сокращение
превращается в молчаливое доверие.
Дешёвые гейты (`typecheck`, `test`, `build` со сверкой копий бандла) гоняются в
каждом раунде: код изменился, а стоят они минуты. Тяжёлые — по дельте (§10.2).
**Разбор остаётся полным**, если дельта не локальна: ребейз на ушедший вперёд
`dev` (после ребейза это другой код, §7.2), смена контракта поведения, задета
новая подсистема, либо объём дельты сопоставим с исходной задачей.
Сокращается объём **разбора, а не строгость**: правка по замечанию способна
сломать AC, который предыдущий раунд признал выполненным — так появилась
регрессия #102. Граница не «только находки», а «находки плюс всё, до чего
дотягивается дельта».
---
## 3. Правила
@@ -307,24 +271,11 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
## 4. Лимит циклов ревью: 4
Оба ревью-гейта возвращают задачу на правки не более **4 раз**.
Оба ревью-гейта возвращают задачу на правки не более **4 раз**. Счётчик виден в
имени документа: `-r1` … `-r4`; на четвёртом заходе ставится метка `review-4`.
- **Что считается циклом:** отправка на ревью → вердикт с блокирующими находками
→ возврат. Уточняющий вопрос без вердикта циклом не считается.
- **Зелёный вердикт цикла не образует** и бюджет не тратит (решение владельца
2026-08-20, issue #227): он ничего не вернул на правки. Практический случай —
зелёное ревью, слияние которого не удалось: конвейер сам предписывает ребейз и
возврат метки, и этот заход не должен наказываться. Раньше счётчик считал все
вердикты подряд, и на #225 последовательность жёлтый → зелёный → ребейз дала
`review-4` на задаче с зелёным ревью и зелёным CI.
- **Заход и цикл — разные величины.** Заход — сколько раз ревью отработало; он
виден в имени документа (`-r1`, `-r2`, …) и нужен, чтобы два документа не
затёрли друг друга. Цикл — единица бюджета §4. Заходов законно бывает больше,
чем циклов, поэтому порог проверки 7 в `scripts/process-gate.mjs` выше лимита
циклов (шесть документов = четыре цикла плюс два ребейза).
- Метка `review-4` ставится, когда исчерпан **бюджет циклов**; конвейер снимать
её не вправе — это решение владельца. Если бюджет пересчитан и оказался ниже
лимита, конвейер сообщает пересчёт, но метку не трогает.
- **Исчерпание лимита — не «пятая попытка», а разбор.** Задача уходит владельцу,
решение одно из трёх:
1. **разделить** — issue закрывается как «заменён», вместо него 2–3 меньших с
@@ -501,12 +452,9 @@ issue #NN
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · заход r<N> ·
блокирующих циклов K/<лимит> · High: N · Medium: N → в задаче | #… ·
Документ: docs/reviews/…`
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору.
Заход — номер прогона ревью, K — израсходованный бюджет §4: зелёные вердикты
его не тратят, поэтому заход и K расходятся, #227)
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · цикл r<N>/<лимит> ·
High: N · Medium: N → в задаче | #… · Документ: docs/reviews/…`
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору)
- **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>`
**Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору;
@@ -538,39 +486,18 @@ npx tsc --noEmit
npm test
npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js \
&& cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
node scripts/smoke-select.mjs --base origin/dev --head HEAD # какие смоки относятся к диффу
node demo/smoke_<целевые>.mjs
npm run golden:verify # если менялся визуал
node scripts/check-docs.mjs # если менялся src/**
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
```
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла, а при
любом diff'е по `src/**` — ещё и `node scripts/check-docs.mjs`. По
необходимости, определяемой diff'ом и AC: браузерные смоки (их 167 — прогон всех
уместен только когда задача задевает всё; какие относятся к диффу, печатает
`node scripts/smoke-select.mjs --base origin/dev --head HEAD`, и его вывод
прикладывается к ревью вместе с решением по каждой строке), `golden:verify` при изменении видимого
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла. По
необходимости, определяемой diff'ом и AC: браузерные смоки (их 127 — прогон всех
уместен только когда задача задевает всё), `golden:verify` при изменении видимого
результата, `pytest tests_backend` при правках в Python, performance-профили при
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
Скриншоты снимаются **только** джобой `Docs screenshots` (`workflow_dispatch`) и
принимаются локально: `npm run docs:accept -- --reviewed --from=<распакованный
артефакт>` (#246). Съёмка на своей машине даёт байтово другой PNG при том же
кадре, и набор из «не того» браузера переписывает все десять файлов без единого
содержательного изменения. Приёмка отказывает, если кандидат снят не с этого
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит делает
человек.
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
всегда попадает, и решать нечего. Цена пропуска измерена: скриншоты не
пересняли в #230 и #234, и `dev` стоял с красным job `docs`, пока это не нашли
при следующей задаче (#237). Пересъёмка — `npm run build && node
demo/docs/capture.mjs`, коммит вместе с задачей.
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
пропуском.
+2 -2
View File
@@ -46,12 +46,12 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.67.0-beta.3"
VERSION = "1.65.0-beta.5"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
# user have different compatibility lifecycles.
PLAN_MODEL_VERSION = 7
PLAN_MODEL_VERSION = 6
EXPORT_VERSION = 1
MAX_EXPORT_BYTES = 8 * 1024 * 1024
IMPORT_PREVIEW_TTL_S = 10 * 60
@@ -1,149 +0,0 @@
"""Lossless, allow-listed canonicalisation of persisted geometry.
The frontend mirrors this module in src/coordinate-canonicalization.ts.
Keep the precision, scalar formula and field allow-list in lockstep; a shared
fixture is exercised by both runtimes.
"""
from __future__ import annotations
import copy
import math
from typing import Any
COORDINATE_DECIMALS = 9
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
def canonicalize_number(value: Any) -> Any:
"""Return one stable IEEE-754 representation for an allow-listed number."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return value
number = float(value)
if not math.isfinite(number):
return value
sign = -1.0 if math.copysign(1.0, number) < 0 else 1.0
result = sign * (
math.floor(abs(number) * COORDINATE_FACTOR + 0.5)
/ COORDINATE_FACTOR
)
if result == 0:
return 0.0
return result
def _record(value: Any) -> dict[str, Any] | None:
return value if isinstance(value, dict) else None
def _records(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, dict)]
def _fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
for name in names:
if name in record:
record[name] = canonicalize_number(record[name])
def _point(value: Any) -> None:
if not isinstance(value, list):
return
for index in range(min(2, len(value))):
value[index] = canonicalize_number(value[index])
def _points(value: Any) -> None:
if not isinstance(value, list):
return
for point in value:
_point(point)
def canonicalize_position(position: Any) -> Any:
"""Canonicalise x/y in one layout record without touching its metadata."""
result = copy.deepcopy(position)
record = _record(result)
if record is not None:
_fields(record, ("x", "y"))
return result
def canonicalize_layout_geometry(layout: Any) -> Any:
"""Canonicalise x/y in every layout record, preserving future fields."""
result = copy.deepcopy(layout)
record = _record(result)
if record is None:
return result
for position in record.values():
item = _record(position)
if item is not None:
_fields(item, ("x", "y"))
return result
def canonicalize_config_geometry(config: Any) -> Any:
"""Canonicalise only the named persisted geometry fields."""
result = copy.deepcopy(config)
root = _record(result)
if root is None:
return result
for space in _records(root.get("spaces")):
_fields(
space,
(
"plan_x",
"plan_y",
"plan_scale",
"plan_scale_x",
"plan_scale_y",
"plan_angle",
),
)
for room in _records(space.get("rooms")):
_fields(room, ("x", "y", "w", "h"))
_points(room.get("poly"))
for wall in _records(space.get("walls")):
_point(wall.get("a"))
_point(wall.get("b"))
for opening in _records(space.get("openings")):
_fields(opening, ("x", "y", "angle", "length"))
host = _record(opening.get("host"))
if host is not None:
_fields(host, ("t",))
for decor in _records(space.get("decor")):
kind = decor.get("kind")
if kind == "line":
_fields(decor, ("x1", "y1", "x2", "y2"))
elif kind in ("rect", "ellipse", "furniture"):
_fields(decor, ("x", "y", "w", "h", "angle"))
elif kind == "text":
_fields(decor, ("x", "y", "scale", "angle"))
for draft in _records(space.get("room_drafts")):
_points(draft.get("points"))
for partition in _records(space.get("partitions")):
_point(partition.get("a"))
_point(partition.get("b"))
for column in _records(space.get("wall_columns")):
_point(column.get("center"))
if column.get("shape") == "square":
_fields(column, ("angle",))
for span in _records(space.get("open_spans")):
_point(span.get("a"))
_point(span.get("b"))
for marker in _records(root.get("markers")):
_fields(marker, ("angle",))
return result
File diff suppressed because one or more lines are too long
+3 -76
View File
@@ -17,7 +17,6 @@ import unicodedata
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urlsplit
import voluptuous as vol
@@ -349,24 +348,6 @@ def placement_manifest(config: dict[str, Any], layout: dict[str, Any]) -> list[d
def _internal_path(root: Path, url: str) -> tuple[str, Path] | None:
# A url is parsed as a url, not as a string: everything after "?" or "#"
# addresses the transfer, never the file. Legacy attachments carry a
# cache-buster (".../files/m1/doc.pdf?v=1783170649"), and while the string
# form fed "doc.pdf?v=1783170649" to sanitize_filename the name never
# matched itself — the reference read as internal-but-non-canonical and
# every backup holding one refused to import (issue #225). Path segments
# keep doing the guarding: dropping the query cannot widen what a segment
# is allowed to be.
#
# Only a same-document reference may be trusted this way: with a scheme or
# an authority the path belongs to another host, and taking it would let
# "https://evil.example/houseplan_files/files/m1/doc.pdf" resolve onto a
# local file (review CODE-REVIEW-225-r1, M1). Such a url stays external,
# which is also what _looks_internal says about it.
parsed = urlsplit(url)
if parsed.scheme or parsed.netloc:
return None
url = parsed.path
content_plan = CONTENT_URL + "/plans/_/"
if url.startswith(content_plan) or url.startswith(PLANS_URL + "/"):
prefix = content_plan if url.startswith(content_plan) else PLANS_URL + "/"
@@ -822,56 +803,6 @@ def _orphan_marker(key: str, manifest: dict[str, Any] | None, space_id: str, use
}
def _repair_target_space_refs(
current_config: dict[str, Any],
current_layout: dict[str, Any],
old_space_id: str,
new_space_id: str,
old_room_ids: dict[str, str],
) -> tuple[dict[str, Any], dict[str, Any], int]:
"""Apply the exact map of this import to pre-existing orphan target refs."""
config = _json_copy(current_config)
layout = _json_copy(current_layout)
if any(str(space.get("id")) == old_space_id for space in config.get("spaces") or []):
return config, layout, 0
repaired = 0
for marker in config.get("markers") or []:
if marker.get("space") == old_space_id:
marker["space"] = new_space_id
repaired += 1
room_id = marker.get("room_id")
if room_id is not None and str(room_id) in old_room_ids:
marker["room_id"] = old_room_ids[str(room_id)]
repaired += 1
vacuum = marker.get("vacuum")
segment_map = vacuum.get("segment_map") if isinstance(vacuum, dict) else None
if isinstance(segment_map, dict):
for key, room_id in list(segment_map.items()):
mapped = old_room_ids.get(str(room_id))
if mapped is not None:
segment_map[key] = mapped
repaired += 1
# Rewrite ownership before rekeying labels. A valid destination wins a
# collision inside the target; the imported document itself is overlaid
# later and has final priority for its own label position.
for position in layout.values():
if isinstance(position, dict) and position.get("s") == old_space_id:
position["s"] = new_space_id
repaired += 1
for old_room_id, new_room_id in old_room_ids.items():
old_key = "rl_" + old_room_id
if old_key not in layout:
continue
new_key = "rl_" + new_room_id
if new_key not in layout:
layout[new_key] = layout[old_key]
del layout[old_key]
repaired += 1
return config, layout, repaired
def build_space_merge(
document: dict[str, Any],
current_config: dict[str, Any],
@@ -909,9 +840,6 @@ def build_space_merge(
old_room_ids = {
old: new for old, new in id_map.items() if old != old_space_id and new.startswith("room_")
}
target_config, target_layout, repaired_target_refs = _repair_target_space_refs(
current_config, current_layout, old_space_id, new_space_id, old_room_ids,
)
for room in space.get("rooms") or []:
if room.get("open_to"):
room["open_to"] = [old_room_ids.get(str(value), str(value)) for value in room["open_to"]]
@@ -937,7 +865,7 @@ def build_space_merge(
if isinstance(item, dict)
}
incoming_bindings = _binding_inventory(incoming, incoming_layout, manifest)
current_bindings = _binding_inventory(target_config, target_layout)
current_bindings = _binding_inventory(current_config, current_layout)
duplicate = incoming_bindings & current_bindings
marker_map: dict[str, str] = {}
output_markers: list[dict[str, Any]] = []
@@ -1044,14 +972,14 @@ def build_space_merge(
virtualized += 1
output_layout[new_key] = {**pos, "s": new_space_id}
merged_config = target_config
merged_config = _json_copy(current_config)
merged_config.setdefault("spaces", []).append(space)
merged_config.setdefault("markers", []).extend(output_markers)
dropped_marker_links += _drop_invalid_import_marker_links(
merged_config,
clean_ids={str(marker.get("id")) for marker in output_markers},
)
merged_layout = {**target_layout, **output_layout}
merged_layout = {**_json_copy(current_layout), **output_layout}
if len(merged_config.get("spaces") or []) > MAX_SPACES \
or len(merged_config.get("markers") or []) > MAX_MARKERS \
or len(merged_layout) > MAX_LAYOUT:
@@ -1087,7 +1015,6 @@ def build_space_merge(
"virtualized": virtualized,
"orphan_markers": len(output_markers) - len(marker_map),
"dropped_marker_links": dropped_marker_links,
"repaired_target_refs": repaired_target_refs,
}
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.67.0-beta.3"
"version": "1.65.0-beta.5"
}
+3 -8
View File
@@ -20,10 +20,6 @@ from .const import (
STORAGE_VERSION,
STORAGE_VIRTUAL_LIGHTS_KEY,
)
from .coordinate_canonicalization import (
canonicalize_config_geometry,
canonicalize_layout_geometry,
)
_LOGGER = logging.getLogger(__name__)
@@ -161,7 +157,7 @@ def layout_store_payload(
}
if metadata:
out.update(metadata)
out["layout"] = canonicalize_layout_geometry(layout)
out["layout"] = layout
out["rev"] = rev
return out
@@ -209,8 +205,7 @@ async def async_save_config_state(
except (TypeError, ValueError):
previous_rev = 0
canonical_config = canonicalize_config_geometry(config)
payload = {"config": canonical_config, "rev": rev}
payload = {"config": config, "rev": rev}
await runtime.config_store.async_save(payload)
# The config is already durable at this point. Reconciliation remains a
@@ -221,7 +216,7 @@ async def async_save_config_state(
try:
await async_reconcile_virtual_lights(
runtime.virtual_light_store,
canonical_config,
config,
rev,
previous_config_rev=previous_rev,
)
+3 -31
View File
@@ -10,7 +10,6 @@ want to see where the cleanup has already been).
from __future__ import annotations
import asyncio
import math
import time
from typing import Any
@@ -25,33 +24,11 @@ import logging
_LOGGER = logging.getLogger(__name__)
TRAIL_CAP = 2000 # raw points per run before decimation
TRAIL_RESUME_GRACE_S = 30 * 60 # same-map stop/pause belongs to one cleanup
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
MOVING_STATES = {"cleaning", "returning", "on"}
def can_resume_trail_run(run: Any, map_id: str, now: float) -> bool:
"""Whether an ended current run may be reopened for this point.
Store timestamps are untrusted persisted data. Only finite JSON-number
timestamps and a non-negative inclusive grace interval are accepted;
malformed values and wall-clock rollback fail closed into a new run.
"""
if not isinstance(run, dict) or run.get("map_id") != map_id:
return False
ended = run.get("ended")
if (
isinstance(ended, bool)
or not isinstance(ended, (int, float))
or isinstance(now, bool)
or not isinstance(now, (int, float))
):
return False
elapsed = now - ended
return math.isfinite(elapsed) and 0 <= elapsed <= TRAIL_RESUME_GRACE_S
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
"""Map-id normalisation contract, shared with the frontend.
@@ -87,10 +64,7 @@ class TrailBook:
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
rec = self.data.setdefault(marker, {})
cur = rec.get("current")
resumed = bool(cur and can_resume_trail_run(cur, map_id, now))
if resumed:
cur["ended"] = None
if not cur or cur.get("ended") is not None or cur.get("map_id") != map_id:
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
# a new run begins: the old one becomes "previous" (and the one
# before it is forgotten — we keep exactly two, per the owner)
if cur:
@@ -99,9 +73,7 @@ class TrailBook:
rec["current"] = cur
pts: list[list[float]] = cur["points"]
if pts and pts[-1][0] == x and pts[-1][1] == y:
# Clearing ended is observable state even if the source repeats
# the dock point: it must still reach Store and live cards.
return resumed
return False
pts.append([x, y])
if len(pts) > TRAIL_CAP:
# decimate by two but never lose the freshest point
@@ -113,7 +85,7 @@ class TrailBook:
def end_run(self, marker: str, now: float) -> bool:
cur = (self.data.get(marker) or {}).get("current")
if cur and cur.get("ended") is None:
if cur and not cur.get("ended"):
cur["ended"] = now
return True
return False
+38 -54
View File
@@ -9,12 +9,6 @@ import re
import voluptuous as vol
from custom_components.houseplan.coordinate_canonicalization import (
canonicalize_config_geometry,
canonicalize_layout_geometry,
canonicalize_position,
)
# ---------- limits and extension sets ----------
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
MAX_PLAN_BYTES = 8 * 1024 * 1024
@@ -528,18 +522,11 @@ _URL = vol.All(str, vol.Length(max=MAX_URL))
CANVAS_LIMIT = 5000.0
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
POS_SCHEMA = vol.All(
vol.Schema(
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
),
canonicalize_position,
)
LAYOUT_SCHEMA = vol.All(
vol.Schema({str: POS_SCHEMA}),
vol.Length(max=MAX_LAYOUT),
canonicalize_layout_geometry,
POS_SCHEMA = vol.Schema(
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
)
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
@@ -1137,41 +1124,38 @@ MARKER_SCHEMA = vol.Schema(
},
extra=vol.ALLOW_EXTRA,
)
CONFIG_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): _COLOR,
# sun on the plan (docs/SUN.md): global defaults
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
# legacy field so an existing stored config can still load; the
# frontend ignores it and removes it on the next settings save.
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
{
vol.Required("c"): _COLOR,
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
)
}
),
},
extra=vol.ALLOW_EXTRA,
),
},
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
),
canonicalize_config_geometry,
CONFIG_SCHEMA = vol.Schema(
{
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): _COLOR,
# sun on the plan (docs/SUN.md): global defaults
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
# legacy field so an existing stored config can still load; the
# frontend ignores it and removes it on the next settings save.
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
{
vol.Required("c"): _COLOR,
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
)
}
),
},
extra=vol.ALLOW_EXTRA,
),
},
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
)
+14 -243
View File
@@ -25,10 +25,6 @@ from .const import (
MAX_SIGN_PATHS,
PLANS_DIR, PLANS_URL,
)
from .coordinate_canonicalization import (
canonicalize_config_geometry,
canonicalize_layout_geometry,
)
from .auth import may_write
from .import_export import (
ImportFailure,
@@ -142,7 +138,6 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_optimize)
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
websocket_api.async_register_command(hass, ws_space_delete)
websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_plans_list)
websocket_api.async_register_command(hass, ws_plans_delete)
@@ -431,12 +426,8 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
if kind == "full":
backup = {
"kind": "import",
"config": canonicalize_config_geometry(
config_data.get("config") or DEFAULT_CONFIG
),
"layout": canonicalize_layout_geometry(
layout_data.get("layout") or {}
),
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout") or {},
"created": int(time.time()),
"after_config_rev": new_config_rev,
"after_layout_rev": new_layout_rev,
@@ -453,20 +444,16 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
final_metadata[_OPTIMIZE_BACKUP] = backup
pending = {
"kind": "import",
"config": canonicalize_config_geometry(target_config),
"layout": canonicalize_layout_geometry(target_layout),
"config": target_config,
"layout": target_layout,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"final_metadata": final_metadata,
}
rollback = {
"kind": "import_rollback",
"config": canonicalize_config_geometry(
config_data.get("config") or DEFAULT_CONFIG
),
"layout": canonicalize_layout_geometry(
layout_data.get("layout") or {}
),
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout") or {},
"config_rev": config_rev,
"layout_rev": layout_rev,
"final_metadata": original_metadata,
@@ -509,7 +496,6 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
"layout_rev": new_layout_rev,
"counts": details.get("counts", {}),
"space_id": details.get("space_id"),
"repaired_target_refs": details.get("repaired_target_refs", 0),
"can_undo": kind == "full",
})
@@ -580,9 +566,6 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
return
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
if layout == data.get("layout", {}):
connection.send_result(msg["id"], {"ok": True, "rev": current_rev})
return
new_rev = current_rev + 1
await async_save_layout_state(
rt, data, layout, new_rev,
@@ -644,10 +627,7 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
return
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
if layout.get(msg["device_id"]) == msg["pos"]:
connection.send_result(msg["id"], {"ok": True, "rev": int(data.get("rev", 0))})
return
layout = {**layout, msg["device_id"]: msg["pos"]}
layout[msg["device_id"]] = msg["pos"]
# keep the revision: a point-wise write used to drop it, which made the
# optimistic locking on layout/set meaningless — every drag reset the
# counter to 0 (HP-1454-08)
@@ -755,10 +735,7 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
rt, data, new_layout, new_rev,
metadata={
**_optimizer_backup_after_layout_maintenance(data, new_rev),
"repair_backup": {
"space": space_id,
"positions": canonicalize_layout_geometry(touched),
},
"repair_backup": {"space": space_id, "positions": touched},
},
remove=("repair_backup",),
)
@@ -1304,17 +1281,6 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
"Plan file no longer exists: " + ", ".join(sorted(missing)),
)
return
if msg["config"] == data.get("config"):
# A semantic no-op still has to reconcile Repairs with external
# file-system changes. It must not create a revision, event, or
# discard the optimizer snapshot merely to refresh diagnostics.
entry = get_entry(hass)
if entry is not None:
from .repairs import async_check_plan_files
hass.async_create_task(async_check_plan_files(hass, entry))
connection.send_result(msg["id"], {"ok": True, "rev": int(current_rev)})
return
new_rev = current_rev + 1
await async_save_config_state(
rt,
@@ -1354,193 +1320,6 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
# ---------------- whole-plan maintenance ----------------
def _space_marker_dependencies(
config: dict[str, Any], layout: dict[str, Any], space_id: str,
) -> list[str]:
"""Active marker ids that make deleting a space unsafe (deduplicated)."""
space = next(
(item for item in config.get("spaces") or [] if item.get("id") == space_id),
None,
)
room_ids = {
str(room.get("id")) for room in (space or {}).get("rooms") or []
if room.get("id") is not None
}
dependencies = {
str(marker.get("id"))
for marker in config.get("markers") or []
if marker.get("removed") is not True
and marker.get("id") is not None
and (
marker.get("space") == space_id
or (
marker.get("room_id") is not None
and str(marker.get("room_id")) in room_ids
)
or (layout.get(str(marker.get("id"))) or {}).get("s") == space_id
)
}
return sorted(dependencies)
def _space_delete_candidate(
config: dict[str, Any], layout: dict[str, Any], space_id: str,
) -> tuple[dict[str, Any], dict[str, Any], list[str], int]:
"""Return a pure exact pair; blockers leave both inputs unchanged."""
candidate_config = json.loads(json.dumps(config))
candidate_layout = json.loads(json.dumps(layout))
dependencies = _space_marker_dependencies(candidate_config, candidate_layout, space_id)
spaces = candidate_config.get("spaces") or []
deleting_last_space = len(spaces) == 1 and spaces[0].get("id") == space_id
if dependencies and not deleting_last_space:
return candidate_config, candidate_layout, dependencies, 0
space = next(
(item for item in candidate_config.get("spaces") or []
if item.get("id") == space_id),
None,
)
room_ids = {
str(room.get("id")) for room in (space or {}).get("rooms") or []
if room.get("id") is not None
}
candidate_config["spaces"] = [
item for item in candidate_config.get("spaces") or []
if item.get("id") != space_id
]
for marker in candidate_config.get("markers") or []:
marker_id = str(marker.get("id")) if marker.get("id") is not None else None
marker_position = candidate_layout.get(marker_id) if marker_id is not None else None
references_deleted_space = (
marker.get("space") == space_id
or (
marker.get("room_id") is not None
and str(marker.get("room_id")) in room_ids
)
or (
isinstance(marker_position, dict)
and marker_position.get("s") == space_id
)
)
if deleting_last_space and references_deleted_space:
marker.pop("space", None)
marker.pop("room_id", None)
continue
if marker.get("removed") is not True:
continue
if marker.get("space") == space_id:
marker.pop("space", None)
if (marker.get("room_id") is not None
and str(marker.get("room_id")) in room_ids):
marker.pop("room_id", None)
removed_layout = 0
for key in list(candidate_layout):
position = candidate_layout.get(key)
if isinstance(position, dict) and position.get("s") == space_id:
del candidate_layout[key]
removed_layout += 1
return candidate_config, candidate_layout, dependencies, removed_layout
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/space/delete",
vol.Required("space_id"): str,
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
}
)
@websocket_api.async_response
async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Delete one space as a crash-recoverable config/layout pair."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only editors may delete spaces")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
space_id = msg["space_id"]
if not valid_space_id(space_id):
connection.send_error(msg["id"], "invalid_space_id", "Invalid space id")
return
try:
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if (msg["expected_config_rev"] != config_rev
or msg["expected_layout_rev"] != layout_rev):
connection.send_error(msg["id"], "conflict", "Plan changed elsewhere")
return
current_config = config_data.get("config") or DEFAULT_CONFIG
current_layout = layout_data.get("layout") or {}
if not any(
item.get("id") == space_id for item in current_config.get("spaces") or []
):
connection.send_error(msg["id"], "space_not_found", "Space no longer exists")
return
target_config, target_layout, dependencies, removed_layout = (
_space_delete_candidate(current_config, current_layout, space_id)
)
spaces = current_config.get("spaces") or []
deleting_last_space = (
len(spaces) == 1 and spaces[0].get("id") == space_id
)
if dependencies and not deleting_last_space:
connection.send_error(
msg["id"], "space_in_use",
f"Space is still used by {len(dependencies)} active marker(s)",
)
return
target_config = CONFIG_SCHEMA(target_config)
target_layout = LAYOUT_SCHEMA(target_layout)
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
original_metadata = _layout_metadata(layout_data)
final_metadata = {
key: value for key, value in original_metadata.items()
if key not in {_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup", "geom_pending"}
}
pending = {
"kind": "space_delete",
"config": canonicalize_config_geometry(target_config),
"layout": canonicalize_layout_geometry(target_layout),
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"final_metadata": final_metadata,
}
rollback = {
"kind": "space_delete_rollback",
"config": canonicalize_config_geometry(current_config),
"layout": canonicalize_layout_geometry(current_layout),
"config_rev": config_rev,
"layout_rev": layout_rev,
"final_metadata": original_metadata,
}
await _commit_import_pair(rt, pending, rollback)
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
except vol.Invalid as err:
connection.send_error(msg["id"], "invalid_config", str(err))
return
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan space delete failed")
connection.send_error(msg["id"], "commit_failed", "Space delete failed")
return
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
_refresh_trail_recorder(hass)
connection.send_result(msg["id"], {
"ok": True,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"removed_layout": removed_layout,
})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plan/optimize",
@@ -1618,19 +1397,15 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
new_layout_rev = layout_rev + 1
backup = {
"kind": "optimize",
"config": canonicalize_config_geometry(
config_data.get("config") or DEFAULT_CONFIG
),
"layout": canonicalize_layout_geometry(
layout_data.get("layout", {})
),
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout", {}),
"created": int(time.time()),
"after_config_rev": new_config_rev,
"after_layout_rev": new_layout_rev,
}
pending = {
"config": canonicalize_config_geometry(msg["config"]),
"layout": canonicalize_layout_geometry(msg["layout"]),
"config": msg["config"],
"layout": msg["layout"],
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"clear_backup": False,
@@ -1700,12 +1475,8 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
backup = layout_data[_OPTIMIZE_BACKUP]
restored_kind = str(backup.get("kind") or "optimize")
restored_config = canonicalize_config_geometry(
backup.get("config") or DEFAULT_CONFIG
)
restored_layout = canonicalize_layout_geometry(
backup.get("layout") or {}
)
restored_config = backup.get("config") or DEFAULT_CONFIG
restored_layout = backup.get("layout") or {}
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
pending = {
@@ -1,96 +0,0 @@
// #199: same-process production-builder baseline versus the complete Optimize
// preflight wrapper on the deterministic 3-floor large-house fixture.
import { performance } from 'node:perf_hooks';
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import {
checkOptimizeGeometry,
prepareSpacePhysicalGeometryInputs,
} from '../test-build/plan-geometry-preflight.js';
import { spaceModels } from '../test-build/space-geometry.js';
import {
floorFootprintGeometry,
wallBodiesGeometry,
} from '../test-build/wall-thickness.js';
const WARMUPS = 3;
const SAMPLES = 20;
const ABSOLUTE_P95_MS = 250;
const RELATIVE_RATIO = 1.2;
const RELATIVE_NOISE_MS = 15;
const fixture = makeLargeHouseFixture();
const models = spaceModels(fixture.config);
const prepared = fixture.config.spaces.map((space, index) =>
prepareSpacePhysicalGeometryInputs(space, models[index]));
const directProductionPass = () => {
for (const input of prepared) {
const hasWalls = input.walls.length > 0 || input.physicalBodies.length > 0;
const united = hasWalls
? wallBodiesGeometry(
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
input.physicalBodies,
)
: null;
if (hasWalls && united == null) throw new Error(`baseline wall failure: ${input.space.id}`);
if (input.space.rooms.length && united?.paperGeom == null) {
const floor = floorFootprintGeometry(
input.space.rooms, input.walls, input.openCuts,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
);
if (floor == null) throw new Error(`baseline floor failure: ${input.space.id}`);
}
}
};
const completePreflight = () => {
const result = checkOptimizeGeometry(fixture.config);
if (!result.ok || result.spaces.length !== LARGE_HOUSE_COUNTS.floors
|| result.spaces.some((space) => space.status !== 'ok')) {
throw new Error(`candidate preflight failure: ${JSON.stringify(result.spaces)}`);
}
};
const sample = (operation) => {
const start = performance.now();
operation();
return performance.now() - start;
};
const run = (operation) => {
for (let index = 0; index < WARMUPS; index++) operation();
return Array.from({ length: SAMPLES }, () => sample(operation));
};
const quantile = (values, ratio) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
};
const summary = (values) => ({
min: Math.min(...values),
median: quantile(values, 0.5),
p95: quantile(values, 0.95),
max: Math.max(...values),
});
const baseline = summary(run(directProductionPass));
const candidate = summary(run(completePreflight));
const relativeLimit = baseline.p95 * RELATIVE_RATIO + RELATIVE_NOISE_MS;
const pass = candidate.p95 <= ABSOLUTE_P95_MS && candidate.p95 <= relativeLimit;
const report = {
issue: 199,
fixture: LARGE_HOUSE_COUNTS,
warmups: WARMUPS,
samples: SAMPLES,
baseline,
candidate,
budgets: {
absoluteP95Ms: ABSOLUTE_P95_MS,
relativeRatio: RELATIVE_RATIO,
relativeNoiseMs: RELATIVE_NOISE_MS,
relativeLimitP95Ms: relativeLimit,
},
pass,
};
console.log(JSON.stringify(report, null, 2));
if (!pass) process.exitCode = 1;
-29
View File
@@ -29,32 +29,3 @@ export async function assertFreshDemoBundle(page, root = process.cwd()) {
}
return expected;
}
/** Env switch that lets a debugging session run against a stale bundle. */
export const ALLOW_STALE_BUNDLE = 'HP_ALLOW_STALE_BUNDLE';
/**
* The freshness gate for every browser check, escape hatch included (#236).
*
* The smoke launcher had no freshness check at all, while golden runs and
* benchmarks did. A smoke against a stale `demo/srv/assets/houseplan-card.js`
* does not fail cleanly: on #234 three assertions went red and a fourth went
* GREEN, because the old code was wrong in two places that agreed with each
* other. A partly-red partly-green result looks like a logic defect and sends
* the reader hunting in the wrong file.
*
* Skipping is allowed for debugging, but never silently: a skipped guard that
* says nothing is the same silent success this project keeps digging out.
*/
export async function assertFreshDemoBundleUnlessAllowed(
page, root = process.cwd(), env = process.env,
) {
if (env[ALLOW_STALE_BUNDLE]) {
console.warn(
`[houseplan] ${ALLOW_STALE_BUNDLE} is set — bundle freshness NOT verified. `
+ 'A red result may mean a stale bundle rather than a defect (#236).',
);
return null;
}
return assertFreshDemoBundle(page, root);
}
-202
View File
@@ -1,202 +0,0 @@
#!/usr/bin/env node
// Issue #211: human-reviewable Reference SVG <-> Runtime matrix.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { mdiLightbulbSpot } from '@mdi/js';
import { launch } from './serve.mjs';
const artifactDir = resolve('artifacts/device-icon-reference');
mkdirSync(artifactDir, { recursive: true });
const referenceAsset = (theme, file, coreSize) => {
let source = readFileSync(resolve('demo/srv/reference/device-icons', theme, file), 'utf8');
if (file === 'Lock.svg') {
const old = theme === 'Dark' ? '#252525' : 'black';
source = source.replaceAll(old, '#66D17A');
if (theme === 'Dark') source = source.replaceAll('fill="white"', 'fill="#252525"');
}
if (file === 'Unlock.svg') {
source = source.replaceAll(theme === 'Dark' ? '#1DC21D' : '#F0A00C', '#F0410C');
}
const nativeWidth = Number(source.match(/<svg[^>]*width="([\d.]+)"/)?.[1] || 127);
return {
url: `data:image/svg+xml;base64,${Buffer.from(source).toString('base64')}`,
displayWidth: nativeWidth * coreSize / 80,
};
};
const { page, browser } = await launch(
{ width: 1280, height: 960 }, 1, [], { colorScheme: 'dark' },
);
await page.evaluate((path) => { window.__ICONS['mdi:lightbulb-spot'] = path; }, mdiLightbulbSpot);
await page.evaluate(async () => {
const c = window.__card;
const marker = (id, patch) => ({
...(c._serverCfg.markers || []).find((item) => item.id === id),
id, binding: `device:${id}`, ...patch,
});
const replacements = new Map([
['d_light1', marker('d_light1', { display: 'badge', icon: 'mdi:lightbulb-spot' })],
['d_tv', marker('d_tv', { display: 'value' })],
['d_temp', marker('d_temp', {
display: 'badge',
value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: 'sensor.living_temp' },
position: 'right',
},
})],
]);
c._serverCfg.markers = [
...(c._serverCfg.markers || []).filter((item) => !replacements.has(item.id)),
...replacements.values(),
];
c.hass = {
...c.hass,
states: {
...c.hass.states,
'sensor.living_temp': {
...c.hass.states['sensor.living_temp'],
state: '23',
attributes: { ...c.hass.states['sensor.living_temp']?.attributes, unit_of_measurement: '%' },
},
'media_player.tv': {
...c.hass.states['media_player.tv'],
state: 'Working',
},
},
};
c._regSignature = '';
c._cfgEpoch++;
c._maybeRebuildDevices();
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
const qaStyle = document.createElement('style');
qaStyle.textContent = '.devtip{display:none!important}';
(c.renderRoot || c.shadowRoot).append(qaStyle);
await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)));
});
const selector = (id) => `.dev[data-id="${id}"]`;
async function runtimePng(theme, row, size) {
await page.mouse.move(1, 1);
await page.evaluate(({ id, themeName, classes, px, clearValues }) => {
const node = (window.__card.renderRoot || window.__card.shadowRoot)
.querySelector(`.dev[data-id="${id}"]`);
for (const marker of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.dev'))
marker.style.visibility = marker === node ? 'visible' : 'hidden';
node.classList.remove(...[
'theme-light', 'theme-dark', 'on', 'open', 'alarm', 'unavail', 'virtual',
'sel', 'lock-locked', 'lock-unlocked',
]);
node.classList.add(`theme-${themeName}`, ...classes);
node.style.setProperty('--device-base-size', `${px}px`);
node.style.setProperty('--dev-scale', '1');
node.querySelector('.device-core')?.style.setProperty('transition', 'none');
node.querySelector('.device-shell-frame')?.style.setProperty('transition', 'none');
if (clearValues) node.querySelectorAll('.value-badge').forEach((value) => value.remove());
node.blur();
}, {
id: row.id,
themeName: theme.toLowerCase(),
classes: row.classes || [],
px: size,
clearValues: row.clearValues || false,
});
if (row.hover) {
await page.hover(selector(row.id));
await page.waitForTimeout(180);
}
if (row.focus) {
await page.$eval(selector(row.id), (node) => node.focus());
}
await page.$eval(selector(row.id), (node) => {
for (const tooltip of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.devtip'))
tooltip.style.setProperty('display', 'none', 'important');
node.querySelector('.lqi')?.style.setProperty('display', 'none');
});
const clip = await page.$eval(selector(row.id), (node) => {
const shell = node.querySelector('.device-shell-frame').getBoundingClientRect();
const pad = 22;
return {
x: Math.max(0, shell.left - pad),
y: Math.max(0, shell.top - pad),
width: shell.width + pad * 2,
height: shell.height + pad * 2,
};
});
return (await page.screenshot({ clip })).toString('base64');
}
const rows = [
{ label: 'Default', file: 'Icon Default.svg', id: 'd_light1' },
{ label: 'Hover', file: 'Icon Hover.svg', id: 'd_light1', hover: true },
{ label: 'Active', file: 'Icon Active.svg', id: 'd_light1', classes: ['on'] },
{ label: 'Lock', file: 'Lock.svg', id: 'd_lock', classes: ['lock-locked'] },
{ label: 'Unlock', file: 'Unlock.svg', id: 'd_lock', classes: ['lock-unlocked'] },
{ label: 'Selected', file: 'Selected.svg', id: 'd_light1', classes: ['sel'] },
{ label: 'Focus', file: 'Focus Visible.svg', id: 'd_light1', focus: true },
{ label: 'Alert', file: 'Alert Value.svg', id: 'd_temp', classes: ['alarm'] },
{ label: 'Virtual', file: 'Virtual Device Default.svg', id: 'd_motion', classes: ['virtual'] },
{ label: 'Unavailable', file: 'Unavailable.svg', id: 'd_light1', classes: ['unavail'] },
{ label: 'Text', file: 'Text Default.svg', id: 'd_tv' },
{ label: 'Double Right', file: 'Double Default Right.svg', id: 'd_temp' },
];
const matrix = [];
for (const theme of ['Light', 'Dark']) {
for (const row of rows) {
matrix.push({
theme,
row,
size: 56,
runtime: await runtimePng(theme, row, 56),
});
}
for (const size of [32, 96]) {
const row = rows[0];
matrix.push({ theme, row, size, runtime: await runtimePng(theme, row, size) });
}
const textRow = rows.find((row) => row.label === 'Text');
matrix.push({ theme, row: textRow, size: 96, runtime: await runtimePng(theme, textRow, 96) });
}
const escapeHtml = (value) => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
const body = matrix.map(({ theme, row, size, runtime }) => {
const reference = referenceAsset(theme, row.file, size);
return `
<tr>
<td>${theme}</td><td>${escapeHtml(row.label)}</td><td>${size}px</td>
<td class="preview"><img style="width:${reference.displayWidth}px" src="${reference.url}" alt="Reference ${escapeHtml(row.label)}"></td>
<td class="preview runtime"><img src="data:image/png;base64,${runtime}" alt="Runtime ${escapeHtml(row.label)}"></td>
</tr>`;
}).join('');
const html = `<!doctype html>
<html><head><meta charset="utf-8"><title>Device icon reference/runtime matrix</title>
<style>
body{margin:24px;background:#777;color:#111;font:16px system-ui,sans-serif}
h1,p{max-width:1100px} table{border-collapse:collapse;width:100%;background:#aaa}
th,td{border:1px solid #555;padding:8px;text-align:left} th{position:sticky;top:0;background:#ddd;z-index:2}
.preview{width:38%;text-align:center;background:linear-gradient(135deg,#d5d5d5 50%,#666 50%)}
.preview img{display:block;margin:auto;max-width:300px;max-height:180px}.runtime img{image-rendering:auto}
</style></head><body>
<h1>House Plan device icons: package 1.1.1 vs runtime</h1>
<p>Issues #211/#217. Reference SVG is loaded directly from the designer package; Runtime is a fresh browser capture. Default covers 32/56/96 px and Text has an additional large 96 px row so its outer stadium curvature is reviewable. Dark Unlock is evaluated using the owner's amber override from #179.</p>
<table><thead><tr><th>Theme</th><th>State/layout</th><th>Core</th><th>Reference SVG</th><th>Runtime</th></tr></thead>
<tbody>${body}</tbody></table></body></html>`;
const htmlPath = resolve(artifactDir, 'device-icons-reference-runtime.html');
writeFileSync(htmlPath, html);
await page.setViewportSize({ width: 1600, height: 1000 });
await page.setContent(html, { waitUntil: 'load' });
await page.screenshot({
path: resolve(artifactDir, 'device-icons-reference-runtime.png'),
fullPage: true,
});
await browser.close();
console.log(`OK device icon reference/runtime matrix: ${htmlPath}`);
+71 -11
View File
@@ -3,11 +3,10 @@ import { createHash } from 'node:crypto';
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { visualFingerprint } from '../../scripts/source-fingerprint.mjs';
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
import { launch } from '../serve.mjs';
import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from './screenshots.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const OUTPUT = resolve(ROOT, 'docs/images');
@@ -17,6 +16,75 @@ const INTEGRATION_BUNDLE = resolve(ROOT, 'custom_components/houseplan/frontend/h
const SCRIPT = fileURLToPath(import.meta.url);
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
export const DOC_SCREENSHOT_VERSION = 1;
export const DOC_SCREENSHOTS = Object.freeze([
{
id: 'view-desktop', file: '01-view-desktop.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'view-touch', file: '02-view-touch.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true, kiosk: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 390, height: 760 }, capture: 'page',
},
{
id: 'space-create', file: '03-space-create.png', fixture: 'empty', noFloors: true,
title: 'House Plan', language: 'en', theme: 'dark',
viewport: { width: 900, height: 850 }, capture: 'page', expectDialog: true,
},
{
id: 'room-contour-close', file: '04-room-contour-close.png', fixture: 'visual',
space: 'golden-geometry', mode: 'plan',
wallJunctionPreview: {
path: [[0.18, 0.18], [0.40, 0.18], [0.40, 0.40], [0.18, 0.40]],
pointer: [0.18, 0.18], cms: [440, 440, 440], cm: 15,
},
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'plan-context-tray', file: '05-plan-context-tray.png', fixture: 'visual',
space: 'golden-geometry', mode: 'plan', editorTray: 'plan-selection',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'device-editor', file: '06-device-editor.png', fixture: 'visual',
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
deviceName: 'Living-room ceiling light',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
},
{
id: 'device-display-preview', file: '06-device-display-preview.png', fixture: 'visual',
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
deviceName: 'Living-room ceiling light', devicePresentationPreview: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
},
{
id: 'background-editor', file: '07-background-editor.png', fixture: 'visual',
space: 'golden-geometry', mode: 'decor', editorTray: 'decor-selection',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'room-card', file: '08-room-card.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'room-card',
},
{
id: 'device-info', file: '09-device-info.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', dialog: 'device-info',
deviceId: 'golden-light-two', deviceName: 'Living-room ceiling light',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 900 }, capture: 'page', expectDialog: true,
},
]);
const roomCardClip = (page) => page.evaluate(() => {
const card = window.__goldenCard;
@@ -111,12 +179,7 @@ const browserErrors = [];
page.on('pageerror', (error) => browserErrors.push(error.message));
try {
// Свежесть бандла проверяется строго, вместе с версией: картинки обязаны
// приехать из бандла, собранного из ЭТОГО дерева. А в манифест пишется
// версионно-нечувствительный отпечаток (#245) — номер версии на скриншотах
// не виден, и требовать из-за него пересъёмки нечестно.
await assertFreshDemoBundle(page, ROOT);
const fingerprint = visualFingerprint(ROOT);
const fingerprint = await assertFreshDemoBundle(page, ROOT);
const scenarios = {};
for (const scenario of DOC_SCREENSHOTS) {
await prepareGoldenScenario(page, scenario);
@@ -144,9 +207,6 @@ try {
const manifest = {
version: DOC_SCREENSHOT_VERSION,
fixture: 'synthetic-only',
// Кто снимал. Смена браузера переписывает все картинки без содержательных
// изменений (#246), поэтому окружение съёмки — часть доказательства.
chromium: browser.version(),
sourceFingerprint: fingerprint,
captureScriptSha256: sha256(readFileSync(SCRIPT)),
command: 'npm run build && node demo/docs/capture.mjs',
-75
View File
@@ -1,75 +0,0 @@
/**
* Каталог сценариев съёмки документации. Отдельным модулем, потому что его
* читают трое: сам капчур, `scripts/check-docs.mjs` и приёмка артефакта
* `scripts/docs-accept.mjs` (#246). Импортировать его из `capture.mjs` нельзя —
* тот скрипт при импорте поднимает браузер и снимает картинки.
*/
export const DOC_SCREENSHOT_VERSION = 1;
export const DOC_SCREENSHOTS = Object.freeze([
{
id: 'view-desktop', file: '01-view-desktop.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'view-touch', file: '02-view-touch.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true, kiosk: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 390, height: 760 }, capture: 'page',
},
{
id: 'space-create', file: '03-space-create.png', fixture: 'empty', noFloors: true,
title: 'House Plan', language: 'en', theme: 'dark',
viewport: { width: 900, height: 850 }, capture: 'page', expectDialog: true,
},
{
id: 'room-contour-close', file: '04-room-contour-close.png', fixture: 'visual',
space: 'golden-geometry', mode: 'plan',
wallJunctionPreview: {
path: [[0.18, 0.18], [0.40, 0.18], [0.40, 0.40], [0.18, 0.40]],
pointer: [0.18, 0.18], cms: [440, 440, 440], cm: 15,
},
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'plan-context-tray', file: '05-plan-context-tray.png', fixture: 'visual',
space: 'golden-geometry', mode: 'plan', editorTray: 'plan-selection',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'device-editor', file: '06-device-editor.png', fixture: 'visual',
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
deviceName: 'Living-room ceiling light',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
},
{
id: 'device-display-preview', file: '06-device-display-preview.png', fixture: 'visual',
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
deviceName: 'Living-room ceiling light', devicePresentationPreview: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
},
{
id: 'background-editor', file: '07-background-editor.png', fixture: 'visual',
space: 'golden-geometry', mode: 'decor', editorTray: 'decor-selection',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'room-card', file: '08-room-card.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'room-card',
},
{
id: 'device-info', file: '09-device-info.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', dialog: 'device-info',
deviceId: 'golden-light-two', deviceName: 'Living-room ceiling light',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 900 }, capture: 'page', expectDialog: true,
},
]);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

+71 -88
View File
@@ -1,104 +1,87 @@
{
"schema": 1,
"matrixVersion": 38,
"acceptedAt": "2026-08-22T23:47:18.565Z",
"sourceFingerprint": "bbcf69875d25a1a6027c2ee32d4863dd63da90cf058cca41d1c29f05acdedd33",
"matrixVersion": 31,
"acceptedAt": "2026-08-19T16:39:29.503Z",
"sourceFingerprint": "d41a4e02ccbb4b2025c4a7245d40e4f3c9551622fea4df6f5c3778ca840358cf",
"chromium": "151.0.7922.34",
"scenarios": {
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
"isometric-geometry-view-dark": "d4b3918e0597dee2e059153d98812a5035da0f82da20e43ae810b83b8e0ac965",
"isometric-geometry-view-light": "e68183fed65d263a2bd80e7600f548a4ab03c03cbfcffd2abea0e011fec0f353",
"isometric-live-layers-dark": "90fa1c0b64a12626e481c5f34e515c53b3e06cd657f5976bb8a72127aec3e1e9",
"isometric-no-borders-dark": "a0abf9fdc31092980de3bcbf8598844b6e3d152363694e9bd070d45af73c26d9",
"isometric-touch-kiosk-dark": "d3ae363165c4f0aba65bcbac9bd6bdca79f29aca03bba997f3abec1860eb1e55",
"isometric-large-warm-remount-dark": "b53aec1bdb466be97a21250db914b88535449321967ecc80c4bbe749a2af82b5",
"geometry-view-dark-fit": "e680793d24a575727d5b87910e824f9ba1fbdb9d7a0c6f60b8bfe839afe73a98",
"geometry-view-light-fit": "8a2fa3c2c7f28cb55fb3119b645fa68b92dbda71706642e38dba569a7f770eea",
"room-label-parity-view-dark": "b35675251b85144bca1bc15ed7bf27a4dd1eaa0bfc288e3e3b0d1cdefac79ae5",
"room-label-parity-plan-dark": "64827483632ce5b66e2ae5cdf7c6858ca7062c3499b1967e8ca31ebca0cb5af1",
"room-label-parity-view-light": "789f8f3b6211d35e70021094f2063dc9c6ac57599d9d64743a33b7ae9dd868c2",
"room-label-parity-plan-light": "0f98689d2fbc21e14a321918ab46c35a9d8463d7db68d78f3c5b00d026c9cc50",
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
"day-cycle-dawn-dark": "aa332443ebe5e93b11c569cd4804a0995e1e76b7a5b6aa6844d001a0f6c12991",
"day-cycle-day-dark": "af1ab469144fbb3c73d99ff2817f86ec9b8704eadfaef83905cac645eb2eab07",
"day-cycle-dusk-dark": "19fdca90fbd9c902bc734b3dc4e5e50c1aa06b22a6e8a19079ad35cd86beb1fb",
"day-cycle-night-dark": "b5530a74422e577db90b340a395c0c097907244b330c535169a8e5c4edc8c24b",
"geometry-plan-editor-dark": "9da7bacff03d7a159507587355f2251d28f0025c8ff6186a7924001b49679a1c",
"space-tab-drop-before-light": "c9f79192a38f70d16fb92fbbe925f411bede05594d703c2bc74c28a96141a495",
"space-tab-drop-after-dark": "0def812dba49350d1c9d0e8405e6ce51454e9e33f83126bc4a85ac97f7e70066",
"plan-snap-endpoint-light": "cd6592803c6bdebd8ebb80a70ab2345ec3da5f2967dc630eca267e846e3aee7f",
"plan-snap-line-gaps-dark": "fb298a4149e062baf4e788cc6e38f6f18a8cb9cff5631848c061c7884771a4ac",
"wall-junctions-plan-preview-light": "1482b01209a796f93ebc23e231b33fe869ee96b6f46d1fb2d32ccf677a94e602",
"wall-junctions-plan-t-dark": "7a7e47db083a737669d4c803b4552bbe7c8700615cde5edaea5b8c553f9345d1",
"isometric-geometry-view-dark": "9bc8eb0da8746bddc0b5245b337eef7477d4ce6fb39ac576cab1e49057fd837c",
"isometric-geometry-view-light": "144f1cc3107562bc252cdcb54165f2c92569f4cf3484554148a7fdb78de372ec",
"isometric-live-layers-dark": "c841269f672c7ad8b208a549cc87592bd26f2a9e226f793a2b79592b65ee54c6",
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
"isometric-touch-kiosk-dark": "745482a797e236058e3ddd56cb5d85d700c9333a3aeb764333addeaba40cf6b0",
"isometric-large-warm-remount-dark": "0afc1069d334f10be2d0ed135ee0eb52e9272a1a1ad1224e00f05753b8789d0c",
"geometry-view-dark-fit": "438817c56cd8f91ef63778a3d3d22a4ee1fb675064bc24bf613fb752d6c503d9",
"geometry-view-light-fit": "667d38fd55a946ea6930b8674c4036e75c261ba4f111f893367ae72ed19500fc",
"room-label-parity-view-dark": "77282827c22fc5522df0caaf6a7675215cfd17674f3161a3e9515118adcea67f",
"room-label-parity-plan-dark": "e782f0386a6b1e75c7bb9f135e9ec01a9ff4b540f0384d518c7ea9f2198683ea",
"room-label-parity-view-light": "0e01112e10e0d481a7482ba91227cda2ac0f5ab08dd37bbe12f0d9677bb02352",
"room-label-parity-plan-light": "84afb43102cebb53bede8d2af2dac43506c0a7c69ca329df2cf5dc57ab3e1dbd",
"washer-active-cycle-dark": "7597ffad91165f2f3cc9647e5081c03d4ffd722313bf74f8dcf5fe6dc52d1dcf",
"washer-idle-cycle-dark": "6b1de9786f4852659d2d453fa39340626a7944417f88c5a37355b62c458b6ac1",
"day-cycle-dawn-dark": "289e6edd6c206308d3257775b2948085a24698398995bc6ce72201745705c58c",
"day-cycle-day-dark": "df27409e83f4492d3f69dc6cebf2a6e7799e154698425b17fb3ab8cfddb426a6",
"day-cycle-dusk-dark": "9798e9dac27e69727adbe9c9a782a3bd3dc5dd0850e7a49852c069d54b382f9a",
"day-cycle-night-dark": "1abf9528ac05dc3a963eb19a8b3ed4b7bb9ce647f7b83dab4fd2e736756cd770",
"geometry-plan-editor-dark": "a3828bdd877c23d283dcd30dc88287c2104c8d39e849aa826a76190d7daa30ae",
"plan-snap-endpoint-light": "2df0bd2abcb615d6bd1a4422b29747f95a1edd7b02d4ae3a6b333e24b27e9f78",
"plan-snap-line-gaps-dark": "a29d42ae49a0a513dc36e933bdd2b5f3cb97aa40b0e7ec897c602fe65a5a5fe8",
"wall-junctions-plan-preview-light": "cdb96edabd5b04a47e5026eed4255e06b802bd8ba7f0c7ba2cab66c4ae2f371a",
"wall-junctions-plan-t-dark": "ec2b29ca87bba9b41e4c22ce94d2c4fe06c6d9c0833152d3a6224371763ae6bc",
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
"junction-patch-resilience-plan-dark": "ac3e1818d3e85e8fd4e2a24695deb4461ee8b7f49a87c29528c95a352ef93a8d",
"junction-patch-resilience-plan-dark": "c42203eea92d2bc972eed829aa726ad51f36fc76d0ae049730d0f4155ef8efbf",
"junction-patch-resilience-view-dark": "d531f2a01def73fb074e55543c8543112af158df65f24dc549e0da5a23f1af40",
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
"opening-placement-door-thick-wall-dark": "3f76b530f29d3aeaecdbfc5a98f5543dc3448198529b32eb0ebd75e812fcba5a",
"opening-placement-passage-thick-wall-dark": "5449cefb21e9c17713b1723d85a80f307c7abd81f305e7e0ac654ddddcc919f3",
"opening-placement-passage-thick-wall-light": "616c9977489b8c036fa95d41b50f5f710319e27cb09376a3917c08b3b48986e0",
"opening-symbol-room-wall-light": "403f1c6e1c59122331fbe99019a3ebd80de4106dc6d33f5f5f33feb748229645",
"opening-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
"opening-symbol-flip-pairs-light": "6d780d1d06909e6e5a7ebb35987b9681d6902d78408e974dfe2fe587521990ce",
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
"geometry-devices-editor-dark": "209e602446ed79de7f7c5931f96e7d8dc0fdeb311083f01ef73b4df9c5d9e1b6",
"geometry-decor-editor-dark": "3a98ded3a0c13306bbb61681dec12879c9baec5e2969a6fb06188693f1de32b7",
"tray-wide-selection-en": "a4f275a1cf737eb186d281edc73d9ddc54d9d582dfd7b310433b5be44941106e",
"tray-wide-tool-ru": "f9dc7e76ebbe13fbba81938a2164244c6cba74e4ef14d716a4dab090ecaf600b",
"tray-medium-group-en": "42e007eeb83f1745a2040fd5d331aedcf1e8bb87b9f07835e92e744cc5e41190",
"tray-medium-selection-ru": "951c3c68516f8d400dd5bb6d5eddfb1fda2b29923281c8c73da342ba55f22408",
"tray-narrow-palette-en": "405db03787009eccf3faf7dd93e0b7f557d27d66f02fc5dcb815883bf673a3b7",
"tray-narrow-tool-ru": "fce66752a474bc5a330306fad21fc42a0f863576fb73b770cb03423fcbfb6a24",
"geometry-diagonal-45-opening-dark": "41b62c9f308b5d8d2e97d4e4d26170c66f40470c11c0ed6df16d6ff7281cb49e",
"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": "73a51bf46b42869d5c333bb42fa17d7a3570e2c95ebef9cb740f353703433b59",
"device-icon-state-table-dark": "7cc888cd2f8f3359cf86171dc7152cc94bea0126b82a4626af4d182c33ea37ce",
"device-text-shell-long-light": "97a113b5164f373b4af2922c8e71bc1f063d8c2761b72c3182697fcb1aac3cab",
"device-text-shell-long-dark": "c8fe32aced704c7e7562f0c8499183fad848a977063ab731401cc01e7f20ef38",
"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": "6ed270f62e7c1cb5e28e800cae46b4839987467bf6b2933c1af212f9b716f2fe",
"large-house-zoom-040-dark": "71847674ec42b0dac55208e9571635bee50fb20485d4e31f797df3cef5b42b37",
"large-house-zoom-250-dark": "7acd4cc3b3dfa2124f7c6839e78c9539dcb224af47a321049095cd7ff9e3de68",
"large-house-warm-remount-dark": "2532c6e438b219a2bebeab3658b184c3b6d2f5401fd485a660d96f95d32f382a",
"device-dialog-desktop-en": "a6637997458ef1def3d30ffaccb6bbc9728dbc4ba2f02400a7334915a8101bbe",
"opening-placement-door-thick-wall-dark": "c398f53391bc697e859ba389bac0ce586526d6f14c85f32d9a4b314bebe902ab",
"opening-placement-passage-thick-wall-dark": "98bb63f72886986d71d598fffcaa4fb8e7dce3e7f3a27b5afb6012f9eac4537c",
"opening-placement-passage-thick-wall-light": "2f19063a0e792c7bec92f3e4069739954dcdd79eb4933355a575aec5463b9ade",
"geometry-devices-editor-dark": "2ec2647797c71f6c6406f6e33fcbf70d5cb6d36a01a65c70ecabc7ad11e86183",
"geometry-decor-editor-dark": "46798a59d99982c4e73596861e20b6e466f6e7aa1d70a26bf917e1bd3bf73cb1",
"tray-wide-selection-en": "457b53c4e5ae5f691457f98f3c2fd255e5b7ed1c599af92c303c93c11f87b7d4",
"tray-wide-tool-ru": "1fcc25ee374c95f51c71b33720daa37f82a92ccd52119b8bdb48de5484591e49",
"tray-medium-group-en": "3bebd852929ff67cff11827d09c4859e32e17fa0be36fcabd4a591c4728a4827",
"tray-medium-selection-ru": "e40dcdadf3ebdbec200175dc6648382daa91a232d081c8add1f6646c7d1a0ee5",
"tray-narrow-palette-en": "861adc403fbf0aff1e45b27fc07c4856f3ca409b58ef87cc4a81d870a09dbb9a",
"tray-narrow-tool-ru": "ccf11295b104cecd4bf1e1bc95417003e2c40cfba29b5555ac9ba021b90d16a6",
"geometry-diagonal-45-opening-dark": "dd93866af62313806a4444b707943c693e2b7709367156ebdc3b80168233bbe8",
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
"lighting-glow-sun-dark": "a98eee332f25a43c8d9d126c118c8cfea4ee73752b1060227548efedaf0efcdd",
"device-value-badge-positions-dark": "1ad43f2bd866733aa75c34de38fb97d469661799b540a8ae22150067d788cec8",
"lighting-sun-window-state-only-dark": "3bd581a23a2e0ebba58530db5182adea5cba6ec10bc032bee024415c19108a17",
"lighting-fill-light-axis-split-dark": "4f867528aeb9124229f81659876b03ff297a3a7a92bc57ffb32d5c24913c4938",
"lighting-fill-temp-axis-split-dark": "e0535b70701c9fe6753f74c943b9f288a0fb1a23a9cfc8590d4945e0a1724eb5",
"lighting-fill-lqi-axis-split-dark": "485ab183144913569ddc11154553ab4ac7db522ab188de74d652b717dc786d9c",
"lighting-temp-glow-dark": "ecaed039fb6aab4e1fdc9f1856c89e5f563219b8ff813877027737cecbeca41a",
"lighting-temp-glow-light": "5bc8a35aaa94c427d465d198f1cd5eecfdc704f5abeded9e407f32ce93aa7d32",
"lighting-custom-glow-dark": "899e334dfc0d3490ca291b7239bf7b88ca9d1ef3be199ddf347314bab1513f27",
"lighting-opaque-glow-two-doorways-dark": "413f5a8e39193ba941f72955a391ccac954c09f24322b7bf67494c7691275980",
"lighting-custom-glow-light": "266bba4ae1744a884b2cd224b37fdc36447d57405a1c5298ca30027e2957cc8a",
"lighting-temp-glow-no-sources-dark": "5100c81543fc30a7934a6db6f9e67e2c4fa185df0a974be5911879e43c0d3fc9",
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
"hover-nested-room-dark": "2db78e53a76fa9b7cdc4597f23fc2c8ae439e5a56d82895109ca968d73075640",
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
"device-dialog-desktop-en": "343b79ef61e1fb71906bce79db50d4d1d73b2af09ca86ed550fd91c7e2b03164",
"device-dialog-mobile-ru": "c90be98e65c37963fd4443e413848ca68a412c9ee568d648aba76bf9232ee59d",
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
"toggle-entity-dialog-desktop-en": "013e2d01382435d995e8deece19e07e9bcced4fe80952cbab765e642b3d641d6",
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
"device-help-popover-light-ru": "add3275d74ced358a184ec9f864c83735f1196e2bff7d75fb3e12160cb85bfaf",
"decor-color-popover-mobile-ru": "fcdd138e396654e3517927c27d2062663c61860e8b9903471fa44651a4cbdefd",
"decor-color-popover-desktop-en": "46e78386308cd109e25e7b41967421dfa4fbbfb4639710c59e9cc2d88a10fde0",
"general-color-popover-desktop-en": "008865918113e05847a7ad8ae0fdc068b5326acf1c591d855806b333084978a3",
"device-help-popover-light-ru": "3f14b5f159d4911f01f3a5d8f9db579bbc5da617a46653fe2d98c1e612a3f05c",
"decor-color-popover-mobile-ru": "a731bbd8c794457fceb6f281124c20ef403fc3a50d2e3d6a640ad6389e90eeae",
"decor-color-popover-desktop-en": "50f8816484d1767d8c0eabf117151a7ff2be75272b3509d9a13221855ad02b72",
"general-color-popover-desktop-en": "da85d0159802afce90fd87ecf940ef1b97ffd57c27fbdc79469cc11082b9a482",
"device-ripple-color-popover-mobile-ru": "3ab12c339106004c76fe3c7741d242269e157c40ddfa29afd8f13b445b0a7023",
"space-room-color-popover-desktop-ru": "99fbe17ab8ec383988b944f7db72eeb4e933cab18b0020350ae44befd0b9ae44",
"backup-full-preview-desktop-en": "434567d8f97cb09bf217c353bb38e3c4e3aa4c1b837c722a946d75c2c40f34af",
"backup-plan-only-export-desktop-en": "8cb234a8d71be11acc6e7bae775f409d3c4b41705072c610f13f40dde06c9c0d",
"backup-space-preview-mobile-ru": "698e81b952a26e50b60d2ec85321c43035ff4376a5a348eeed39726804cead78",
"optimize-preflight-dialog-dark-en": "d42cab423d2838b7592b661548f7d524d6403db5258419e635733af9ffdda685",
"optimize-preflight-dialog-light-ru": "d8262ed5bf93347c4727365aab71897dcb2f24f0c8196e4fcdcbf43b07919b5a",
"optimize-orphan-references-dark-en": "6071c61b90243fc10ff178a4ba8bb965515b3c955a184d1039ba257158d90625",
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
"space-room-color-popover-desktop-ru": "a5d62d68097af1ff5d2556be0c889efc25768ffc61af5d302f0b5775e082f296",
"backup-full-preview-desktop-en": "6f0cfecf587b73f38088d414f489b67cc68ed71c37c96d4e307e66f6d97bda9f",
"backup-plan-only-export-desktop-en": "1e1c8a9cc5383394b91e24af8342d92103a5e85b3e5c9b53c45ab0e9b0d57da4",
"backup-space-preview-mobile-ru": "a4719cbe29b378ef7baa63bb7ff23e201b2943025008d939f2c08cee63bb9038"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

After

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

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 336 KiB

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