Compare commits

..
Author SHA1 Message Date
claude[bot] 0ba651a558 docs: review document for #29
Issue: #29
User-Visible: no
2026-08-27 23:17:50 +00:00
Matyshandclaude[bot] 8572d43c91 fix: align device catalog with plan rooms
Issue: #29
User-Visible: yes
2026-08-27 23:05:45 +00:00
claude[bot] 2f6c79c4aa docs: review document for #29
Issue: #29
User-Visible: no
2026-08-27 23:05:45 +00:00
Matyshandclaude[bot] 4b3b71e73b feat: add device lifecycle catalog
Issue: #29
User-Visible: yes
2026-08-27 23:05:45 +00:00
claude[bot] ef1320b6b7 docs: review document for #29
Issue: #29
User-Visible: no
2026-08-27 23:05:45 +00:00
Matyshandclaude[bot] ba434693be docs: preserve hidden device ghost mode
Issue: #29
User-Visible: no
2026-08-27 23:05:45 +00:00
claude[bot] ce75899ec5 docs: review document for #29
Issue: #29
User-Visible: no
2026-08-27 23:05:45 +00:00
Matyshandclaude[bot] fcf30073e9 docs: update device inbox lifecycle spec
Issue: #29
User-Visible: no
2026-08-27 23:05:45 +00:00
1368 changed files with 33209 additions and 221602 deletions
-10
View File
@@ -31,16 +31,6 @@ fi
status=0
# Локальный набор гейтов (#343). Выключен по умолчанию намеренно: 20-45 секунд на
# каждый пуш, включая пуши одной строки документации, — цена, которую стоит
# платить осознанно. Документация: docs/TESTING.md.
if [ "${HP_PREPUSH_GATE:-}" = "1" ] && [ -f "$repo_root/scripts/pre-push-gate.mjs" ]; then
echo "pre-push-gate: HP_PREPUSH_GATE=1, прогоняю локальный набор" >&2
if ! node "$repo_root/scripts/pre-push-gate.mjs" >&2; then
status=1
fi
fi
while read -r local_ref local_sha remote_ref remote_sha; do
# Deleting a remote branch pushes nothing to examine.
if [ "$local_sha" = "$zero" ]; then
+7 -76
View File
@@ -9,12 +9,6 @@
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
# попадают в репозиторий через явное решение, а не через бота.
#
# Снимать здесь больше не обязанность, а удобство (#401). Приёмка проверяет не
# место съёмки, а её воспроизводимость: каждый кадр, не объявленный изменённым,
# должен совпасть с закоммиченным байт-в-байт. Эта джоба потому и удобна, что
# среда у неё та же, в которой снят закоммиченный набор, — но принять получится
# из любой, где кадры воспроизводятся, и не получится ни из одной, где нет.
name: Скриншоты документации
on:
@@ -54,89 +48,26 @@ jobs:
run: npx playwright install chromium
- name: Build the bundle the screenshots must come from
run: npm run build
# oxipng без потерь снимает с набора ~19% (замер в #345 на pyoxipng 9.1.1;
# у 10.2.0 пресеты уровней перебалансированы, точная доля может отличаться,
# но кадры остаются пиксельно идентичными в любой версии — это перепаковка).
#
# Пин версии и контрольной суммы намеренно, а не `apt-get install oxipng`:
# пакет из образа раннера может пропасть или переехать, а падение шага
# съёмки стоит целого цикла приёмки. Тот же урок, что с azure-зеркалом
# Playwright (#175, #206).
- name: Установить oxipng
env:
OXIPNG_VERSION: 10.2.0
OXIPNG_SHA256: b33f84c73d42cb592bea5d84c431030b1e97784817693380dfcec7d9575f871e
run: |
set -euo pipefail
asset="oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
curl -fsSL -o "$asset" \
"https://github.com/oxipng/oxipng/releases/download/v${OXIPNG_VERSION}/${asset}"
echo "${OXIPNG_SHA256} ${asset}" | sha256sum -c -
mkdir -p "$HOME/.local/bin"
tar -xzf "$asset" --strip-components=1 -C "$HOME/.local/bin" \
"oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/oxipng" --version
# Съёмка идёт дважды и сравнивается по хешам (#422). Прежний замер
# (`--stability`) отвечает на вопрос «плавает ли кадр от времени внутри
# страницы» и остаётся ниже; дефект #410 был по другой оси — обрезка
# плавала МЕЖДУ прогонами, и три снимка внутри одного процесса совпали бы
# всегда. Проверка, объявленная гарантией воспроизводимости, на
# собственном инциденте промолчала бы.
#
# Второй прогон заодно оставляет в `docs/images` кадры, которые и уедут в
# артефакт: сравнивать хеши и публиковать разные файлы было бы странно.
- name: "Съёмка воспроизводима между прогонами (#410, #422)"
run: node scripts/capture-determinism.mjs
- name: Capture
run: node demo/docs/capture.mjs
# Вердикт до всякой приёмки. Само число изменившихся файлов ничего не
# говорит: набор, снятый другим браузером, меняет их все, и это нормально
# ровно один раз — при переходе на канонический прогон. Сравнивать надо
# браузер: тот же Chromium и десять изменившихся картинок означают, что
# изменился продукт (или что-то не так), другой Chromium — ожидаемую
# разницу рендеринга.
# Хеши печатаются в лог, а не только уезжают в артефакт (#410): чтобы
# сравнить два прогона одного и того же SHA, нужен текст, который видно с
# экрана. Именно так измеряется недетерминированность съёмки — и её
# отсутствие после починки.
# Съёмка обязана быть воспроизводимой, и это проверяется, а не
# предполагается (#410): три снимка на сценарий в одном процессе, без
# правки состояния между ними, обязаны совпасть побайтово. Шаг падает,
# если кадр снова начнёт зависеть от времени.
#
# Шаг стоит ПОСЛЕ съёмки и до вердикта: публикацию артефакта он всё равно
# блокирует падением job'а, а снимать набор третий раз ради порядка строк
# в логе незачем.
- name: "Кадр не плавает внутри одного состояния (#410)"
run: node demo/docs/capture.mjs --stability=3
- name: Хеши кадров
run: |
for f in docs/images/*.png; do sha256sum "$f"; done
- name: Вердикт
run: |
# Поле манифеста «до» — из закоммиченного состояния, «после» — из
# свежего. Читается одинаково для браузера и для упаковщика: оба
# переписывают все кадры сразу, и различить их причины обязан вердикт,
# а не человек по памяти (#345).
field() {
node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).$1||'')}catch{console.log('')}})"
}
git status --porcelain docs/images
changed=$(git diff --name-only docs/images | grep -c png || true)
before=$(git show HEAD:docs/images/screenshots.json | field chromium)
before=$(git show HEAD:docs/images/screenshots.json | node -e \
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).chromium||'')}catch{console.log('')}})")
after=$(node -e "console.log(require('./docs/images/screenshots.json').chromium)")
packer_before=$(git show HEAD:docs/images/screenshots.json | field oxipng)
packer_after=$(node -e "console.log(require('./docs/images/screenshots.json').oxipng || '')")
echo "--- изменившихся PNG: $changed"
echo "--- Chromium: было «${before:-не записан}», стало «$after»"
echo "--- oxipng: было «${packer_before:-не записан}», стало «${packer_after:-нет}»"
if [ "$packer_before" != "$packer_after" ] && [ "$changed" -gt 0 ]; then
echo "ВЕРДИКТ: изменился упаковщик, поэтому переписаны все кадры сразу."
echo "Это перепаковка без потерь: пиксели те же, размер меньше на ~19%."
echo "Ожидаемо один раз — при включении oxipng либо при смене его версии."
echo "Проверить можно сравнением декодированных кадров, а не байтов."
elif [ "$before" = "$after" ] && [ "$changed" -gt 0 ]; then
echo "ВЕРДИКТ: тот же браузер и тот же упаковщик, а картинки изменились —"
echo "изменился продукт. Смотрите на кадры: если изменение ожидаемое, принимайте."
if [ "$before" = "$after" ] && [ "$changed" -gt 0 ]; then
echo "ВЕРДИКТ: тот же браузер, а картинки изменились — изменился продукт."
echo "Смотрите на кадры: если изменение ожидаемое, принимайте."
elif [ "$before" != "$after" ]; then
echo "ВЕРДИКТ: браузер другой, поэтому переписаны все кадры сразу."
echo "Это ожидаемо один раз — при переходе на канонический прогон."
+4 -113
View File
@@ -31,13 +31,8 @@ on:
permissions:
contents: read
# Группа зависит от события (#472). Прежде она была одна на всё, и ручной
# запуск перед релизом отменял идущий по расписанию — так 24.08 погиб
# еженедельный прогон, а отменённый в списке выглядит «не красным». Поймать
# отмену изнутри нельзя: вместе с прогоном отменяются и не начавшиеся job,
# включая любой репортёр. Значит отмену надо не ловить, а не допускать.
concurrency:
group: mutation-gate-${{ github.event_name }}
group: mutation-gate
cancel-in-progress: true
jobs:
@@ -66,15 +61,10 @@ jobs:
- uses: actions/setup-python@v7
with:
python-version: '3.14'
python-version: '3.13'
- name: Установить backend test dependencies
run: |
# Точные версии — в tests_backend/requirements.txt (#392): без них
# резолвер выбирает набор сам, и «зелёный backend» значит разное
# в разные дни.
pip install -r tests_backend/requirements.txt
pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest '
run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
- name: Кэш браузеров Playwright
id: pw
@@ -93,104 +83,5 @@ jobs:
- name: Тёплый test-build для инкрементальной компиляции мутантов
run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
# Вывод шарда сохраняется артефактом (#472): строки
# `FAIL <id>: тест остался зелёным…` — единственное место, где названо,
# ЧТО сбежало. Без артефакта отказ безымянный. `PIPESTATUS` — чтобы
# `tee` не съел код выхода раннера.
- name: Каждый тест ловит свою поломку
run: |
mkdir -p artifacts
set -o pipefail
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}.log
- name: Сохранить лог шарда
if: always()
uses: actions/upload-artifact@v7
with:
name: mutation-shard-${{ matrix.shard }}
path: artifacts/mutation-shard-${{ matrix.shard }}.log
if-no-files-found: warn
retention-days: 30
# Адресат у отказа (#472). Только по расписанию: ручные прогоны перед
# релизом падают по замыслу, их результат владелец смотрит сам — issue на
# каждый такой отказ был бы шумом, который снова перестанут читать.
#
# Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент —
# validate.yml, job с actions: read): перечислены все три.
report:
name: "Отказ расписания: issue и Telegram"
needs: mutants
if: always() && github.event_name == 'schedule' && needs.mutants.result != 'success'
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
issues: write
steps:
- uses: actions/checkout@v7
with:
ref: dev
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Забрать логи шардов
uses: actions/download-artifact@v7
with:
pattern: mutation-shard-*
path: artifacts/mutation-logs
- name: Собрать отчёт
id: report
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# SHA — того дерева, которое чекаутили и гоняли (dev), а не
# github.sha: для расписания это вершина default-ветки main, и отчёт
# называл бы «dev @ <SHA main>» (ревью r1). Образец — process.yml.
SHA=$(git rev-parse HEAD)
mkdir -p artifacts
node scripts/mutation-gate-report.mjs \
--logs=artifacts/mutation-logs --shards=4 \
--run-url="$RUN_URL" --ref=dev --sha="$SHA" \
--body-out=artifacts/mutation-report.md \
--telegram-out=artifacts/mutation-telegram.txt >> "$GITHUB_OUTPUT"
# Одно issue, а не одно на неделю: открытое с тем же маркером в заголовке
# получает комментарий, новое заводится только если открытого нет.
- name: Issue — создать или дописать
id: issue
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
MARKER: ${{ steps.report.outputs.marker }}
TITLE: ${{ steps.report.outputs.title }}
run: |
existing=$(gh issue list --repo "$REPO" --state open --search "\"$MARKER\" in:title" \
--json number,title --jq '[.[] | select(.title | startswith(env.MARKER))][0].number // empty')
if [ -n "$existing" ]; then
gh issue comment "$existing" --repo "$REPO" --body-file artifacts/mutation-report.md
url="${{ github.server_url }}/$REPO/issues/$existing"
else
url=$(gh issue create --repo "$REPO" --title "$TITLE" \
--label infra --label process --label tests \
--body-file artifacts/mutation-report.md)
fi
echo "url=$url" >> "$GITHUB_OUTPUT"
echo "issue: $url"
# Тот же канал, что у релизов (announce.yml). Нет секретов — не отказ:
# issue уже заведено, а Telegram — второй адресат, не единственный.
- name: Telegram
if: always() && steps.issue.outcome == 'success'
env:
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
ISSUE_URL: ${{ steps.issue.outputs.url }}
run: |
if [ -z "$TOKEN" ] || [ -z "$CHAT" ]; then
echo "::warning::TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID не заданы — оповещение пропущено, issue заведено"
exit 0
fi
TEXT=$(sed "s|(issue)|$ISSUE_URL|" artifacts/mutation-telegram.txt)
curl -sS --fail-with-body -X POST \
"https://api.telegram.org/bot$TOKEN/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=$TEXT" \
-d disable_web_page_preview=true
run: node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4
-38
View File
@@ -1,38 +0,0 @@
# Ночной полный прогон (#479).
#
# Тяжёлые job Validate — смоки, golden, performance_smoke — на обычном пуше не
# идут: они ни разу не ловили дефект в момент ревью и стоили ~6 минут
# критического пути на каждую итерацию. Полный набор идёт на кандидате беты
# (трейлер `Release:`), по кнопке и здесь — каждую ночь на голове `dev`.
#
# Почему не `schedule` прямо в validate.yml: расписание исполняется на ветке
# по умолчанию (`main`), а проверять надо `dev`. Один dispatch с `--ref dev`
# делает это без переписывания checkout во всех job. Reuse (#208) сохраняется:
# при неизменённом дереве ночной прогон обойдётся маркерами.
#
# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт:
# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и
# там полный набор идёт заново.
name: Ночной полный прогон dev
on:
schedule:
- cron: '30 2 * * *'
workflow_dispatch: {}
permissions:
actions: write
contents: read
jobs:
dispatch:
name: "Запустить Validate на dev с полным набором"
runs-on: ubuntu-latest
steps:
- env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
gh workflow run validate.yml --repo "$REPO" --ref dev -f full=true
echo "Validate(dev, full=true) поставлен в очередь: $(date -u +%FT%TZ)"
+2 -38
View File
@@ -27,7 +27,7 @@ jobs:
name: Бенчмарки рендера и геометрии
# Every profile keeps base and candidate sequential on one hosted runner.
# Independent profile pairs run in parallel: cross-profile timing is never
# compared, while serialising all profile pairs cannot fit the job timeout.
# compared, while serialising all five pairs cannot fit the job timeout.
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -36,13 +36,9 @@ jobs:
profile:
- large-house
- isometric
- isometric-stage3
- plan-snap
- interaction
- blend
- overlay
- space-default
- space-glow
steps:
- name: Check out candidate
uses: actions/checkout@v7
@@ -188,18 +184,10 @@ jobs:
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
;;
isometric-stage3)
npm run benchmark:isometric-stage3-dense -- --allow-stage2-base --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-stage3-baseline.json
npm run benchmark:isometric-stage3-dense -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-stage3-candidate.json
;;
plan-snap)
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
;;
interaction)
npm run benchmark:large-house-interaction -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/interaction-baseline.json
npm run benchmark:large-house-interaction -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/interaction-candidate.json
;;
blend)
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
@@ -212,18 +200,6 @@ jobs:
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
fi
;;
space-default)
npm run benchmark:glow -- --profile=large-space-card-default-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/space-default-baseline.json
npm run benchmark:glow -- --profile=large-space-card-default-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/space-default-candidate.json
;;
space-glow)
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/space-glow-baseline.json
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/space-glow-candidate.json
if ! grep -q "light_pools" ../baseline/src/space-card.ts; then
echo "Base predates opt-in static Glow; bootstrap relative baseline, keep absolute gate"
cp ../artifacts/performance/space-glow-candidate.json ../artifacts/performance/space-glow-baseline.json
fi
;;
*)
echo "::error::Unknown performance profile: $PROFILE"
exit 1
@@ -240,29 +216,17 @@ jobs:
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
;;
isometric)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-comparison.json
;;
isometric-stage3)
npm run benchmark:compare -- --budgets=demo/performance/budgets-isometric-stage3-dense.json --baseline=../artifacts/performance/isometric-stage3-baseline.json --candidate=../artifacts/performance/isometric-stage3-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-stage3-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
;;
plan-snap)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
;;
interaction)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-interaction.json --baseline=../artifacts/performance/interaction-baseline.json --candidate=../artifacts/performance/interaction-candidate.json --output=../artifacts/performance/interaction-comparison.json
;;
blend)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
;;
overlay)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
;;
space-default)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-default.json --baseline=../artifacts/performance/space-default-baseline.json --candidate=../artifacts/performance/space-default-candidate.json --output=../artifacts/performance/space-default-comparison.json
;;
space-glow)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-glow.json --baseline=../artifacts/performance/space-glow-baseline.json --candidate=../artifacts/performance/space-glow-candidate.json --output=../artifacts/performance/space-glow-comparison.json
;;
esac
- name: Upload full performance report
+26 -260
View File
@@ -43,19 +43,9 @@ jobs:
spent: ${{ steps.decide.outputs.spent }}
limit: ${{ steps.decide.outputs.limit }}
steps:
# Мелкий checkout: guard остаётся лёгким, но ему нужен
# scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что
# inline-shell не покрывается тестами (#454). Node на раннере
# предустановлен, setup-node не нужен.
- uses: actions/checkout@v7
with:
fetch-depth: 1
ref: dev
persist-credentials: false
- id: decide
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
REPO: ${{ github.repository }}
LABEL: ${{ github.event.label.name }}
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
@@ -97,78 +87,19 @@ jobs:
#
# Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал
# цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по
# имени документа — раньше по подстроке маркера в теле комментария,
# теперь по имени документа ЭТОЙ задачи, `<MARKER>-<NUM>`: голая
# подстрока протекала на прозе. #454 поймала это на себе — разбор
# чужих задач в комментарии содержал `CODE-REVIEW`, и первый же
# код-ревью получил заход r3.
#
# Счёт по комментариям остаётся ровно тем же, но он БОЛЬШЕ НЕ
# ЕДИНСТВЕННЫЙ (#454). Маркер этапа попадает в тело комментария,
# только если ревьюер сам назвал имя файла, — то есть прежний счёт
# зависел от формулировки. На #449 первый спек-вердикт файла не
# назвал, заход r2 получил номер r1, и документ второго раунда лёг
# ПОВЕРХ документа первого. Оценка «недосчёт обратим» была неверна
# ровно здесь: номер захода входит в имя файла, и повтор номера — не
# лишний заход, а потеря артефакта.
#
# Поэтому рядом встаёт второй источник — опубликованные документы:
# их имена несёт сам конвейер, и подделать их прозой нельзя. Берётся
# МАКСИМУМ двух источников: недосчёт возможен только при отказе
# обоих, перерасчёт невозможен по построению.
# имени документа в теле комментария; документа нет — вердикт не
# посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы
# работу досрочно: из двух ошибок выбрана обратимая.
attempt=1; spent=0; spent_list=""
if [ -n "$stage" ]; then
comments=$(mktemp)
gh issue view "$NUM" --repo "$REPO" --json comments > "$comments"
# Ветка задачи — та же, что выберет шаг ревью: свежая по коммиту.
# Её нет у задач, размеченных до появления конвейера; тогда счёт по
# файлам даёт ноль и работает страховка по комментариям.
branch=""; newest=""
for ref in $(gh api "repos/$REPO/git/matching-refs/heads/issue/$NUM-" \
--jq '.[].ref' 2>/dev/null | sed 's|^refs/heads/||'); do
date=$(gh api "repos/$REPO/commits/$ref" --jq '.commit.committer.date' 2>/dev/null || true)
if [ -n "$date" ] && { [ -z "$newest" ] || [ "$date" \> "$newest" ]; }; then
newest="$date"; branch="$ref"
fi
done
target="${branch:-dev}"
names=$(mktemp); docs=$(mktemp -d)
if ! gh api "repos/$REPO/contents/docs/reviews?ref=$target" --jq '.[].name' \
> "$names" 2>/dev/null; then
: > "$names"
echo "::warning::список docs/reviews на $target не получен — счёт по файлам отключён"
fi
# Каталог перечисляется одним ответом до 1000 записей; за этой
# границей ответ молча обрежется, и счёт по файлам занизится.
if [ "$(grep -c . "$names")" -ge 1000 ]; then
echo "::warning::в docs/reviews не меньше 1000 файлов — листинг contents обрезается, счёт по файлам ненадёжен"
fi
# Тела нужны только своим документам этапа: их единицы.
for name in $(grep -E "^${marker}-${NUM}-r[0-9]+\\.md$" "$names" || true); do
gh api "repos/$REPO/contents/docs/reviews/$name?ref=$target" \
-H 'Accept: application/vnd.github.raw' > "$docs/$name" 2>/dev/null || rm -f "$docs/$name"
done
list=$(mktemp)
counters=$(node scripts/review-doc-guard.mjs --counters \
--marker="$marker" --num="$NUM" --names="$names" --docs="$docs" \
--comments="$comments" --spent-list="$list")
spent_list=$(cat "$list")
# Пустой ответ означает, что скрипт не отработал. Тогда остаются
# значения по умолчанию (заход 1, циклов 0): guard обязан
# продолжить работу, а не встать.
new_attempt=$(printf '%s\n' "$counters" | sed -n 's/^attempt=//p')
new_spent=$(printf '%s\n' "$counters" | sed -n 's/^spent=//p')
new_blocking=$(printf '%s\n' "$counters" | sed -n 's/^blocking=//p')
case "$new_attempt" in ''|*[!0-9]*) echo "::warning::счётчик раундов не дал числа — работают значения по умолчанию" ;; *) attempt="$new_attempt" ;; esac
case "$new_spent" in ''|*[!0-9]*) : ;; *) spent="$new_spent" ;; esac
# Перечень учтённого обязан сходиться с числом: если цикл виден
# только документом, ссылка на комментарий его не объяснит.
if [ -n "$new_blocking" ]; then
spent_list="$spent_list
- документы: $new_blocking"
fi
echo "ветка материала: ${branch:-нет, читался dev}"
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\")")
fi
# Отказ обязан быть виден в issue, а не только в логе прогона.
@@ -295,20 +226,6 @@ jobs:
git checkout -q "origin/$branch"
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
echo "name=$branch" >> "$GITHUB_OUTPUT"
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
# ребейз меняет — содержимое нет: git адресует деревья и блобы их
# хешем. Снимаются здесь, где рабочая копия ЕЩЁ равна тому, что
# ревьюер прочтёт; в шаге публикации дерево уже сброшено на целевую
# ветку, и спрашивать его поздно.
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
# ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст
# где-нибудь достижим. Файлов может не быть (инфраструктурная
# задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо
# длиннее одного.
specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \
| awk '{print $2" "$4}' | tr '\n' ';')
echo "specs=$specs" >> "$GITHUB_OUTPUT"
else
echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev"
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
@@ -349,18 +266,9 @@ jobs:
before=$(git rev-parse "origin/$BRANCH")
echo "dev впереди на $behind коммит(ов) — привожу ветку"
if ! git rebase origin/dev; then
# Список снимается ДО abort: он же снимает состояние конфликта, и
# тогда автору достаётся «не ребейзится» без единого имени файла (#364).
files=$(git diff --name-only --diff-filter=U | sort -u | paste -sd'\n' -)
git rebase --abort || true
{
echo 'conflict=true'
echo 'conflicts<<EOF_FILES'
printf '%s\n' "${files:-(git не назвал файлы)}"
echo 'EOF_FILES'
} >> "$GITHUB_OUTPUT"
echo "conflict=true" >> "$GITHUB_OUTPUT"
echo "::warning::ветка $BRANCH не ребейзится на dev без конфликта — ревью не запускается"
printf 'конфликтуют:\n%s\n' "${files:-(git не назвал файлы)}"
exit 0
fi
# --force-with-lease с явным ожидаемым значением обязателен: между
@@ -400,17 +308,10 @@ jobs:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
BRANCH: ${{ steps.branch.outputs.name }}
CONFLICTS: ${{ steps.rebase.outputs.conflicts }}
run: |
cat > /tmp/stale.md <<EOF
**Ревью не запускалось:** ветка \`$BRANCH\` не ребейзится на \`dev\` без конфликта. Код никто не читал, вердикта нет, цикл ревью не израсходован.
Конфликтуют:
\`\`\`
$CONFLICTS
\`\`\`
Проверка стоит до ревью намеренно: конфликт всё равно вернул бы задачу, но уже после сорока минут работы ревьюера и потраченных лимитов.
Задача переведена в \`S6-in-progress\`. Осталось:
@@ -426,41 +327,6 @@ jobs:
--add-label S6-in-progress --remove-label S7-code-review
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
# Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя
# Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет
# ревью тратится на повторение CI вместо чтения кода.
#
# Доказательство здесь такое же строгое, как у reuse-маркеров (#208): не
# «недавно было зелено», а «completed success ровно на этом SHA». После
# ребейза SHA другой, прогона для него нет — и ревьюер честно гоняет сам.
- name: Зелёные гейты на этом SHA
id: validated
if: steps.rebase.outputs.conflict != 'true'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
run: |
sha=$(git rev-parse HEAD)
short=$(git rev-parse --short HEAD)
row=$(gh run list --repo "${{ github.repository }}" --workflow validate.yml \
--commit "$sha" --limit 5 \
--json status,conclusion,url \
--jq '[.[] | select(.status=="completed" and .conclusion=="success")][0] // empty')
{
echo 'note<<EOF_NOTE'
if [ -n "$row" ]; then
url=$(printf '%s' "$row" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).url||""))')
echo "**Дешёвые гейты на этом SHA уже подтверждены** (#343). Validate на \`$short\` завершился success: $url"
echo ""
echo "Значит \`npx tsc --noEmit\`, \`npm test\` и \`npm run build\` со сверкой копий бандла перегонять не нужно — сошлись на этом прогоне, назвав его ссылкой. Бюджет раунда тратится на чтение кода."
echo ""
echo "Что Validate НЕ покрывает и остаётся за тобой: смоки, выбранные по диффу; golden, если diff трогает рендер; инварианты модели на конкретной конфигурации; и любой гейт, который требуют AC задачи."
else
echo "**Зелёного Validate на этом SHA (\`$short\`) нет** — прогон не найден, не завершён либо не success. Дешёвые гейты прогоняешь сам и называешь результат."
fi
echo 'EOF_NOTE'
} >> "$GITHUB_OUTPUT"
if [ -n "$row" ]; then echo "Validate на $short: зелёный"; else echo "Validate на $short: зелёного нет"; fi
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
- name: Установить зависимости
@@ -587,10 +453,8 @@ jobs:
правке — не тщательность, а потеря времени: полные наборы это
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
${{ steps.validated.outputs.note }}
Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые,
и в повторном раунде тоже: код изменился, а стоят они минуты:
Всегда, они дешёвые, и в повторном раунде тоже: код изменился,
а стоят они минуты:
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
копий бандла. Плюс `node scripts/check-docs.mjs`, если diff трогает
`src/**`: отпечаток скриншотов документации считается по всему
@@ -712,9 +576,6 @@ jobs:
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
SOURCE: ${{ runner.temp }}/review-document.md
MATERIAL_SHA: ${{ steps.branch.outputs.sha }}
MATERIAL_TREE: ${{ steps.branch.outputs.tree }}
MATERIAL_SPECS: ${{ steps.branch.outputs.specs }}
run: |
# Ветки задачи может не быть: у задач, размеченных до появления
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
@@ -728,31 +589,14 @@ jobs:
marker=CODE-REVIEW
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
# Документ спасается ПЕРВЫМ делом. Ревьюер мог написать его по старому
# пути прямо в рабочую копию, а дальше эта копия будет отброшена
# целиком — и вместе с ней пропал бы артефакт (#220).
if [ ! -f "$SOURCE" ] && [ -f "$doc" ]; then
cp "$doc" "$SOURCE"
echo "документ найден в рабочей копии и сохранён в $SOURCE"
fi
# Reset, а не checkout+clean, и вот почему (#365).
#
# 28.08 коммит bb2919f уехал в dev с тридцатью файлами вместо одного
# markdown: откатил отревьюженную реализацию #359, вернул старые чанки
# и держал dev откаченным три часа. Механизм воспроизведён:
# `git checkout -- .` восстанавливает рабочее дерево ИЗ ИНДЕКСА, а
# `git clean -fd` убирает неотслеживаемое — ни то, ни другое индекс не
# трогает. Ревьюер работает с Bash и в ходе проверки «умеет ли тест
# падать» вполне может сделать `git add`; всё, что осталось у него в
# индексе, прежняя уборка сохраняла, и следующий же `git commit`
# забирал это вместе с документом. Сообщение при этом невинное, и от
# рутины инцидент отличается только диффом.
#
# `reset --hard` снимает и индекс, и дерево разом. Терять нечего:
# документ приезжает извне репозитория, из RUNNER_TEMP.
git fetch -q origin "$target"
git reset -q --hard "origin/$target"
git clean -fdq -e node_modules >/dev/null 2>&1 || true
# Рабочая копия отбрасывается ДО того, как документ попадёт в дерево:
# ревьюер правит код, проверяя «умеет ли тест падать», и его правки
# публиковаться не должны.
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 -- .` плюс
@@ -763,21 +607,12 @@ jobs:
mkdir -p docs/reviews
cp "$SOURCE" "$doc"
echo "документ взят из $SOURCE ($(wc -c < "$doc") байт)"
# Якоря дописывает конвейер, а не ревьюер (#414). Дисциплина здесь
# уже подводила: на #403 SHA сняли до ребейза и не сверили перед
# выводом — через раунд команда из §2.10 не работала. Машина же
# снимает якоря в момент чтения материала и ошибиться в них не
# может; блок помечен как машинный, чтобы никто не правил его руками.
node scripts/review-doc-guard.mjs --anchor="$doc" \
--sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \
--branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS"
else
echo "::warning::$SOURCE не найден — документа для публикации нет"
# Совместимость: ревьюер мог написать по старому пути, если промпт
# ещё не обновился в этой ветке.
echo "::warning::$SOURCE не найден — ищу документ в рабочей копии"
fi
# Индексируется ровно один путь, а не каталог: `git add docs/reviews`
# забрал бы всё, что там окажется, а после reset там не должно быть
# ничего постороннего — но полагаться на «не должно» здесь нельзя.
git add -- "$doc" 2>/dev/null || true
git add docs/reviews 2>/dev/null || true
if git diff --cached --quiet; then
# Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит
# документ сам, своим app-токеном мимо этого шага (CODE-REVIEW-150-r1,
@@ -795,8 +630,6 @@ jobs:
echo "::error::вердикт есть, а документа нет: ни $SOURCE, ни $doc в рабочей копии, ни $doc в $target — ревью без артефакта (#171, #220)"
exit 1
fi
# Первый рубеж: что вообще проиндексировано.
git diff --cached --name-only | node scripts/review-doc-guard.mjs
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
commit -q -F - <<EOF
@@ -805,10 +638,6 @@ jobs:
Issue: #$NUM
User-Visible: no
EOF
# Второй рубеж, и он главный: что пуш ДОБАВИТ в целевую ветку. Первый
# судит намерение шага, этот — результат, а расходились они именно
# тогда, когда база оказывалась не той.
git diff --name-only "origin/$target...HEAD" | node scripts/review-doc-guard.mjs
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
# либо dev, поэтому push нужен с явным перебазированием при гонке:
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
@@ -823,9 +652,6 @@ jobs:
echo "::error::документ ревью не удалось опубликовать в $target: конфликт (#171)"
exit 1
fi
# После ребейза набор путей другой — проверяется заново. Форс здесь
# запрещён и не появляется: ветка двигается только вперёд.
git diff --name-only "origin/$target...HEAD" | node scripts/review-doc-guard.mjs
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"
fi
@@ -839,42 +665,6 @@ jobs:
fi
echo "документ опубликован в $target: $doc"
# Материал раунда обязан быть достижим с origin (#413).
#
# SPEC-REVIEW-403-r2 объявил материал на `HEAD = 83005c3c`, и тот же SHA
# независимо назвал автор ТЗ в комментарии issue. Коммит существовал, но
# к моменту публикации был осиротевшим: ветку перебазировали за 15 минут
# ДО публикации документа, спец-коммит переехал в 94502d3d с тем же
# сообщением и тем же содержимым. Через раунд команда `git diff
# 83005c3c..HEAD` из §2.10 буквально не работала, и r3 восстанавливал
# реальный коммит по содержимому диффа руками.
#
# Проверка стоит ПОСЛЕ публикации намеренно. Артефакт ревью терялся здесь
# трижды (#171, #220), и «вердикт без документа» в этом репозитории
# дороже мёртвой ссылки: документ сначала спасается, потом судится. Шаг
# при этом идёт ДО «Переставить метку», поэтому инвариант «метка не
# сменилась = прогон упал» сохраняется.
#
# Достижимость считается от `refs/remotes/origin/*`, а не от локальных
# ссылок: осиротевший 83005c3c до сих пор лежит в клоне автора и
# достижим там из необновлённой локальной ветки. Читателю отчёта от этого
# пользы нет — он достанет только то, что есть на origin.
- name: "Материал раунда воспроизводим (#413)"
if: steps.rebase.outputs.conflict != 'true'
env:
NUM: ${{ github.event.issue.number }}
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
BRANCH: ${{ steps.branch.outputs.name }}
run: |
marker=CODE-REVIEW
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
target="${BRANCH:-dev}"
git fetch -q origin "$target"
# Судится опубликованная версия, а не рабочая копия: именно её прочтёт
# следующий раунд.
git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=-
- name: Решение по вердикту
id: decide
if: steps.rebase.outputs.conflict != 'true'
@@ -907,30 +697,6 @@ jobs:
echo "from=$from" >> "$GITHUB_OUTPUT"
echo "to=$to" >> "$GITHUB_OUTPUT"
# Ревью идёт десятки минут, а dev за это время двигается (28 августа —
# четыре раза за день). Вердикт при этом вынесен по дереву, которое уже не
# совпадает с вершиной линии, и слияние приведёт ветку к dev — то есть в
# dev уедет код, отличный от прочитанного (§7.2). Молчать об этом нельзя,
# но и шуметь на каждом прогоне ни к чему: строка появляется только когда
# dev действительно ушёл и вердикт зелёный, то есть слияние вот-вот
# случится (#364).
- name: dev ушёл вперёд, пока шло ревью
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
MATERIAL: ${{ steps.material.outputs.sha }}
GREEN: ${{ steps.decide.outputs.green }}
run: |
git fetch -q origin dev
moved=$(git rev-list --count "$MATERIAL..origin/dev")
echo "dev продвинулся на $moved коммит(ов) с момента фиксации материала"
echo "- dev продвинулся на **$moved** коммит(ов) во время ревью" >> "$GITHUB_STEP_SUMMARY"
if [ "$moved" -eq 0 ] || [ "$GREEN" != "true" ]; then exit 0; fi
short=$(git rev-parse --short "$MATERIAL")
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
"Пока шло ревью, \`dev\` продвинулся на $moved коммит(ов). Материал ревью — \`$short\`. Вердикт вынесен по дереву, которое уже не совпадает с вершиной линии: слияние приведёт ветку к dev, и это другой код (§7.2)."
# S8-merged утверждает, что код в dev. Значит слияние обязано произойти
# ДО метки, иначе она врёт в промежутке.
#
+9 -22
View File
@@ -54,23 +54,6 @@ jobs:
env:
TAG: ${{ inputs.tag }}
run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
# #479: тяжёлые job Validate идут только на коммите с трейлером `Release:`.
# Зелёный Validate без трейлера означал бы прогон без смоков и golden —
# класс тихого пропуска #171/#207, поэтому трейлер проверяется здесь явно.
- name: Require the Release trailer on the candidate commit
env:
SHA: ${{ steps.candidate.outputs.sha }}
run: |
set -euo pipefail
git log -1 --format=%B "$SHA" > /tmp/head-message.txt
if ! grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+' /tmp/head-message.txt; then
echo "::error::Candidate $SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
exit 1
fi
# #479: свежесть скриншотов на обычном пуше — предупреждение; на
# кандидате она обязана быть доказана строгим режимом.
- name: Documentation screenshots are fresh for the candidate
run: node scripts/check-docs.mjs --screenshots=strict
- name: Require green Validate for this exact SHA
env:
GH_TOKEN: ${{ github.token }}
@@ -99,13 +82,17 @@ jobs:
set -euo pipefail
npm ci
npm run build
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
npm run bundle:budget
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
VERSION=${TAG#v}
grep -RFq "$VERSION" dist
grep -Fq "$VERSION" dist/houseplan-card.js
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
node scripts/verify-houseplan-zip.mjs houseplan.zip \
custom_components/houseplan/frontend "$VERSION"
unzip -l houseplan.zip | grep -q "manifest.json"
ZIP_VERSION=$(unzip -p houseplan.zip manifest.json | node -e \
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).version))")
test "$ZIP_VERSION" = "$VERSION" || {
echo "::error::houseplan.zip manifest version $ZIP_VERSION != $VERSION"
exit 1
}
test -s dist/houseplan-card.js
test -s houseplan.zip
- name: Create or verify the annotated tag
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
run: cd custom_components/houseplan && zip -qr ../../houseplan.zip .
- name: Sanity check
run: node scripts/verify-houseplan-zip.mjs houseplan.zip custom_components/houseplan/frontend
run: unzip -l houseplan.zip | grep -q "manifest.json"
- name: Upload asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -8
View File
@@ -34,12 +34,6 @@ jobs:
# target_commitish (it may be a branch name) or an event-context SHA.
SHA=$(git rev-parse HEAD)
echo "release tag: $TAG; exact commit: $SHA"
# #479: тяжёлые job Validate идут только на коммите с трейлером
# `Release:`; без него зелёный Validate прогона без смоков не доказывает.
if ! git log -1 --format=%B "$SHA" | grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::$SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
exit 1
fi
node scripts/release-gate.mjs "$SHA"
- name: Require full performance for a stable release
if: ${{ !github.event.release.prerelease }}
@@ -73,8 +67,7 @@ jobs:
with:
name: continuity-screencast
path: artifacts/continuity-screencast
- name: Verify the complete committed frontend tree
run: node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
- run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
- name: Attach card to release
uses: softprops/action-gh-release@v3
with:
+52 -480
View File
@@ -13,22 +13,11 @@ on:
paths-ignore:
- 'docs/reviews/**'
pull_request:
# Полный набор тяжёлых job по кнопке и по ночам (#479): nightly.yml делает
# `gh workflow run validate.yml --ref dev -f full=true`.
workflow_dispatch:
inputs:
full:
description: 'Полный набор: смоки, golden, performance_smoke'
type: boolean
default: true
# A new push supersedes an unfinished validation for the same branch or PR.
# Exact-SHA release gates never depend on an obsolete commit.
# Полный прогон по кнопке/ночью (#479) живёт в своей группе: первый ручной
# запуск отменил очередной push конвейера через полторы минуты — ночной
# результат обнулялся бы любым коммитом, пришедшим в то же окно.
concurrency:
group: validate-${{ github.event_name == 'workflow_dispatch' && 'dispatch-' || '' }}${{ github.event.pull_request.number || github.ref }}
group: validate-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
@@ -43,101 +32,41 @@ jobs:
preflight:
name: "Предполётные проверки: документация, провенанс, процесс"
runs-on: ubuntu-latest
# `actions: read` — чтение списка прогонов Validate (#388), `issues: read` —
# проверка 8 процессного гейта. Права перечислены явно, потому что job
# обращается к API сверх содержимого репозитория.
permissions:
contents: read
actions: read
issues: read
steps:
# `blob:none` при полной истории (#345): этой job нужны сообщения
# коммитов, трейлеры и ИМЕНА изменённых файлов, а не содержимое старых
# ревизий. Полный клон стоит 215 МБ против 26 МБ, и 32% этого веса —
# скриншоты документации, переснятые 196 раз.
#
# Коммиты и деревья скачиваются целиком, поэтому диапазоны и `merge-base`
# работают как раньше. Единственная догрузка по требованию здесь —
# `git show origin/main:.github/workflows/process.yml`: один блоб.
- uses: actions/checkout@v7
with: { fetch-depth: 0, filter: 'blob:none' }
with: { fetch-depth: 0 }
- uses: actions/setup-node@v7
with: { node-version: 22 }
# Свежесть скриншотов на обычном пуше — предупреждение, на кандидате беты
# (трейлер `Release:`), по кнопке и на PR — ошибка (#479). Остальные
# проверки документации красят всегда. Режим считает тот же скрипт, что и
# выход `heavy` job `changes`, чтобы два места не разошлись.
- name: "Документация: гайды, ченджлоги, скриншот-индекс"
id: docs
continue-on-error: true
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
FULL_INPUT: ${{ inputs.full }}
run: |
heavy=$(node scripts/classify-changes.mjs --heavy)
mode=warn
[ "$heavy" = "heavy=true" ] && mode=strict
echo "скриншоты документации: режим $mode ($heavy)"
node scripts/check-docs.mjs --external --screenshots=$mode
run: node scripts/check-docs.mjs --external
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
- name: "Процесс: process.yml и mutation-gate.yml идентичны в main и dev"
- name: "Процесс: process.yml идентичен в main и dev"
id: workflow_sync
continue-on-error: true
run: |
git fetch --quiet origin main dev
# #472: расписание mutation-gate.yml тоже исполняется из ветки по
# умолчанию — та же ловушка, что у process.yml. Сверяются оба.
status=0
for file in process.yml mutation-gate.yml; do
if diff <(git show "origin/main:.github/workflows/$file") \
<(git show "origin/dev:.github/workflows/$file"); then
echo "$file: main и dev идентичны"
else
echo "РАСХОЖДЕНИЕ: $file в main и dev различаются."
echo "Файл исполняется из ветки по умолчанию, поэтому"
echo "правку нужно отправить в обе ветки."
status=1
fi
done
exit $status
if diff <(git show origin/main:.github/workflows/process.yml) \
<(git show origin/dev:.github/workflows/process.yml); then
echo "main и dev идентичны"
else
echo "РАСХОЖДЕНИЕ: process.yml в main и dev различаются."
echo "Конвейер исполняет версию из ветки по умолчанию, поэтому"
echo "правку нужно отправить в обе ветки."
exit 1
fi
# Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до
# #388 брали его от головы предыдущего пуша. Прогон предыдущего пуша
# штатно отменяется следующим (concurrency), и тогда его коммиты не судит
# никто: свой прогон отменён, а следующий пуш сравнивает уже с ними.
#
# База — последний предок с УСПЕШНО завершённым Validate. Фолбэк остаётся
# прежним `before`: расширять диапазон здесь нельзя, иначе гейт, который
# сам красит прогон, лишает следующий пуш зелёного предка и запирает dev
# в красноте навсегда.
- name: "База диапазона: последний доказанно зелёный предок"
id: range
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
FALLBACK: ${{ github.event.before }}
run: |
# `status=completed`, а не `success`: гейтам диапазона нужен факт
# «коммит судили», а не «вердикт был оправдательный». Упавший прогон
# коммит судил; отменённый — нет, его отсеивает judgedShas (#388).
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
-f branch=dev -f status=completed -F per_page=100 \
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
--fallback="$FALLBACK" --runs=/tmp/validate-runs.json
- name: "Провенанс коммитов: трейлеры и эталоны"
id: provenance
continue-on-error: true
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ steps.range.outputs.base || github.event.before }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEVELOPMENT_BRANCH: dev
@@ -155,7 +84,7 @@ jobs:
continue-on-error: true
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ steps.range.outputs.base || github.event.before }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEVELOPMENT_BRANCH: dev
@@ -188,7 +117,7 @@ jobs:
}
echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY"
check "документация" "$DOCS"
check "process.yml и mutation-gate.yml в main и dev" "$WORKFLOW_SYNC"
check "process.yml в main и dev" "$WORKFLOW_SYNC"
check "провенанс коммитов" "$PROVENANCE"
check "процессный гейт" "$PROCESS_GATE"
exit $fail
@@ -203,126 +132,44 @@ jobs:
changes:
name: Классификация изменённых файлов
runs-on: ubuntu-latest
# Чтение списка прогонов Validate — единственное, что этой job нужно сверх
# содержимого репозитория (#387).
permissions:
contents: read
actions: read
outputs:
frontend: ${{ steps.classify.outputs.frontend }}
backend: ${{ steps.classify.outputs.backend }}
integration: ${{ steps.classify.outputs.integration }}
perf_iso: ${{ steps.classify.outputs.perf_iso }} # #473 §5
perf_interaction: ${{ steps.classify.outputs.perf_interaction }}
mutants: ${{ steps.classify.outputs.mutants }}
# Тяжёлые job только на кандидате/по кнопке/на PR (#479), см. шаг heavy.
heavy: ${{ steps.heavy.outputs.heavy }}
base: ${{ steps.base.outputs.base }}
# Разные вещи под разными именами намеренно: `base` — до какого коммита
# классифицировать файлы ветки (#387), `range_base` — от какого коммита
# судить сам диапазон на dev (#388). Общее имя означало бы, что
# потребитель однажды молча возьмёт чужую базу.
range_base: ${{ steps.base.outputs.range_base }}
steps:
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
# блобы истории этой job не нужны ни на одном шаге (#345).
- uses: actions/checkout@v7
with: { fetch-depth: 0, filter: 'blob:none' }
# База диапазона — самый новый предок с УСПЕШНО завершённым Validate
# (#387). Прежде бралась голова предыдущего пуша (`github.event.before`),
# то есть допущение «до этого всё проверено». Concurrency отменяет прогон
# предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки
# без единого исполненного тяжёлого гейта.
# Смоки, golden, performance_smoke — на кандидате беты (трейлер
# `Release:`), по `workflow_dispatch full=true`, на PR (#479). Логика и
# её тест — scripts/classify-changes.mjs.
- id: heavy
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
FULL_INPUT: ${{ inputs.full }}
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
- id: base
if: github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.sha }}
BRANCH: ${{ github.ref_name }}
REPO: ${{ github.repository }}
REF: ${{ github.ref }}
FALLBACK: ${{ github.event.before }}
run: |
git fetch -q origin dev
# Недоступность API — не отказ гейта: пустой ответ уводит базу в
# сторону БОЛЬШЕГО объёма проверок, а не меньшего.
# Один запрос на оба режима: `completed` — надмножество `success`,
# а нужный предикат применяет скрипт (#387 — успех, #388 — судимость).
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
-f branch="$BRANCH" -f status=completed -F per_page=100 \
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
if [ "$REF" = "refs/heads/dev" ]; then
# На dev классифицировать нечего (всё true), но база диапазона
# нужна гейту «новый код не добавляет any» в job frontend (#388).
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
--name=range_base --fallback="$FALLBACK" --runs=/tmp/validate-runs.json
exit 0
fi
merge_base=$(git merge-base origin/dev "$HEAD_SHA" || git rev-parse "$HEAD_SHA~1")
node scripts/classify-base.mjs --head="$HEAD_SHA" \
--merge-base="$merge_base" --runs=/tmp/validate-runs.json
with: { fetch-depth: 0 }
- id: classify
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
GREEN_BASE: ${{ steps.base.outputs.base }}
HEAD_SHA: ${{ github.sha }}
REF: ${{ github.ref }}
run: |
if [ "$REF" = "refs/heads/dev" ]; then
echo "dev: без фильтров, всё true"
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
exit 0
fi
zero=$(printf '%040d' 0)
# #347: непустой before, которого больше не существует, — это
# force-push (ребейз/аменд переписал историю ветки). Диапазон в этом
# случае НЕ угадывается: merge-base после переписанной истории уже
# выдавал «изменились только два doc-файла» при реальном диффе в
# custom_components/**, и тяжёлые job тихо скипались, а общий статус
# рана оставался success — ровно класс тихого пропуска #171/#207.
# Force-push редок, полный прогон дешевле ложного «зелёного».
#
# Проверка сохранена как есть и после #387: там база стала доказуемой
# (зелёный предок), но механизм, из-за которого merge-base врал на
# переписанной истории, не разобран до конца. Снимать защиту, не
# объяснив её, — способ получить #347 второй раз.
if [ -n "$BEFORE_SHA" ] && [ "$BEFORE_SHA" != "$zero" ] \
&& ! git cat-file -e "$BEFORE_SHA" 2>/dev/null; then
echo "force-push: before=$BEFORE_SHA переписан — полный прогон без классификации"
{
echo "### Классификация изменённых файлов"
echo "\`before\` (=$BEFORE_SHA) переписан force-push'ем: диапазон недоказуем,"
echo "все тяжёлые job запускаются без фильтра путей (#347)."
} >> "$GITHUB_STEP_SUMMARY"
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
exit 0
fi
# Диапазон считается от последнего ДОКАЗАННО зелёного предка (#387),
# а не от головы предыдущего пуша: его прогон штатно отменяется
# следующим пушем. PR — исключение: там база задана событием.
base="$GREEN_BASE"
base="$BEFORE_SHA"
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
if [ -z "$base" ]; then
echo "база не определена — полный прогон без классификации"
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
exit 0
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
# иначе классифицировалась бы вся история.
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|| ! git cat-file -e "$base" 2>/dev/null; then
git fetch -q origin dev
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1")
fi
files=$(git diff --name-only "$base" "$HEAD_SHA")
printf '%s\n' "$files" | head -50
# Шаблоны — в scripts/classify-changes.mjs: там они проверяемы
# unit-тестом, здесь — только переписываются в выходы (#473 AC8).
printf '%s\n' "$files" | node scripts/classify-changes.mjs | tee -a "$GITHUB_OUTPUT"
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
{
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|pytest\.ini$)')"
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
} >> "$GITHUB_OUTPUT"
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
@@ -339,10 +186,6 @@ jobs:
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
reuse:
name: "Переиспользование: это дерево уже проверено"
# `changes` нужна ради набора профилей перф-смока (#473): ключ
# performance_smoke включает набор, иначе glow-only результат засчитался бы
# прогону, которому нужен изометрический профиль.
needs: changes
runs-on: ubuntu-latest
outputs:
smoke: ${{ steps.probe.outputs.smoke }}
@@ -352,7 +195,6 @@ jobs:
smoke_key: ${{ steps.keys.outputs.smoke }}
golden_key: ${{ steps.keys.outputs.golden }}
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
performance_smoke_set: ${{ steps.keys.outputs.performance_smoke_set }}
backend_key: ${{ steps.keys.outputs.backend }}
steps:
- uses: actions/checkout@v7
@@ -360,21 +202,12 @@ jobs:
with: { node-version: 22 }
- name: Ключи переиспользования
id: keys
env:
PERF_ISO: ${{ needs.changes.outputs.perf_iso }}
PERF_INTERACTION: ${{ needs.changes.outputs.perf_interaction }}
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
# Набор профилей перф-смока — часть ключа (#473 AC5).
set=glow
[ "$PERF_ISO" = "true" ] && set="$set-iso"
[ "$PERF_INTERACTION" = "true" ] && set="$set-interaction"
echo "performance_smoke_set=$set" >> "$GITHUB_OUTPUT"
echo "performance_smoke set: $set"
# lookup-only: маркер только проверяется, но не восстанавливается —
# сохранять его в этой job нечего, она ничего не прогоняла.
- name: Маркер smoke
@@ -396,7 +229,7 @@ jobs:
uses: actions/cache/restore@v6
with:
path: .reuse-marker
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}-${{ steps.keys.outputs.performance_smoke_set }}
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
lookup-only: true
- name: Маркер backend
id: m_backend
@@ -453,107 +286,13 @@ jobs:
- name: Hassfest validation
uses: home-assistant/actions/hassfest@master
# Мутанты по диффу (#475). Свидетель гниёт двумя способами: изменился
# файл, который он патчит, либо изменился его гард. `--check` этого не
# видит — он проверяет существование якоря, а не достижимость ветки.
# Полный прогон раз в неделю ловит гниль с опозданием до релиза (#466,
# #467). Здесь гоняются только затронутые мутанты с той же базы диапазона,
# что и остальные гейты ветки (#387/#388). Релизный диапазон всё же может
# задеть сотню свидетелей, поэтому #480 делит тот же набор на три
# детерминированных непересекающихся шарда вместо ослабления проверки.
changed_mutants:
name: "Мутанты по диффу (${{ matrix.shard }}/3): затронутые свидетели краснеют"
needs: changes
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mutants == 'true'
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- uses: actions/setup-python@v7
with:
python-version: '3.14'
- name: Backend test dependencies
run: pip install -r tests_backend/requirements.txt
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
# Журнал пойманных свидетелей (#481). Отменённый или упавший по таймауту
# прогон не пропадает: журнал пишется после каждого пойманного мутанта и
# сохраняется при любом исходе шага, а следующий пуш начинает с того
# места, где предыдущий остановился. Мутант с тем же отпечатком входов
# (файлы патча и гарда, без строки версии) повторно не гоняется.
- name: Журнал свидетелей (последний по шарду)
uses: actions/cache/restore@v6
with:
path: artifacts/mutation-ledger
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}
restore-keys: mutation-ledger-${{ matrix.shard }}-
- name: Затронутые мутанты ловятся
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
PROVEN_BASE: ${{ needs.changes.outputs.range_base }}
HEAD_SHA: ${{ github.sha }}
REF: ${{ github.ref }}
SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
git fetch -q origin dev
zero=$(printf '%040d' 0)
base=""
if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then
base="${PROVEN_BASE:-$BEFORE_SHA}"
fi
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|| ! git cat-file -e "$base" 2>/dev/null; then
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "")
fi
if [ -z "$base" ]; then
echo "не нашёл базу диапазона — мутанты по диффу пропущены, это увидит следующий пуш"
exit 0
fi
echo "диапазон: $base..$HEAD_SHA"
npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/3" \
--ledger="artifacts/mutation-ledger/shard-$SHARD.json"
# Сохраняется всегда: и после красного шага (пойманные до отказа уже
# записаны), и при отмене прогона — post-шаг cache/save исполняется,
# пока job не убита целиком. Ключ уникален на прогон: cache не
# перезаписывает существующий ключ, а restore-keys берёт самый новый.
- name: Сохранить журнал свидетелей
if: always()
uses: actions/cache/save@v6
with:
path: artifacts/mutation-ledger
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}
frontend:
name: "Фронтенд: типы, юниты, мутанты, синхрон бандла"
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
# Полная история без блобов (#342): гейт «новый код не добавляет any»
# diff-aware, ему нужен диапазон, а содержимое старых ревизий — нет.
- uses: actions/checkout@v7
with: { fetch-depth: 0, filter: 'blob:none' }
- uses: actions/setup-node@v7
with:
node-version: 22
@@ -561,49 +300,13 @@ jobs:
- run: npm ci
- name: Typecheck
run: npm run typecheck
# Долг типизации снимается при извлечении подсистем (#34), а не разовой
# заменой: в src/** его 1034 вхождения в 49 файлах. Гейт держит приращение
# на нуле — судит только добавленные строки (#342).
- name: "Новый код не добавляет any"
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
PROVEN_BASE: ${{ needs.changes.outputs.range_base }}
HEAD_SHA: ${{ github.sha }}
REF: ${{ github.ref }}
run: |
set -euo pipefail
git fetch -q origin dev
zero=$(printf '%040d' 0)
base=""
# Пуш прямо в dev: диапазон — то, что добавлено с последнего
# ДОКАЗАННО зелёного предка (#388), а не с головы предыдущего пуша:
# его прогон штатно отменяется следующим, и добавленные им строки не
# судил бы никто. Фолбэк — прежний `before`.
if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then
base="${PROVEN_BASE:-$BEFORE_SHA}"
fi
# Ветка, PR, новая история: точки отсчёта нет, берём merge-base с dev.
# Там диапазон и так шире, поэтому #388 этих случаев не касается.
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|| ! git cat-file -e "$base" 2>/dev/null; then
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "")
fi
if [ -z "$base" ]; then
echo "не нашёл базу диапазона — гейт пропущен, это увидит следующий пуш"
exit 0
fi
node scripts/no-new-any.mjs --base "$base" --head "$HEAD_SHA"
- name: Unit tests
run: npm test
- name: Build
run: npm run build
# Entry, manifest and every content-hashed chunk are one artifact (#337).
- name: Card bundle trees in sync
run: |
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
npm run bundle:budget
# Копия стенда больше не коммитится (#255): сверяются две обязательные.
- name: Card bundle snapshots in sync
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
# Бандл собирается один раз и уезжает артефактом (#336). Прежде каждая из
# пяти браузерных job собирала его заново: `bundle:sync` — это `tsc
# --noEmit` плюс rollup, около минуты на job.
@@ -615,27 +318,16 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: card-bundle
path: dist/
retention-days: 1
if-no-files-found: error
# Несколько браузерных контрактов импортируют чистые функции из
# скомпилированного тестового дерева. Оно создаётся `npm test`, но не
# входит в production-бандл и потому едет отдельным артефактом (#336).
- name: Выложить тестовые модули для смоков
uses: actions/upload-artifact@v7
with:
name: card-test-build
path: test-build/
path: dist/houseplan-card.js
retention-days: 1
if-no-files-found: error
smoke:
name: Смоки в браузере (шард ${{ matrix.shard }} из 3)
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
needs: [changes, frontend, reuse]
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true'
needs: [frontend, reuse]
if: needs.reuse.outputs.smoke != 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
# критическим путём всего Validate. Три шарда режут его примерно вдвое;
# цена — трижды `npm ci` и сборка, около двух оплаченных минут раннера.
@@ -672,21 +364,8 @@ jobs:
with:
name: card-bundle
path: dist
- name: Забрать тестовые модули
uses: actions/download-artifact@v7
with:
name: card-test-build
path: test-build
- name: Разложить бандл по копиям
run: node scripts/bundle-sync.mjs
# Гард исключений внутри карточки объявлен в demo/serve.mjs с 2026-07-27 и
# до #404 не срабатывал ни разу: счётчик читался раньше, чем Playwright
# доставлял pageerror. Такое ловится только запуском, поэтому пробы живут
# здесь — в единственной job с настоящим браузером. Один шард из трёх:
# проверка не зависит от разбиения, а платить за неё трижды незачем.
- name: "Гард исключений умеет падать (#404)"
if: matrix.shard == 1
run: node demo/guard/verify-guard.mjs
- name: Smoke suite (шард ${{ matrix.shard }} из 3)
env:
SHARD: ${{ matrix.shard }}
@@ -703,20 +382,10 @@ jobs:
if [ $(( (index - 1) % SHARDS + 1 )) -ne "$SHARD" ]; then continue; fi
ran=$((ran + 1))
name=$(basename "$f" .mjs)
if timeout --kill-after=10s 180s node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
echo "ok $name"
else
status=$?
echo "FAIL $name"
if [ "$status" -eq 124 ]; then
echo "diagnostic smoke-timeout: $name exceeded 180s"
fi
# Диагностические строки смока печатаются раньше вердикта, и
# `tail -20` их срезал: на #411 сам смок сообщал, НАСКОЛЬКО
# разошлись кадры (введено в #302), а в логе прогона осталось
# только «expected true, got false». Числа теперь достаются
# адресно — они и есть разница между «чинить» и «гадать».
grep -aE 'pixel-diffs|^EXC |diagnostic' "/tmp/smoke-logs/$name.log" | tail -5 || true
tail -20 "/tmp/smoke-logs/$name.log"
fail=1
fi
@@ -736,8 +405,8 @@ jobs:
# частично прогнанная матрица не имеет права выглядеть как выполненная работа.
smoke_done:
name: "Смоки: все шарды зелёные"
needs: [changes, smoke, reuse]
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true'
needs: [smoke, reuse]
if: needs.reuse.outputs.smoke != 'true'
runs-on: ubuntu-latest
steps:
- name: Записать маркер успеха
@@ -759,8 +428,8 @@ jobs:
name: Golden-кадры против принятых эталонов
# Deterministic visual correctness stays in every prerelease gate: it is
# inexpensive and catches a different class of regressions than timings.
needs: [changes, frontend, reuse]
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.golden != 'true'
needs: [frontend, reuse]
if: needs.reuse.outputs.golden != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -793,14 +462,6 @@ jobs:
path: dist
- name: Разложить бандл по копиям
run: node scripts/bundle-sync.mjs
# Маркер предыдущего падения с ТЕМ ЖЕ ключом (#386). Восстанавливается до
# прогона, потому что после падения шаги кэша уже не помогут отличить
# виновника от свидетеля.
- name: Прежнее падение на тех же входах
uses: actions/cache/restore@v6
with:
path: .fail-marker
key: fail-golden-${{ needs.reuse.outputs.golden_key }}
- name: Capture or verify golden matrix
id: golden
run: |
@@ -817,25 +478,6 @@ jobs:
with:
name: golden-images
path: artifacts/golden
# Красная golden обязана сказать, ЧЕЙ это долг (#386). Без этого письмо
# «Run failed» называет коммит, который пушнули следующим: 29 августа так
# был назван документ ревью `0f7b6f5`, а сцену без эталона добавил
# `dbbe94ae`. Вердикт job не меняется — меняется только формулировка.
- name: Кто уронил golden
id: fail_note
if: failure()
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node scripts/gate-reuse.mjs --job=golden --note --marker=.fail-marker
- name: Запомнить первопричину
if: failure() && steps.fail_note.outputs.first == 'true'
# Гонка двух прогонов даёт «Cache already exists»; для маркера падения
# это тем более не отказ гейта — job и так красная по существу.
continue-on-error: true
uses: actions/cache/save@v6
with:
path: .fail-marker
key: fail-golden-${{ needs.reuse.outputs.golden_key }}
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
@@ -857,14 +499,13 @@ jobs:
name: "Перф-смок: бюджет времени кадра"
# Candidate-only catastrophic-regression guard for ordinary pushes and
# prereleases. The expensive same-runner comparison lives in performance.yml.
needs: [changes, frontend, reuse]
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.performance_smoke != 'true'
needs: [frontend, reuse]
if: needs.reuse.outputs.performance_smoke != 'true'
runs-on: ubuntu-latest
# 15 минут не хватало, когда установка браузера шла через apt: замер
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
# job укладывается в те же минуты, что и раньше. Диффозависимые профили
# (#473) добавляют до двух замеров по 3 образца — окно расширено под них.
timeout-minutes: 30
# job укладывается в те же минуты, что и раньше.
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
@@ -899,31 +540,9 @@ jobs:
- name: Capture the heaviest Glow state
run: |
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/space-candidate.json
- name: Enforce absolute smoke ceilings
run: |
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-space-glow-smoke.json --candidate=artifacts/performance-smoke/space-candidate.json --output=artifacts/performance-smoke/space-comparison.json
# Диффозависимые профили (#473 §5): изометрия — когда дифф задел
# `src/iso-*`, взаимодействие — когда живой путь или оркестраторы кадра.
# Абсолютные потолки — `hardMaxMs` полных профилей: регрессия #160
# (9 870 мс против 3 500) ловится с одного образца, шум — нет.
- name: Изометрический профиль по диффу (#473)
if: needs.changes.outputs.perf_iso == 'true'
run: |
npm run benchmark:large-house -- --profile=large-house-isometric-v1 --samples=3 --warmups=1 --output=artifacts/performance-smoke/isometric-candidate.json
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-isometric-smoke.json --candidate=artifacts/performance-smoke/isometric-candidate.json --output=artifacts/performance-smoke/isometric-comparison.json
- name: Профиль взаимодействия по диффу (#473)
if: needs.changes.outputs.perf_interaction == 'true'
run: |
npm run benchmark:large-house -- --profile=large-house-interaction-v1 --samples=3 --warmups=1 --output=artifacts/performance-smoke/interaction-candidate.json
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-interaction-smoke.json --candidate=artifacts/performance-smoke/interaction-candidate.json --output=artifacts/performance-smoke/interaction-comparison.json
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
- name: Бюджеты ограничений стыков (#330)
run: npm run benchmark:junction-limits
- name: Быстрый commit точки стены (#461)
run: npm run benchmark:wall-draw-click -- --output=artifacts/performance-smoke/wall-draw-click.json
- name: Upload performance smoke report
if: always()
uses: actions/upload-artifact@v7
@@ -945,7 +564,7 @@ jobs:
continue-on-error: true
with:
path: .reuse-marker
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}-${{ needs.reuse.outputs.performance_smoke_set }}
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
backend:
name: "Бэкенд: pytest в Home Assistant"
@@ -959,57 +578,10 @@ jobs:
- uses: actions/setup-node@v7
with: { node-version: 22 }
- uses: actions/setup-python@v7
with: { python-version: "3.14" }
- run: |
# Точные версии — в tests_backend/requirements.txt (#392): без них
# резолвер выбирает набор сам, и «зелёный backend» значит разное
# в разные дни. #42 ставит из того же файла ruff для шага линта.
pip install -r tests_backend/requirements.txt
pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest '
- name: Линт бэкенда (ruff, узкий набор)
run: python -m ruff check custom_components/houseplan
# #42: типизация была измеримой только локально — CI её не исполнял, и
# регрессия в любом из allowlist-модулей проходила молча. Список модулей
# берётся ИЗ pyproject.toml, а не дублируется здесь: разошедшийся дубль
# означал бы зелёный шаг, проверяющий не то. Пустой список — отказ.
- name: Типы бэкенда (mypy strict по allowlist)
run: |
modules=$(python -c "
import tomllib
cfg = tomllib.load(open('pyproject.toml', 'rb'))
for o in cfg['tool']['mypy'].get('overrides', []):
if o.get('strict'):
print(' '.join('-p ' + m for m in o['module']))
")
test -n "$modules" || { echo '::error::strict-allowlist в pyproject.toml пуст — проверять нечего'; exit 1; }
echo "mypy strict: $modules"
python -m mypy $modules
# #42: защита от тихого скипа HA-harness — импорт и порог collect
- name: HA-harness присутствует
run: |
python -c "import homeassistant"
collected=$(python -m pytest tests_backend/ -q --collect-only 2>/dev/null | grep -c "test_ha_" || true)
echo "collected HA-harness tests: $collected"
test "$collected" -ge 50
- name: Backend unit tests (pure + HA harness, coverage)
run: >
python -m pytest tests_backend/ -q
--cov=custom_components/houseplan --cov-branch
--cov-report=xml --cov-report=term
- name: Support relay unit tests
run: python -m unittest discover -s scripts/support-relay/tests -q
# #42: гейт «не ниже baseline» — пороги 90/95 поднимаются trivial-правкой числа
- name: Порог покрытия не ниже baseline
run: |
baseline=$(head -1 scripts/backend-coverage-baseline.txt)
actual=$(python -c "import xml.etree.ElementTree as ET; print(round(float(ET.parse('coverage.xml').getroot().get('line-rate'))*100, 1))")
echo "coverage: $actual% (baseline $baseline%)"
python -c "import sys; sys.exit(0 if float('$actual') >= float('$baseline') - 0.1 else 1)"
- name: Артефакт coverage.xml
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: coverage.xml
with: { python-version: "3.13" }
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
- name: Backend unit tests (pure + HA harness)
run: python -m pytest tests_backend/ -q
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
-2
View File
@@ -12,5 +12,3 @@ artifacts/
# она только росла — 364 версии по 1.16 МБ за семь недель (#255). Обязательных
# копий две: `dist/` (артефакт сборки) и `custom_components/` (её ставит HACS).
demo/srv/assets/houseplan-card.js
demo/srv/assets/houseplan-assets.json
demo/srv/assets/houseplan-assets/
+27 -74
View File
@@ -2,8 +2,7 @@
House Plan is one HACS package with two parts plus a demo harness:
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to
the entry, manifest and hashed chunks under `dist/`.
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to `dist/houseplan-card.js`.
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
@@ -41,16 +40,10 @@ task records: problem, scope, acceptance criteria and discussion.
of a status and `rejected` on a closed issue. Exactly one `S*` label per open
issue. Labels are the whole of it: GitHub Projects is no longer used.
**The light track is the default, not a shortcut** (owner's decision 2026-08-27,
issue #338). `small` — the spec lives in the issue body and its review is a
comment. Analysis names the `small` criterion the task *fails* when it takes the
full track; "ordinary track" without a named criterion is not a justification.
The threshold itself did not move — only which side carries the proof. The full
track stays what it was for geometry, config migrations and public contracts,
where a criterion is broken plainly and saying which one is easy.
`trivial` — the short track: no spec stage at all, `S2-analysis` straight to
`S5-ready`, with the AC written into the issue body first. `trivial` requires a bug confined to one surface with no new UX
Two shortcuts exist for small work. `small` — the light track: the spec lives in
the issue body and its review is a comment. `trivial` — the short track: no spec
stage at all, `S2-analysis` straight to `S5-ready`, with the AC written into the
issue body first. `trivial` requires a bug confined to one surface with no new UX
contract, no migration, no i18n, no perf or touch impact, at most three checkable
AC, **and expected behaviour already on record** — nothing left to decide. Code
review is never skipped on either track; it is what stands in for testing.
@@ -94,7 +87,7 @@ Start with the spec?" is the correct answer, not a smaller patch.
| **A — product** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, i18n, `custom_components/**/translations/**` | yes |
| **B — gates and tooling** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, `.github/workflows/**`, `rollup.config.mjs`, `tsconfig*.json` | yes; may reuse the issue it covers |
| **C — documentation** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md` | not if it is part of its issue's DoD |
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` | never changes on its own. The stand copy `demo/srv/assets/**` is no longer committed (#255): build the complete tree with `npm run bundle:sync` |
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` | never changes on its own. The stand copy `demo/srv/assets/houseplan-card.js` is no longer committed (#255): build it with `npm run bundle:sync` |
The table above is a summary; `PROCESS.md` §1 is the authority and now covers the
configuration files this one omits — `package.json`, `package-lock.json`,
@@ -153,12 +146,8 @@ in between.
If the rebase conflicts the pipeline says so in the issue and sends the task back
to `S6-in-progress`. The verdict still stands: nothing needs reviewing again, the
remaining work is the rebase. When the conflict is only in the committed bundle
(`dist/**`, `custom_components/houseplan/frontend/**` — the usual case when two
tasks built it in parallel), run `node scripts/rebase-on-dev.mjs` (#479): it takes
`dev`'s copy through the rebase, rebuilds with `npm run bundle:sync` and amends
the result into your last commit; a conflict anywhere else aborts and leaves the
tree as it was. Then push the branch and re-apply `S7-code-review`. The second review run is not a formality — after a rebase onto a
remaining work is the rebase. Resolve it, push the branch, re-apply
`S7-code-review`. The second review run is not a formality — after a rebase onto a
moved `dev` this is different code, and accepting it unchecked is how regressions
arrive. Cycles are counted per stage, so a code review spends its own budget.
@@ -285,9 +274,7 @@ the owner splits the task, rejects it, or arbitrates.
On the light track (`small`: complexity ≤3, one surface, no config migration, no
new UX contract, no perf or touch impact — all at once) the spec lives in the issue
body and the spec review is a comment. Code review is never skipped. This track is
the default: taking the full one means naming the criterion above that the task
does not meet.
body and the spec review is a comment. Code review is never skipped.
## Specs
@@ -330,31 +317,14 @@ npm run inventory # the only correct way to get test counts
Never copy test counts into documents by hand; they go stale in days.
After building, keep the complete manifest-driven bundle trees in sync — CI
verifies every listed file byte-for-byte:
After building, keep all three bundle snapshots in sync — CI compares them
byte-for-byte:
```
cp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
npm run bundle:sync # dist → custom_components + demo/srv/assets (#255)
npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
```
`npm run gate:small` runs the mandatory part of PROCESS §8 in one go (#479):
unit tests, build with typecheck, `no-new-any` and `smoke-select` in parallel,
then the bundle-tree comparison and the bundle budget. It prints the smokes the
diff selects but does not run them — those, `golden`, `pytest` and `check-docs
--screenshots=strict` remain the author's call by diff and AC.
**Heavy CI gates run on the beta candidate, nightly and on demand — not on every
push (#479).** `smoke`, `golden` and `performance_smoke` in Validate are gated
on the `heavy` output: true for a head commit carrying a `Release:` trailer, for
`workflow_dispatch full=true` (which `nightly.yml` issues on `dev` every night)
and for pull requests. A plain push to `dev` runs preflight, frontend (types,
units, build, bundle sync, no-new-any), backend, hacs and hassfest. Screenshot
freshness in `check-docs` is likewise a warning on a plain push and an error on
the candidate; `publish-prerelease.yml` and `release.yml` refuse a candidate
without the `Release:` trailer, so a green Validate without the heavy jobs can
never pass for a release.
During the implementation cycle the fast gates always run. Since 2026-08-14 the
owner's machine also carries Playwright with Chromium (Windows) and a full WSL
environment, which changes one thing (#151): **before moving an issue to
@@ -365,27 +335,9 @@ whole review round that a local run would have caught immediately.
The full smoke set, `golden` and `performance_smoke` still belong to the
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
the beta gate is CI at the exact SHA.
**Verifying and capturing are different things (#455).** `golden:verify` is
advisory and legal anywhere, Windows included: it reports differences and
accepts nothing. **Capturing** frames is refused outside Linux
before the browser even starts — `golden:capture` through
`demo/golden/policy.mjs`, documentation screenshots through
`npm run docs:capture` (use that script, not a bare `node
demo/docs/capture.mjs`: the gate sits one step earlier because editing the
capture script invalidates the committed screenshot index). The refusal prints
the WSL command. The reason is not policy but physics: Windows
rasterizes text through DirectWrite with different subpixel and DPI behaviour,
so no frame ever matches an accepted baseline byte for byte, no environment
witness can exist, and acceptance would refuse anyway (#401 accepts any
environment that proves itself with byte-identical undeclared frames — Linux is
simply the only one we have). The deliberate override is
`HP_ALLOW_FOREIGN_CAPTURE="reason"`; the reason travels into the output and the
manifest. Baselines are still accepted only via
`npm run golden:accept -- --reviewed` on a complete artefact, and the accepted
index records the platform next to the Chromium build.
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
@@ -438,20 +390,21 @@ Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It
reproduces against the pristine committed bundle, so treat it as
pre-existing/pixel-precision, not a regression you introduced.
## Alpha experiments
## Labs flags
`src/labs.ts` is the single registry and resolver for hidden presentation
experiments. One browser-local switch controls the complete set known to the
installed build: `hp_alpha=1` in query or the shared hash grammar enables and
persists it, while `hp_alpha=0` disables and persists it. Do not add per-feature
URL/storage keys or a YAML/config switch. The legacy `hp-labs` grammar and
`houseplan_card_labs_v1` storage are ignored and never migrated.
experiments. Activate a live flag through `?hp-labs=<id>` or the shared hash
grammar, remove it with `-<id>`, and use `off` to clear the set. Do not add a
YAML/config switch for a Labs-only experiment. A new entry needs a unique
lowercase id, issue, numeric-core `since`, numeric-core `expires`, summary and
unit/browser coverage. Invalid or duplicate registry entries fail closed.
A new registry entry needs a unique lowercase id, issue, summary and
unit/browser coverage; it does not get `since` or `expires`. Invalid or duplicate
entries fail closed. Alpha capabilities may alter presentation only and must not
gate data, migrations, stores, HA actions or network calls. Current renderer
details are in `docs/ISOMETRIC.md`.
Expiry is exclusive and ignores prerelease suffixes: an entry expiring at
`1.65.0` is unavailable in `1.65.0-beta.1`. Before that cycle, either remove the
experiment or graduate it through its own reviewed issue; never extend expiry as
an incidental change. Labs may alter presentation only and must not gate data,
migrations, stores, HA actions or network calls. Current renderer details are in
`docs/ISOMETRIC.md`.
Demo harness render quirk: the fake `hass` in `demo.html` is set once, so opening the
page directly in a browser renders the floor plan but **device icons only appear
+3 -59
View File
@@ -9,44 +9,6 @@ User-visible changes go into **both** changelogs in the same commit:
`docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian). Entries
older than v1.42.0 exist only in the English file — no need to backfill them.
## Translations
A shipped UI language has three matching parts:
1. `src/i18n/<code>.json` for the card;
2. `custom_components/houseplan/translations/<code>.json` for the Home Assistant
integration;
3. one registry entry (code, native label and eager dictionary or lazy loader)
in `src/i18n/registry.ts`.
Use the canonical Home Assistant/BCP 47 language tag as `<code>` (for example,
`fr` or `pt-BR`) and use that exact spelling for both JSON filenames. Lookup is
case-insensitive and also accepts `_` from legacy locale sources.
The registry drives language resolution, the visual-editor selector and parity
tests. The tests reject missing or extra locale files; frontend dictionaries
also fail on mismatched keys, empty values and changed placeholders.
Placeholders such as `{name}` and `{n}` are a contract: do not translate, add
or remove them.
English and Russian are synchronous fallback/legacy locales. German is the
reference lazy third-locale implementation: its module carries the same build
fingerprint as the entry bundle, has one content-hashed retry URL and is listed
under `lazyLocaleFiles` in `houseplan-assets.json`. New sizeable locales should
follow that path unless a measured initial-bundle budget explicitly justifies
an eager import. Extend the runtime, manifest, file/key/placeholder parity and
regional-locale tests together; never bypass the registry by importing a locale
directly in a component.
The current `subst()` helper does not implement plural rules. Phrase strings so
their grammar does not depend on the numeric value (for example, use a neutral
label followed by `{n}` rather than an English singular/plural pair).
Adding a UI locale does not automatically create another full documentation
set; maintain the existing English and Russian documentation according to the
project's normal rules. Right-to-left layout is a separate product project,
because the plan canvas and editors cannot be mirrored by translations alone.
## Where to ask
Not sure whether something is a bug, or just want to discuss an idea before
@@ -66,7 +28,7 @@ issue, but they do not replace it or maintain a separate checklist.
## Five-minute setup
```bash
git clone --filter=blob:none https://github.com/Matysh/houseplan-card && cd houseplan-card
git clone https://github.com/Matysh/houseplan-card && cd houseplan-card
npm ci # frontend toolchain
npm run typecheck # tsc --noEmit (strict)
npm test # node:test — pure logic, i18n parity, tap-action security
@@ -75,24 +37,6 @@ pip install pytest voluptuous && python -m pytest tests_backend -q # pure back
npm install # also installs .githooks through the prepare script
```
### Why `--filter=blob:none` (#345)
A full clone is **215 MB of `.git`**; a blobless one is **26 MB** — measured, not
estimated. Both carry all 1611 commits and all 182 tags, so ranges, `merge-base`
and `git diff` across history work identically; `git diff origin/dev~3..origin/dev`
in a blobless clone takes about a second and grows `.git` by one megabyte.
The difference is that historical *file contents* are fetched only if something
actually asks for them. That matters here because 32% of the pack is documentation
screenshots — ten PNGs re-captured 196 times — and another sizeable share is the
committed bundle, one 1.16 MB file per product change. Almost nobody ever reads an
old revision of either.
Drop the flag if you work offline with history, or need `git log -p` over the whole
tree repeatedly. Do **not** replace it with `--depth=1`: a shallow clone is about
the same size but has no `merge-base`, so the process gate, `smoke-select` and
every `origin/dev..HEAD` range stop working.
The HA-harness backend tests (`tests_backend/test_ha_*.py`) need Python ≥3.13 and
`pytest-homeassistant-custom-component home-assistant-frontend`; CI runs them on
every push — locally they are skipped when `homeassistant` is not importable.
@@ -101,8 +45,8 @@ every push — locally they are skipped when `homeassistant` is not importable.
- **Docs in the same commit**: CHANGELOG entry for user-visible changes;
`docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new gotchas.
- Every UI string goes through `src/i18n/<lang>.json`; follow the
[Translations](#translations) flow for registry and backend parity.
- Every UI string goes through `src/i18n/<lang>.json` (tests enforce en/ru key parity).
Adding a language = adding one JSON file + registering it in `src/i18n.ts`.
- The built card must be committed in sync: `cp dist/houseplan-card.js
custom_components/houseplan/frontend/` (CI compares them byte-for-byte).
- Tap actions have a security model (locks/alarms never toggle from the plan) —
+8 -98
View File
@@ -101,9 +101,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
5. приоритет **P1/P2/P3**;
6. тип: баг / фича / техдолг;
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
8. трек — **по умолчанию `small`** (§5). Если задача идёт полным треком,
называется критерий §5, который она не проходит: «обычный трек» без
названного критерия обоснованием не является.
8. трек: обычный / `small` / `trivial` по критериям §5 и §5.1.
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
согласие**, несогласие он выражает правкой меток или комментарием, и это не
@@ -193,35 +191,6 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
коду с явной записью «проверено чтением, не исполнением».
- **Защитный AC доказывается таблицей «чем краснеет» (#435).** Для каждого AC,
заявляющего защиту — валидация, гард, лимит, отказ, инвариант, — в документе
ревью обязательна строка из трёх столбцов: **AC · чем доказан** (точная
команда или имя теста) **· чем краснеет** — мутация, снятая защита или
отрицательная проба, с результатом прогона. Пустой третий столбец — находка
Medium, а не примечание.
«Тест умеет падать» без названной мутации и её вывода доказательством не
является. Аудит v1.71.0-beta.1 нашёл пять контрактов #51 и #423, где тест
оставался зелёным на снятой защите; все пять прошли код-ревью как доказанные,
а два теста были записаны в закрытие coverage-ratchet под именами, обещавшими
то, чего они не проверяли (#430).
Мутант в `scripts/mutation-gate.mjs` обязателен, когда защита живёт в
продуктовом коде и проверяется дорогим гейтом (смок, бэкенд, golden): там
ревьюер не воспроизведёт отрицательный прогон второй раз. Для чистых юнитов
достаточно прогона со снятой защитой, приведённого в документе.
Считаются **защитные AC без названного свидетеля**, а не мутанты на
подсистему: у #421 мутанты были, и дыра всё равно проехала. «Сколько мутантов
принесла задача» остаётся признаком — у #423 их ноль, и именно у #423 нашёлся
тест, спрашивавший регулярку, находит ли она подстроку, которую сам же и
вырезал.
Правило не распространяется на AC, не заявляющие защиту (расположение, текст,
формат вывода): там свидетель — обычное сравнение ожидаемого с фактическим, и
третий столбец превратился бы в ритуал. И не отменяет «проверено чтением»:
тогда во втором столбце стоит «чтением», а не имя теста, и читатель ревью
видит разницу.
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
Medium **вне скоупа** — отдельный issue (#202).
@@ -266,36 +235,16 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
Порядок:
1. найти вердикт предыдущего раунда и **материал, на котором он получен**.
Материал объявлен блоком «Материал раунда» в конце документа предыдущего
раунда: конвейер дописывает туда SHA ветки, **дерево** материала и **блоб**
каждого ТЗ вместе с командами поиска (issue #416). Блок машинный — править
его руками не нужно и не следует;
1. найти вердикт предыдущего раунда и **SHA, на котором он получен**; SHA в
вердикте не назван — это находка;
2. объявить дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла ТЗ либо тела
issue для этапа ТЗ.
**Если SHA не резолвится — это не находка, а обычное дело.** Ветку задачи
между раундами перебазируют, сквошат или удаляют, и SHA умирает: по корпусу
ревью таких объявлений 98 из 804. Материал в этом случае берётся по якорям,
которые ребейз не меняет, потому что адресуются содержимым:
```
git log --all --format='%H %T' | grep <дерево>
git log --all --find-object=<блоб> -- <путь к ТЗ>
```
Находкой остаётся другое: **SHA, мёртвый уже в момент публикации отчёта** —
он означает, что значение сняли до `amend` или `rebase` и не сверили перед
выводом, как требует §7.2. Это отличие не теоретическое: на #403 оба
источника, автор и ревьюер, независимо назвали один и тот же осиротевший
SHA, и следующий раунд восстанавливал коммит по содержимому диффа руками
(issue #413). Конвейер теперь такую публикацию останавливает сам;
issue для этапа ТЗ;
3. по каждой находке предыдущего раунда показать, **чем именно она закрыта** —
строкой кода или текста, а не заявлением автора;
4. заново проверять только те AC, чьё доказательство дельта задевает;
5. **раздел «Унаследовано из r<N−1>»** обязателен: что принято без повторной
проверки, со ссылкой на документ того раунда и его материал. Без перечня
сокращение превращается в молчаливое доверие.
проверки, со ссылкой на документ того раунда и SHA. Без перечня сокращение
превращается в молчаливое доверие.
Дешёвые гейты (`typecheck`, `test`, `build` со сверкой копий бандла) гоняются в
каждом раунде: код изменился, а стоят они минуты. Тяжёлые — по дельте (§10.2).
@@ -399,26 +348,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
---
## 5. Лёгкий трек (метка `small`) — путь по умолчанию
## 5. Лёгкий трек (метка `small`)
Умолчание изменено решением владельца 2026-08-27, issue #338. Прежде полный трек
был бесплатен, а выбор лёгкого требовал обоснования. Фактическая цена: **2.9
ревью-документа на задачу** в среднем и до шести на одну issue (#329, #316,
#290) — при том что Medium-находки всё равно чинятся в той же задаче, без
отдельного цикла.
**Порог не изменился.** Критерии ниже те же и по-прежнему обязательны все
одновременно. Изменилась сторона доказательства: теперь обосновывается не выбор
лёгкого трека, а отказ от него — в `S2-analysis` называется критерий, который
задача не проходит. Полный трек остаётся тем, чем был, для геометрии, миграций
конфига и публичных контрактов: там критерии нарушаются сами, и назвать
нарушенный несложно.
Инверсия умолчания не отменяет ничего из §5 ниже и ничего из §4: бюджет четырёх
циклов, арбитраж владельца, обязательность ТЗ на полном треке и правило «ревью
до мержа» остаются как были. Меняется только стоимость пути по умолчанию.
**Критерии — все одновременно; нарушенный называется явно:**
**Критерии — все одновременно:**
- сложность и риск ≤ 3;
- одна поверхность (один диалог, один модуль, один эндпоинт);
@@ -616,23 +548,12 @@ npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend
# копия стенда собирается `npm run bundle:sync`, в репозитории её нет (#255)
node scripts/smoke-select.mjs --base origin/dev --head HEAD # какие смоки относятся к диффу
node demo/smoke_<целевые>.mjs
node scripts/no-new-any.mjs --base origin/dev --head HEAD # новый код не добавляет any
npm run golden:verify # если менялся визуал
node scripts/check-docs.mjs # если менялся src/**
node scripts/model-invariants.mjs --config <экспорт> # если правилась геометрия или ссылки
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
```
**Новый код не добавляет `any`** (#342). В `src/**` уже 1034 вхождения явного
`any` в 49 файлах; перетипизировать это одним заходом — месяц риска ради нуля
пользовательской ценности, поэтому долг снимается при плановом извлечении
подсистем (#34), а не разовой заменой. Гейт `scripts/no-new-any.mjs` судит
**только добавленные строки**: существующий долг на нетронутой строке законен,
правка строки со старым `any` — новая ответственность. Исключение объявляется на
той же строке, `// any-ok: <конкретная причина>`; голый маркер и причины вида
«todo» не проходят. Текст разбирается парсером TypeScript, поэтому слово «any» в
комментарии, строке или идентификаторе ложных срабатываний не даёт.
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла, а при
любом diff'е по `src/**` — ещё и `node scripts/check-docs.mjs`. По
@@ -661,17 +582,6 @@ python -m pytest tests_backend -q # py3.13, если менялся бэке
при следующей задаче (#237). Пересъёмка — `npm run build && node
demo/docs/capture.mjs`, коммит вместе с задачей.
**Перф-смок в Validate зависит от диффа** (#473). Два glow-профиля
гоняются всегда; при правке `src/iso-*` добавляется `large-house-isometric-v1`,
при правке `src/live-*`, `src/render-*`, `houseplan-render-lifecycle.ts`,
`houseplan-card.ts` — `large-house-interaction-v1`, оба по три образца против
абсолютных потолков `hardMaxMs` полных профилей (`budgets-*-smoke.json`).
Это гейт на «в разы», а не «на проценты»: регрессия #160 (первый кадр 9 870 мс
против потолка 3 500) ловится ещё в ревью, а не предрелизным гейтом под тегом.
Классификацию делает `scripts/classify-changes.mjs`, набор профилей входит в
ключ переиспользования `performance_smoke`. Ревьюер по-прежнему принимает
зелёный Validate на SHA как подтверждение дешёвых гейтов — смок его часть.
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
пропуском.
+9 -40
View File
@@ -94,53 +94,22 @@ House Plan is in the HACS default catalog — no custom repository needed.
2. Restart Home Assistant.
3. Open **Settings → Devices & services → Add integration → House Plan**.
The card is registered automatically. After installing or updating House Plan,
restart Home Assistant and fully reload the page: `Ctrl+F5` on Windows/Linux or
`Cmd+Shift+R` on macOS.
#### Storage mode (Home Assistant default)
No YAML is normally needed. If automatic registration did not make the card
available, open **Settings → Dashboards → menu ⋮ → Resources → Add
resource**, enter `/houseplan_files/houseplan-card.js`, and select **JavaScript
module**.
#### YAML resources mode (Home Assistant 2026.2+)
To manage resources in `configuration.yaml` independently of the dashboard
mode, use:
The card is registered automatically. If you manage Lovelace resources
manually, use the URL served by the integration:
```yaml
lovelace:
resource_mode: yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
resources:
- url: /houseplan_files/houseplan-card.js
type: module
```
#### Legacy Home Assistant 2024.6–2026.1
Only for a full-YAML dashboard that is already managed in YAML, use:
```yaml
lovelace:
mode: yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
```
`mode: yaml` changes the dashboard itself to YAML mode. Do not switch a storage
dashboard to legacy YAML just for House Plan; use the Storage mode instructions
above instead. Do not use the on-disk path inside `custom_components`; Home
Assistant does not serve that path as a JavaScript module.
Do not use the on-disk path inside `custom_components`; Home Assistant does not
serve that path as a JavaScript module.
### Manual installation
Copy the complete `custom_components/houseplan` release folder to
`config/custom_components`, restart Home Assistant, and add the House Plan
integration. Do not copy only `houseplan-card.js`: the card also uses an
internal manifest and content-hashed modules from the same release.
Copy `custom_components/houseplan` to `config/custom_components`, restart Home
Assistant, and add the House Plan integration.
### Add the card
+9 -40
View File
@@ -98,53 +98,22 @@ House Plan входит в основной каталог HACS — пользо
2. Перезапустите Home Assistant.
3. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
Карточка регистрируется автоматически. После установки или обновления
House Plan перезапустите Home Assistant и полностью перезагрузите
страницу: `Ctrl+F5` в Windows/Linux или `Cmd+Shift+R` в macOS.
#### Режим Storage (по умолчанию в Home Assistant)
Обычно YAML не нужен. Если авторегистрация не сделала карточку доступной,
откройте **Настройки → Панели управления → меню ⋮ → Ресурсы → Добавить
ресурс**, укажите `/houseplan_files/houseplan-card.js` и выберите тип
**JavaScript-модуль**.
#### YAML-ресурсы в Home Assistant 2026.2+
Чтобы управлять ресурсами в `configuration.yaml` независимо от режима
самой панели, используйте:
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
добавьте именно URL, который публикует интеграция:
```yaml
lovelace:
resource_mode: yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
resources:
- url: /houseplan_files/houseplan-card.js
type: module
```
#### Home Assistant 2024.6–2026.1: устаревший режим
Только для панели, которая уже полностью управляется через YAML:
```yaml
lovelace:
mode: yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
```
`mode: yaml` переводит в YAML-режим саму панель. Не переключайте
storage-панель в устаревший YAML только ради House Plan; используйте
инструкцию для Storage выше. Не используйте путь к файлу внутри
`custom_components`: Home Assistant не публикует его как JavaScript-модуль.
Не используйте путь к файлу внутри `custom_components`: Home Assistant не
публикует его как JavaScript-модуль.
### Вручную
Скопируйте всю папку `custom_components/houseplan` из релиза в
`config/custom_components`, перезапустите Home Assistant и добавьте интеграцию
House Plan. Одного `houseplan-card.js` недостаточно: карточке также нужны
внутренний манифест и хешированные модули из того же релиза.
Скопируйте `custom_components/houseplan` в `config/custom_components`,
перезапустите Home Assistant и добавьте интеграцию House Plan.
### Добавление карточки
-201
View File
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Binary file not shown.
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Sergey Matyunin (Matysh)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -1,28 +0,0 @@
# House Plan furniture pack 0.3.0
Canonical source artwork for the built-in House Plan furniture library.
- `svg/menu/`: 33 front-view category illustrations used only by the lazy
editor bundle.
- `svg/plan/`: 44 top-view drawings used on the plan.
- `pack.json`: stable ids, category links, default dimensions and names. The
filename deliberately avoids the `*manifest.json` suffix reserved by HACS.
The original author, Sergey Matyunin (`Matysh`), granted House Plan permission
to use, modify and distribute all 77 SVG files under the repository MIT
License without separate UI attribution in
[issue #159](https://github.com/Matysh/houseplan-card/issues/159#issuecomment-5454085168).
The reviewed source archive is `houseplan-furniture-custom-0.3.0.zip`, attached
to [issue #159](https://github.com/Matysh/houseplan-card/issues/159#issuecomment-5449707137),
with SHA-256
`9E969016EE3B4B4E3DB776FEC53C8B387B91368B118EB5E39911483DEF1B0953`.
The editable source is linked from `pack.json`. Generated TypeScript must
not be edited by hand; run `npm run furniture:generate` after changing this
directory.
Note (#369): the source archive referenced by the SHA-256 above may still
carry the earlier romanisation of the author's name; the authoritative
spelling is Sergey Matyunin (Сергей Матюнин), fixed 2026-08-29 by the
owner's decision in issue #369. The archive bytes are unchanged.
-818
View File
@@ -1,818 +0,0 @@
{
"schema_version": 1,
"view_schema": 2,
"pack_id": "houseplan",
"pack_version": "0.3.0",
"title_ru": "Набор мебели: меню и план",
"title_en": "Furniture pack: menu and plan",
"author": "Sergey Matyunin (Matysh)",
"license": "MIT",
"source_url": "https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan?node-id=271-95",
"menu_icons": [
{
"id": "air_conditioner",
"name_ru": "Кондиционер",
"name_en": "Air conditioner",
"group": "appliance",
"file": "svg/menu/air_conditioner.svg"
},
{
"id": "armchair",
"name_ru": "Кресла",
"name_en": "Armchairs",
"group": "furniture",
"file": "svg/menu/armchair.svg"
},
{
"id": "bathtub",
"name_ru": "Ванны",
"name_en": "Bathtubs",
"group": "sanitary",
"file": "svg/menu/bathtub.svg"
},
{
"id": "bed",
"name_ru": "Кровати",
"name_en": "Beds",
"group": "furniture",
"file": "svg/menu/bed.svg"
},
{
"id": "bidet",
"name_ru": "Биде",
"name_en": "Bidets",
"group": "sanitary",
"file": "svg/menu/bidet.svg"
},
{
"id": "boiler",
"name_ru": "Бойлер",
"name_en": "Water heater",
"group": "appliance",
"file": "svg/menu/boiler.svg"
},
{
"id": "chair",
"name_ru": "Стулья",
"name_en": "Chairs",
"group": "furniture",
"file": "svg/menu/chair.svg"
},
{
"id": "coffee_table",
"name_ru": "Журнальные столы",
"name_en": "Coffee tables",
"group": "furniture",
"file": "svg/menu/coffee_table.svg"
},
{
"id": "computer",
"name_ru": "Компьютер",
"name_en": "Computer",
"group": "appliance",
"file": "svg/menu/computer.svg"
},
{
"id": "cooktop",
"name_ru": "Варочные панели",
"name_en": "Cooktops",
"group": "appliance",
"file": "svg/menu/cooktop.svg"
},
{
"id": "dining_table",
"name_ru": "Обеденные столы",
"name_en": "Dining tables",
"group": "furniture",
"file": "svg/menu/dining_table.svg"
},
{
"id": "dishwasher",
"name_ru": "Посудомоечная машина",
"name_en": "Dishwasher",
"group": "appliance",
"file": "svg/menu/dishwasher.svg"
},
{
"id": "dryer",
"name_ru": "Сушильная машина",
"name_en": "Dryer",
"group": "appliance",
"file": "svg/menu/dryer.svg"
},
{
"id": "exercise",
"name_ru": "Тренажёр",
"name_en": "Exercise equipment",
"group": "other",
"file": "svg/menu/exercise.svg"
},
{
"id": "fireplace",
"name_ru": "Камин",
"name_en": "Fireplace",
"group": "other",
"file": "svg/menu/fireplace.svg"
},
{
"id": "fridge",
"name_ru": "Холодильник",
"name_en": "Refrigerator",
"group": "appliance",
"file": "svg/menu/fridge.svg"
},
{
"id": "hood",
"name_ru": "Вытяжка",
"name_en": "Range hood",
"group": "appliance",
"file": "svg/menu/hood.svg"
},
{
"id": "kitchen_cabinet",
"name_ru": "Кухонные шкафы",
"name_en": "Kitchen cabinets",
"group": "furniture",
"file": "svg/menu/kitchen_cabinet.svg"
},
{
"id": "kitchen_sink",
"name_ru": "Кухонные мойки",
"name_en": "Kitchen sinks",
"group": "sanitary",
"file": "svg/menu/kitchen_sink.svg"
},
{
"id": "nightstand",
"name_ru": "Тумбы",
"name_en": "Cabinets",
"group": "furniture",
"file": "svg/menu/nightstand.svg"
},
{
"id": "oven",
"name_ru": "Духовка",
"name_en": "Oven",
"group": "appliance",
"file": "svg/menu/oven.svg"
},
{
"id": "plant",
"name_ru": "Растение",
"name_en": "Plant",
"group": "other",
"file": "svg/menu/plant.svg"
},
{
"id": "rug",
"name_ru": "Ковер",
"name_en": "Rug",
"group": "other",
"file": "svg/menu/rug.svg"
},
{
"id": "shelving",
"name_ru": "Стеллажи",
"name_en": "Shelving",
"group": "furniture",
"file": "svg/menu/shelving.svg"
},
{
"id": "shower",
"name_ru": "Душ",
"name_en": "Shower",
"group": "sanitary",
"file": "svg/menu/shower.svg"
},
{
"id": "sink",
"name_ru": "Раковина",
"name_en": "Sink",
"group": "sanitary",
"file": "svg/menu/sink.svg"
},
{
"id": "sofa",
"name_ru": "Диваны",
"name_en": "Sofas",
"group": "furniture",
"file": "svg/menu/sofa.svg"
},
{
"id": "stairs",
"name_ru": "Лестница",
"name_en": "Stairs",
"group": "other",
"file": "svg/menu/stairs.svg"
},
{
"id": "toilet",
"name_ru": "Унитазы",
"name_en": "Toilets",
"group": "sanitary",
"file": "svg/menu/toilet.svg"
},
{
"id": "tv",
"name_ru": "Телевизоры",
"name_en": "Televisions",
"group": "appliance",
"file": "svg/menu/tv.svg"
},
{
"id": "wardrobe",
"name_ru": "Шкафы",
"name_en": "Wardrobes",
"group": "furniture",
"file": "svg/menu/wardrobe.svg"
},
{
"id": "washer",
"name_ru": "Стиральная машина",
"name_en": "Washing machine",
"group": "appliance",
"file": "svg/menu/washer.svg"
},
{
"id": "work_table",
"name_ru": "Рабочие столы",
"name_en": "Desks",
"group": "furniture",
"file": "svg/menu/work_table.svg"
}
],
"symbols": [
{
"id": "coffee_table",
"operation": "replace",
"name_ru": "Журнальный стол, прямоугольный",
"name_en": "Rectangular coffee table",
"group": "furniture",
"width_cm": 120,
"depth_cm": 60,
"back": "top",
"file": "svg/plan/coffee_table.svg",
"menu_icon": "coffee_table",
"notes": "Типовой размер; свободно стоящий предмет."
},
{
"id": "coffee_table_round",
"operation": "add",
"name_ru": "Журнальный стол, круглый",
"name_en": "Round coffee table",
"group": "furniture",
"width_cm": 80,
"depth_cm": 80,
"back": "top",
"file": "svg/plan/coffee_table_round.svg",
"menu_icon": "coffee_table",
"notes": "Круглый свободно стоящий предмет."
},
{
"id": "coffee_table_oval",
"operation": "add",
"name_ru": "Журнальный стол, овальный",
"name_en": "Oval coffee table",
"group": "furniture",
"width_cm": 120,
"depth_cm": 60,
"back": "top",
"file": "svg/plan/coffee_table_oval.svg",
"menu_icon": "coffee_table",
"notes": "Свободно стоящий предмет."
},
{
"id": "coffee_table_rounded",
"operation": "add",
"name_ru": "Журнальный стол, скруглённый",
"name_en": "Rounded coffee table",
"group": "furniture",
"width_cm": 120,
"depth_cm": 60,
"back": "top",
"file": "svg/plan/coffee_table_rounded.svg",
"menu_icon": "coffee_table",
"notes": "Прямоугольная форма со скруглёнными углами."
},
{
"id": "table_dining",
"operation": "replace",
"name_ru": "Обеденный стол, прямоугольный",
"name_en": "Rectangular dining table",
"group": "furniture",
"width_cm": 160,
"depth_cm": 90,
"back": "top",
"file": "svg/plan/table_dining.svg",
"menu_icon": "dining_table",
"notes": "Типовой стол на 4–6 мест."
},
{
"id": "table_round",
"operation": "replace",
"name_ru": "Обеденный стол, круглый",
"name_en": "Round dining table",
"group": "furniture",
"width_cm": 110,
"depth_cm": 110,
"back": "top",
"file": "svg/plan/table_round.svg",
"menu_icon": "dining_table",
"notes": "Круглый стол на 4 места."
},
{
"id": "table_dining_oval",
"operation": "add",
"name_ru": "Обеденный стол, овальный",
"name_en": "Oval dining table",
"group": "furniture",
"width_cm": 180,
"depth_cm": 100,
"back": "top",
"file": "svg/plan/table_dining_oval.svg",
"menu_icon": "dining_table",
"notes": "Овальный стол на 6 мест."
},
{
"id": "table_dining_rounded",
"operation": "add",
"name_ru": "Обеденный стол, скруглённый",
"name_en": "Rounded dining table",
"group": "furniture",
"width_cm": 160,
"depth_cm": 90,
"back": "top",
"file": "svg/plan/table_dining_rounded.svg",
"menu_icon": "dining_table",
"notes": "Прямоугольная форма со скруглёнными углами."
},
{
"id": "desk",
"operation": "replace",
"name_ru": "Рабочий стол, прямоугольный",
"name_en": "Rectangular desk",
"group": "furniture",
"width_cm": 140,
"depth_cm": 70,
"back": "top",
"file": "svg/plan/desk.svg",
"menu_icon": "work_table",
"notes": "BACK — длинная сторона у стены."
},
{
"id": "desk_corner",
"operation": "add",
"name_ru": "Рабочий стол, угловой",
"name_en": "Corner desk",
"group": "furniture",
"width_cm": 160,
"depth_cm": 160,
"back": "top",
"file": "svg/plan/desk_corner.svg",
"menu_icon": "work_table",
"notes": "Угловой стол; основная задняя сторона направлена вверх."
},
{
"id": "chair",
"operation": "replace",
"name_ru": "Стул",
"name_en": "Chair",
"group": "furniture",
"width_cm": 50,
"depth_cm": 50,
"back": "top",
"file": "svg/plan/chair.svg",
"menu_icon": "chair",
"notes": "Спинка находится сверху."
},
{
"id": "chair_bar",
"operation": "add",
"name_ru": "Барный стул",
"name_en": "Bar stool",
"group": "furniture",
"width_cm": 45,
"depth_cm": 48,
"back": "top",
"file": "svg/plan/chair_bar.svg",
"menu_icon": "chair",
"notes": "Спинка находится сверху."
},
{
"id": "armchair",
"operation": "replace",
"name_ru": "Кресло мягкое",
"name_en": "Soft armchair",
"group": "furniture",
"width_cm": 90,
"depth_cm": 90,
"back": "top",
"file": "svg/plan/armchair.svg",
"menu_icon": "armchair",
"notes": "Спинка находится сверху."
},
{
"id": "armchair_office",
"operation": "add",
"name_ru": "Кресло офисное",
"name_en": "Office chair",
"group": "furniture",
"width_cm": 65,
"depth_cm": 65,
"back": "top",
"file": "svg/plan/armchair_office.svg",
"menu_icon": "armchair",
"notes": "Спинка находится сверху."
},
{
"id": "sofa",
"operation": "replace",
"name_ru": "Диван двухместный",
"name_en": "Two-seat sofa",
"group": "furniture",
"width_cm": 180,
"depth_cm": 90,
"back": "top",
"file": "svg/plan/sofa.svg",
"menu_icon": "sofa",
"notes": "Спинка находится сверху."
},
{
"id": "sofa_three_seat",
"operation": "add",
"name_ru": "Диван трёхместный",
"name_en": "Three-seat sofa",
"group": "furniture",
"width_cm": 240,
"depth_cm": 90,
"back": "top",
"file": "svg/plan/sofa_three_seat.svg",
"menu_icon": "sofa",
"notes": "Спинка находится сверху."
},
{
"id": "sofa_corner_right",
"operation": "add",
"name_ru": "Угловой диван, правый",
"name_en": "Right sectional sofa",
"group": "furniture",
"width_cm": 260,
"depth_cm": 170,
"back": "top",
"file": "svg/plan/sofa_corner_right.svg",
"menu_icon": "sofa",
"notes": "Шезлонг справа при взгляде сверху; спинка сверху."
},
{
"id": "bed_single",
"operation": "replace",
"name_ru": "Кровать односпальная",
"name_en": "Single bed",
"group": "furniture",
"width_cm": 90,
"depth_cm": 200,
"back": "top",
"file": "svg/plan/bed_single.svg",
"menu_icon": "bed",
"notes": "Узкая кровать с одной подушкой; изголовье сверху."
},
{
"id": "bed_double",
"operation": "replace",
"name_ru": "Кровать двуспальная",
"name_en": "Double bed",
"group": "furniture",
"width_cm": 160,
"depth_cm": 200,
"back": "top",
"file": "svg/plan/bed_double.svg",
"menu_icon": "bed",
"notes": "Широкая кровать с двумя подушками; изголовье сверху."
},
{
"id": "nightstand",
"operation": "replace",
"name_ru": "Тумба прикроватная",
"name_en": "Bedside table",
"group": "furniture",
"width_cm": 50,
"depth_cm": 40,
"back": "top",
"file": "svg/plan/nightstand.svg",
"menu_icon": "nightstand",
"notes": "Задняя сторона сверху."
},
{
"id": "cabinet_tv",
"operation": "add",
"name_ru": "Тумба под телевизор",
"name_en": "TV cabinet",
"group": "furniture",
"width_cm": 140,
"depth_cm": 45,
"back": "top",
"file": "svg/plan/cabinet_tv.svg",
"menu_icon": "nightstand",
"notes": "Задняя длинная сторона сверху."
},
{
"id": "cabinet_shoe",
"operation": "add",
"name_ru": "Тумба для обуви",
"name_en": "Shoe cabinet",
"group": "furniture",
"width_cm": 80,
"depth_cm": 35,
"back": "top",
"file": "svg/plan/cabinet_shoe.svg",
"menu_icon": "nightstand",
"notes": "Задняя сторона сверху."
},
{
"id": "cabinet_sink",
"operation": "add",
"name_ru": "Тумба под раковину",
"name_en": "Sink cabinet",
"group": "furniture",
"width_cm": 80,
"depth_cm": 50,
"back": "top",
"file": "svg/plan/cabinet_sink.svg",
"menu_icon": "nightstand",
"notes": "Задняя сторона сверху."
},
{
"id": "bookshelf",
"operation": "replace",
"name_ru": "Шкаф книжный",
"name_en": "Bookcase",
"group": "furniture",
"width_cm": 100,
"depth_cm": 35,
"back": "top",
"file": "svg/plan/bookshelf.svg",
"menu_icon": "wardrobe",
"notes": "Задняя длинная сторона сверху."
},
{
"id": "wall_unit",
"operation": "add",
"name_ru": "Шкаф-стенка",
"name_en": "Wall unit",
"group": "furniture",
"width_cm": 240,
"depth_cm": 45,
"back": "top",
"file": "svg/plan/wall_unit.svg",
"menu_icon": "wardrobe",
"notes": "Задняя длинная сторона сверху."
},
{
"id": "wardrobe",
"operation": "replace",
"name_ru": "Шкаф гардеробный",
"name_en": "Wardrobe",
"group": "furniture",
"width_cm": 180,
"depth_cm": 60,
"back": "top",
"file": "svg/plan/wardrobe.svg",
"menu_icon": "wardrobe",
"notes": "Задняя длинная сторона сверху."
},
{
"id": "kitchen_floor",
"operation": "add",
"name_ru": "Кухонный напольный модуль",
"name_en": "Kitchen floor module",
"group": "furniture",
"width_cm": 60,
"depth_cm": 60,
"back": "top",
"file": "svg/plan/kitchen_floor.svg",
"menu_icon": "kitchen_cabinet",
"notes": "Задняя сторона сверху."
},
{
"id": "kitchen_floor_corner",
"operation": "add",
"name_ru": "Кухонный напольный угловой модуль",
"name_en": "Kitchen floor corner module",
"group": "furniture",
"width_cm": 90,
"depth_cm": 90,
"back": "top",
"file": "svg/plan/kitchen_floor_corner.svg",
"menu_icon": "kitchen_cabinet",
"notes": "Угловой модуль; основной BACK сверху."
},
{
"id": "kitchen_wall",
"operation": "add",
"name_ru": "Кухонный навесной модуль",
"name_en": "Kitchen wall module",
"group": "furniture",
"width_cm": 60,
"depth_cm": 35,
"back": "top",
"file": "svg/plan/kitchen_wall.svg",
"menu_icon": "kitchen_cabinet",
"notes": "Задняя сторона сверху."
},
{
"id": "kitchen_wall_corner",
"operation": "add",
"name_ru": "Кухонный навесной угловой модуль",
"name_en": "Kitchen wall corner module",
"group": "furniture",
"width_cm": 60,
"depth_cm": 60,
"back": "top",
"file": "svg/plan/kitchen_wall_corner.svg",
"menu_icon": "kitchen_cabinet",
"notes": "Угловой модуль; основной BACK сверху."
},
{
"id": "shelf_floor",
"operation": "add",
"name_ru": "Стеллаж напольный",
"name_en": "Floor shelving unit",
"group": "furniture",
"width_cm": 100,
"depth_cm": 35,
"back": "top",
"file": "svg/plan/shelf_floor.svg",
"menu_icon": "shelving",
"notes": "Задняя длинная сторона сверху."
},
{
"id": "shelf_wall",
"operation": "add",
"name_ru": "Полка настенная",
"name_en": "Wall shelf",
"group": "furniture",
"width_cm": 100,
"depth_cm": 25,
"back": "top",
"file": "svg/plan/shelf_wall.svg",
"menu_icon": "shelving",
"notes": "Задняя длинная сторона сверху."
},
{
"id": "cooktop_two",
"operation": "add",
"name_ru": "Варочная панель, 2 конфорки",
"name_en": "Two-burner cooktop",
"group": "appliance",
"width_cm": 30,
"depth_cm": 50,
"back": "top",
"file": "svg/plan/cooktop_two.svg",
"menu_icon": "cooktop",
"notes": "Панель ориентирована управляющей стороной вниз."
},
{
"id": "stove",
"operation": "replace",
"name_ru": "Варочная панель, 4 конфорки",
"name_en": "Four-burner cooktop",
"group": "appliance",
"width_cm": 60,
"depth_cm": 60,
"back": "top",
"file": "svg/plan/stove.svg",
"menu_icon": "cooktop",
"notes": "Панель ориентирована управляющей стороной вниз."
},
{
"id": "tv",
"operation": "replace",
"name_ru": "Телевизор на подставке",
"name_en": "TV on stand",
"group": "appliance",
"width_cm": 120,
"depth_cm": 28,
"back": "top",
"file": "svg/plan/tv.svg",
"menu_icon": "tv",
"notes": "Задняя сторона экрана сверху."
},
{
"id": "tv_wall",
"operation": "add",
"name_ru": "Телевизор на кронштейне",
"name_en": "Wall-mounted TV",
"group": "appliance",
"width_cm": 120,
"depth_cm": 15,
"back": "top",
"file": "svg/plan/tv_wall.svg",
"menu_icon": "tv",
"notes": "Сторона крепления к стене сверху."
},
{
"id": "toilet",
"operation": "replace",
"name_ru": "Унитаз напольный",
"name_en": "Floor-standing toilet",
"group": "sanitary",
"width_cm": 40,
"depth_cm": 70,
"back": "top",
"file": "svg/plan/toilet.svg",
"menu_icon": "toilet",
"notes": "Сторона подключения к стене сверху."
},
{
"id": "toilet_built_in",
"operation": "add",
"name_ru": "Унитаз встроенный",
"name_en": "Built-in toilet",
"group": "sanitary",
"width_cm": 40,
"depth_cm": 55,
"back": "top",
"file": "svg/plan/toilet_built_in.svg",
"menu_icon": "toilet",
"notes": "Инсталляция находится сверху."
},
{
"id": "bathtub",
"operation": "replace",
"name_ru": "Ванна прямоугольная",
"name_en": "Rectangular bathtub",
"group": "sanitary",
"width_cm": 170,
"depth_cm": 75,
"back": "top",
"file": "svg/plan/bathtub.svg",
"menu_icon": "bathtub",
"notes": "Длинная задняя сторона сверху."
},
{
"id": "bathtub_corner",
"operation": "add",
"name_ru": "Ванна угловая",
"name_en": "Corner bathtub",
"group": "sanitary",
"width_cm": 140,
"depth_cm": 140,
"back": "top",
"file": "svg/plan/bathtub_corner.svg",
"menu_icon": "bathtub",
"notes": "Угловая ванна; основной BACK сверху."
},
{
"id": "bidet",
"operation": "replace",
"name_ru": "Биде напольное",
"name_en": "Floor-standing bidet",
"group": "sanitary",
"width_cm": 40,
"depth_cm": 60,
"back": "top",
"file": "svg/plan/bidet.svg",
"menu_icon": "bidet",
"notes": "Сторона подключения к стене сверху."
},
{
"id": "bidet_built_in",
"operation": "add",
"name_ru": "Биде встроенное",
"name_en": "Built-in bidet",
"group": "sanitary",
"width_cm": 40,
"depth_cm": 50,
"back": "top",
"file": "svg/plan/bidet_built_in.svg",
"menu_icon": "bidet",
"notes": "Инсталляция находится сверху."
},
{
"id": "kitchen_sink",
"operation": "replace",
"name_ru": "Кухонная мойка одинарная",
"name_en": "Single kitchen sink",
"group": "sanitary",
"width_cm": 60,
"depth_cm": 50,
"back": "top",
"file": "svg/plan/kitchen_sink.svg",
"menu_icon": "kitchen_sink",
"notes": "Задняя сторона столешницы сверху."
},
{
"id": "kitchen_sink_double",
"operation": "add",
"name_ru": "Кухонная мойка двойная",
"name_en": "Double kitchen sink",
"group": "sanitary",
"width_cm": 90,
"depth_cm": 50,
"back": "top",
"file": "svg/plan/kitchen_sink_double.svg",
"menu_icon": "kitchen_sink",
"notes": "Задняя сторона столешницы сверху."
}
]
}
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M20.5 39h69a2.5 2.5 0 0 1 2.5 2.5V69a2.5 2.5 0 0 1-2.5 2.5h-69A2.5 2.5 0 0 1 18 69V41.5a2.5 2.5 0 0 1 2.5-2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M25 63a2.5 2.5 0 0 1 2.5-2.5h55A2.5 2.5 0 0 1 85 63v8.5H25z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M25 63a2.5 2.5 0 0 1 2.5-2.5h55A2.5 2.5 0 0 1 85 63v3.5H25z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 661 B

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<path d="M42.5 29h25a10 10 0 0 1 10 10v26.5a10 10 0 0 1-10 10h-25a10 10 0 0 1-10-10V39a10 10 0 0 1 10-10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M77.5 48.5a6 6 0 0 1 2 11.657V71.5a4 4 0 0 1-4 4H34a4 4 0 0 1-4-4V60.157A6.001 6.001 0 0 1 32 48.5a6 6 0 0 1 5.419 3.424A4 4 0 0 1 38 54v7h33.5v-6.5q0-.107.005-.213A6 6 0 0 1 77.5 48.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M42.75 57h24.5A4.75 4.75 0 0 1 72 61.75a4.75 4.75 0 0 1-4.75 4.75h-24.5A4.75 4.75 0 0 1 38 61.75 4.75 4.75 0 0 1 42.75 57M36.5 75.5h8l-1.674 5.441A1.5 1.5 0 0 1 41.392 82H38a1.5 1.5 0 0 1-1.5-1.5zm36 0h-8l1.674 5.441A1.5 1.5 0 0 0 67.608 82H71a1.5 1.5 0 0 0 1.5-1.5z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 960 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M28 75h8l-1.674 5.441a1.5 1.5 0 0 1-1.434 1.059H29.5A1.5 1.5 0 0 1 28 80zm53 0h-8l1.674 5.441a1.5 1.5 0 0 0 1.434 1.059H79.5A1.5 1.5 0 0 0 81 80zM19.5 46.5H90a2.5 2.5 0 0 1 2.5 2.5 2.5 2.5 0 0 1-2.5 2.5H19.5A2.5 2.5 0 0 1 17 49a2.5 2.5 0 0 1 2.5-2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.5 51.5H90L86.633 68a10 10 0 0 1-9.798 8h-44.17a10 10 0 0 1-9.798-8zM30 36v-2a5 5 0 1 0-10 0v12.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 666 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M16 75.5h8l-1.674 5.441A1.5 1.5 0 0 1 20.892 82H17.5a1.5 1.5 0 0 1-1.5-1.5zm78.5 0h-8l1.674 5.441A1.5 1.5 0 0 0 89.608 82H93a1.5 1.5 0 0 0 1.5-1.5zM25 29h60.5a2.5 2.5 0 0 1 2.5 2.5V68a2.5 2.5 0 0 1-2.5 2.5H25a2.5 2.5 0 0 1-2.5-2.5V31.5A2.5 2.5 0 0 1 25 29" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 59.5c0-5.523 4.477-10 10-10h58.5c5.523 0 10 4.477 10 10v6H16zm-1 6h80.5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H15a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1m15.5-20a5 5 0 0 1 5-5h11a5 5 0 0 1 5 5v4h-21zm28.5 0a5 5 0 0 1 5-5h11a5 5 0 0 1 5 5v4H59z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 801 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<path d="M36 57.5c0 5.523 4.477 10 10 10h18.5c5.523 0 10-4.477 10-10V53H36zm8.5 22a1 1 0 0 0 1 1H65a1 1 0 0 0 1-1v-12H44.5zm-8.5-32a1 1 0 0 1 1-1h36.5a1 1 0 0 1 1 1V53H36zM50.5 41a1 1 0 0 1 1-1H59a1 1 0 0 1 1 1v5.5h-9.5zM53 30a1 1 0 0 1 1-1h2.5a1 1 0 0 1 1 1v10H53zm-4 6a1 1 0 0 1-1-1v-2.5a1 1 0 0 1 1-1h4V36z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 485 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M46.5 81.5h5V86a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1zm12.5 0h5V86a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1zM44 23h22.5a8 8 0 0 1 8 8v42.5a8 8 0 0 1-8 8H44a8 8 0 0 1-8-8V31a8 8 0 0 1 8-8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M36 31a8 8 0 0 1 8-8h22.5a8 8 0 0 1 8 8v42H36z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M48.5 42.5a7 7 0 1 0 14 0 7 7 0 1 0-14 0m6.5 0 3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 703 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M40.5 39.5C40.5 32.596 46.096 27 53 27h4c6.904 0 12.5 5.596 12.5 12.5v13a5 5 0 0 1-5 5h-19a5 5 0 0 1-5-5z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M41.25 54h27.5A3.25 3.25 0 0 1 72 57.25a3.25 3.25 0 0 1-3.25 3.25h-27.5A3.25 3.25 0 0 1 38 57.25 3.25 3.25 0 0 1 41.25 54m28.25 6.5H64l1.88 21.63a1.5 1.5 0 0 0 1.495 1.37H68a1.5 1.5 0 0 0 1.5-1.5zm-29 0H46l-1.88 21.63a1.5 1.5 0 0 1-1.495 1.37H42a1.5 1.5 0 0 1-1.5-1.5z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 690 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M25 51h5.238l-1.534 15.646A1.5 1.5 0 0 1 27.211 68H26.5a1.5 1.5 0 0 1-1.5-1.5zm60.238 0H80l1.534 15.646A1.5 1.5 0 0 0 83.027 68h.711a1.5 1.5 0 0 0 1.5-1.5z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M24.34 43.485A2.5 2.5 0 0 1 26.625 42h56.75a2.5 2.5 0 0 1 2.285 1.485l2.715 6.109A1 1 0 0 1 87.461 51H22.54a1 1 0 0 1-.914-1.406z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M87.5 51h-66v3a1 1 0 0 0 1 1h64a1 1 0 0 0 1-1z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 764 B

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<path d="M24.5 38h40a2.5 2.5 0 0 1 2.5 2.5v22a2.5 2.5 0 0 1-2.5 2.5h-40a2.5 2.5 0 0 1-2.5-2.5v-22a2.5 2.5 0 0 1 2.5-2.5m50 0h11a2.5 2.5 0 0 1 2.5 2.5v29a2.5 2.5 0 0 1-2.5 2.5h-11a2.5 2.5 0 0 1-2.5-2.5v-29a2.5 2.5 0 0 1 2.5-2.5M43 65h5v7h-5zm-6 7h16m23-12h8m-8-17h8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M35 56V46h5m-5 7h5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M39.5 53a3.5 3.5 0 1 0 0-7m15.036 8.536a5 5 0 1 1 0-7.071" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 745 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M27.5 29h55a2.5 2.5 0 0 1 2.5 2.5v47a2.5 2.5 0 0 1-2.5 2.5h-55a2.5 2.5 0 0 1-2.5-2.5v-47a2.5 2.5 0 0 1 2.5-2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M33.5 66.5a8 8 0 1 0 16 0 8 8 0 1 0-16 0m26.5-23a8 8 0 1 0 16 0 8 8 0 1 0-16 0m3 23a5 5 0 1 0 10 0 5 5 0 1 0-10 0m-29.5-23a8 8 0 1 0 16 0 8 8 0 1 0-16 0" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 579 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M83.5 43H78l6.742 30.82A1.5 1.5 0 0 0 86.207 75h.54a1.5 1.5 0 0 0 1.482-1.732zm-56 0H33l-6.742 30.82A1.5 1.5 0 0 1 24.793 75h-.54a1.5 1.5 0 0 1-1.482-1.732zm-2.62-7.406A2.5 2.5 0 0 1 27.21 34h56.58a2.5 2.5 0 0 1 2.33 1.594l2.35 6.044A1 1 0 0 1 87.538 43H23.462a1 1 0 0 1-.932-1.362z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M88.5 43h-66v3a1 1 0 0 0 1 1h64a1 1 0 0 0 1-1z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 645 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M31.5 25h47a2.5 2.5 0 0 1 2.5 2.5v55a2.5 2.5 0 0 1-2.5 2.5h-47a2.5 2.5 0 0 1-2.5-2.5v-55a2.5 2.5 0 0 1 2.5-2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.5 37.5h47A2.5 2.5 0 0 1 81 40v36a2.5 2.5 0 0 1-2.5 2.5h-47A2.5 2.5 0 0 1 29 76V40a2.5 2.5 0 0 1 2.5-2.5M43 42h24m-1.5-11a2 2 0 1 0 4 0 2 2 0 1 0-4 0m7.5 0a2 2 0 1 0 4 0 2 2 0 1 0-4 0m-37.25 0h9.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 626 B

@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M31.5 25h47a2.5 2.5 0 0 1 2.5 2.5v55a2.5 2.5 0 0 1-2.5 2.5h-47a2.5 2.5 0 0 1-2.5-2.5v-55a2.5 2.5 0 0 1 2.5-2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.5 37.5h47A2.5 2.5 0 0 1 81 40v42.5a2.5 2.5 0 0 1-2.5 2.5h-47a2.5 2.5 0 0 1-2.5-2.5V40a2.5 2.5 0 0 1 2.5-2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M38 60.5a17 17 0 1 0 34 0 17 17 0 1 0-34 0" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M43.5 60.5a11.5 11.5 0 1 0 23 0 11.5 11.5 0 1 0-23 0m22-29.5a2 2 0 1 0 4 0 2 2 0 1 0-4 0m7.5 0a2 2 0 1 0 4 0 2 2 0 1 0-4 0m-37.25 0h9.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M55.994 55.5s-1.329 1.81-1.479 3.143c-.198 1.761 1.599 2.554 1.48 4.321-.1 1.479-1.48 3.536-1.48 3.536m5.979-11s-1.329 1.81-1.479 3.143c-.198 1.761 1.599 2.554 1.48 4.321-.1 1.479-1.48 3.536-1.48 3.536m-8.519-11s-.886 1.81-.986 3.143c-.132 1.761 1.066 2.554.986 4.321-.067 1.479-.986 3.536-.986 3.536" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="m60.752 62.03-5.379-2.312 11.066-25.75a1.5 1.5 0 0 1 1.97-.786l2.624 1.127a1.5 1.5 0 0 1 .786 1.97zm-18.648-4.512-4.353 1.189-4.474-16.382a1.5 1.5 0 0 1 1.052-1.842l1.459-.398a1.5 1.5 0 0 1 1.842 1.052z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M41.297 38.173a2.215 2.215 0 0 1 0 4.43H29.781a2.215 2.215 0 0 1 0-4.43zm33.219 38.091a2.657 2.657 0 1 1 0 5.315H28.453a2.657 2.657 0 1 1 0-5.315zm8.641-51.366a1.795 1.795 0 0 1 .68 2.41l-3.03 5.59a5 5 0 0 1-4.396 2.618H65.657a1.772 1.772 0 1 1 0-3.544h9.61a3 3 0 0 0 2.598-1.5l2.84-4.917a1.795 1.795 0 0 1 2.452-.657M29.339 65.787c0-6.699 6.457-11.503 12.873-9.579l24.291 7.288a10 10 0 0 1 7.127 9.578v3.19H29.339zm0 15.852h7.086l-1.394 3.486a1.5 1.5 0 0 1-1.393.943h-2.8a1.5 1.5 0 0 1-1.5-1.5zm44.291 0h-7.087l1.395 3.486a1.5 1.5 0 0 0 1.392.943h2.8a1.5 1.5 0 0 0 1.5-1.5z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M35.54 66.077a4.872 4.872 0 1 0 9.743 0 4.872 4.872 0 1 0-9.744 0" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M26.672 34.425h55.99v39.47h-55.99Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M23 74.894a1 1 0 0 1 1-1h61.334a1 1 0 0 1 1 1v5.802a1 1 0 0 1-1 1H24a1 1 0 0 1-1-1zM23 29a1 1 0 0 1 1-1h61.334a1 1 0 0 1 1 1v4.425a1 1 0 0 1-1 1H24a1 1 0 0 1-1-1zm15.145 21.44a5 5 0 0 1 5-5h23.043a5 5 0 0 1 5 5v23.454H38.145z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M44.57 65.633a2.754 2.754 0 0 1 2.754-2.753H62.01v5.507H47.324a2.754 2.754 0 0 1-2.754-2.754m20.194 5.507a2.754 2.754 0 0 0-2.754-2.753H47.324v5.507H62.01a2.754 2.754 0 0 0 2.754-2.754" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M44.57 71.14a2.754 2.754 0 1 0 5.508 0 2.754 2.754 0 1 0-5.508 0m14.688-5.507a2.754 2.754 0 1 0 5.507 0 2.754 2.754 0 1 0-5.507 0" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M35.5 18H75a2.5 2.5 0 0 1 2.5 2.5v64A2.5 2.5 0 0 1 75 87H35.5a2.5 2.5 0 0 1-2.5-2.5v-64a2.5 2.5 0 0 1 2.5-2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M33 46.5h44.5v38A2.5 2.5 0 0 1 75 87H35.5a2.5 2.5 0 0 1-2.5-2.5zm2.5 44a1 1 0 0 0 1 1h37a1 1 0 0 0 1-1V87h-39zm3-49.5v-9.5m0 30.5v-9.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 560 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<path d="M44.308 57h21.384L83 69H27zM44 37.5a2.5 2.5 0 0 1 2.5-2.5h17a2.5 2.5 0 0 1 2.5 2.5V57H44zM80.5 74h-51a2.5 2.5 0 0 1-2.5-2.5V70a1 1 0 0 1 1-1h54a1 1 0 0 1 1 1v1.5a2.5 2.5 0 0 1-2.5 2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 368 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<path d="M38 88a1 1 0 0 0 1 1h32a1 1 0 0 0 1-1v-3H38zm17-68h16.5a2.5 2.5 0 0 1 2.5 2.5v17a2.5 2.5 0 0 1-2.5 2.5H55zm-19 2.5a2.5 2.5 0 0 1 2.5-2.5H55v22H38.5a2.5 2.5 0 0 1-2.5-2.5zM59 33v5m-8-5v5m4 47h16.5a2.5 2.5 0 0 0 2.5-2.5v-17a2.5 2.5 0 0 0-2.5-2.5H55zm-19-2.5a2.5 2.5 0 0 0 2.5 2.5H55V63H38.5a2.5 2.5 0 0 0-2.5 2.5zM67 67h-5m-14 0h-5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 514 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M21.5 84h68a2.5 2.5 0 0 0 2.5-2.5v-35a2.5 2.5 0 0 0-2.5-2.5h-68a2.5 2.5 0 0 0-2.5 2.5v35a2.5 2.5 0 0 0 2.5 2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M26 78h26.5a1 1 0 0 0 1-1V51a1 1 0 0 0-1-1H26a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1m23-24v5m9.5 19H85a1 1 0 0 0 1-1V51a1 1 0 0 0-1-1H58.5a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1m-6-39.5a1 1 0 0 0-1-1H44a1 1 0 0 0-1 1V44h9.5zM58 33v-2a5 5 0 1 0-10 0v6.5M62 54v5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 671 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M76.5 73.5h-8l1.674 5.441A1.5 1.5 0 0 0 71.608 80H75a1.5 1.5 0 0 0 1.5-1.5zm-42.5 0h8l-1.674 5.441A1.5 1.5 0 0 1 38.892 80H35.5a1.5 1.5 0 0 1-1.5-1.5z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M34.5 30.5h41A2.5 2.5 0 0 1 78 33v40.5a2.5 2.5 0 0 1-2.5 2.5h-41a2.5 2.5 0 0 1-2.5-2.5V33a2.5 2.5 0 0 1 2.5-2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32 39.5h46v18H32Zm18.5 9H60M50.5 66H60" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 734 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M31.5 25h47a2.5 2.5 0 0 1 2.5 2.5v55a2.5 2.5 0 0 1-2.5 2.5h-47a2.5 2.5 0 0 1-2.5-2.5v-55a2.5 2.5 0 0 1 2.5-2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.5 37.5h47A2.5 2.5 0 0 1 81 40v36a2.5 2.5 0 0 1-2.5 2.5h-47A2.5 2.5 0 0 1 29 76V40a2.5 2.5 0 0 1 2.5-2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M40.5 47h29a2.5 2.5 0 0 1 2.5 2.5v20a2.5 2.5 0 0 1-2.5 2.5h-29a2.5 2.5 0 0 1-2.5-2.5v-20a2.5 2.5 0 0 1 2.5-2.5m25-16a2 2 0 1 0 4 0 2 2 0 1 0-4 0m7.5 0a2 2 0 1 0 4 0 2 2 0 1 0-4 0m-37.25 0h9.5M43 42h24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 851 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M45.952 75.849a2.5 2.5 0 0 0 2.485 2.231H63.43a2.5 2.5 0 0 0 2.485-2.231l1.571-14.532H44.381zM60.236 44.78C58.423 36.174 66.805 29.605 77 31.87c0 8.155-8.609 16.763-16.763 12.912m-9.761 5.147c1.517-8.665-7.085-14.943-17.195-12.33.28 8.15 9.178 16.458 17.195 12.33" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M55.706 55.88v-2.944m0 0c1.133-5.89 7.09-10.956 12.912-15.404M55.706 52.936c-4.938-4.146-7.982-6.16-14.044-9.061m1.453 12.005h25.636a1 1 0 0 1 1 1v3.437a1 1 0 0 1-1 1H43.115a1 1 0 0 1-1-1v-3.436a1 1 0 0 1 1-1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 788 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M90.27 33.874h-5.622m-59.028 0H20m70.27 5.152h-5.622m-59.028 0H20m70.27 5.154h-5.622m-59.028 0H20m70.27 5.152h-5.622m-59.028 0H20m70.27 5.153h-5.622m-59.028 0H20m70.27 5.153h-5.622m-59.028 0H20m70.27 5.153h-5.622m-59.028 0H20m70.27 5.153h-5.622m-59.028 0H20m70.27 5.153h-5.622m-59.028 0H20M28.12 32h54.025a2.5 2.5 0 0 1 2.5 2.5v40.439a2.5 2.5 0 0 1-2.5 2.5H28.12a2.5 2.5 0 0 1-2.5-2.5V34.5a2.5 2.5 0 0 1 2.5-2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M25.62 34.5a2.5 2.5 0 0 1 2.5-2.5h1.717v45.439H28.12a2.5 2.5 0 0 1-2.5-2.5zm59.026 0a2.5 2.5 0 0 0-2.5-2.5H80.43v45.439h1.716a2.5 2.5 0 0 0 2.5-2.5zM37.8 54.485l17.801-8.432 17.801 8.432-17.8 8.432z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 926 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M28 58.5h53V39H28Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M64 58.5h5V47a1 1 0 0 0-1-1h-3a1 1 0 0 0-1 1zM52.5 39h-5V27.5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1zm-12 0h-5V27.5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1zm-5 29.5V71a1 1 0 0 1 1 1H49a1 1 0 0 1 1-1v-2.5a1 1 0 0 1-1-1H36.5a1 1 0 0 1-1 1m33.5-10h7v-14a1 1 0 0 0-1-1h-5a1 1 0 0 0-1 1zM47.5 39h-7V25a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1zm-12 34v4a1 1 0 0 1 1 1h14a1 1 0 0 1 1-1v-4a1 1 0 0 1-1-1h-14a1 1 0 0 1-1 1M28 78h53v7H28Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M28 89.5V23a2.5 2.5 0 0 1 2.5-2.5h48A2.5 2.5 0 0 1 81 23v66.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 911 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M31 84h49a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H31a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1m31.5-47.6v-4.686a11.7 11.7 0 0 0-3.441-8.283A11.77 11.77 0 0 0 50.75 20a11.77 11.77 0 0 0-8.309 3.431A11.7 11.7 0 0 0 39 31.714V84" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M72 39.25a10.25 10.25 0 1 0-20.5 0zm-17.25 5v2.5m7.25-2.5v2.5m7.25-2.5v2.5m-14.5 3.75V53M62 50.5V53m7.25-2.5V53m-14.5 3.75v2.5m7.25-2.5v2.5m7.25-2.5v2.5M54.75 63v2.5M62 63v2.5m7.25-2.5v2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 709 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<path d="M30 57c0 5.523 4.477 10 10 10h29.5c5.523 0 10-4.477 10-10v-6.5A2.5 2.5 0 0 0 77 48H32.5a2.5 2.5 0 0 0-2.5 2.5zm14 22a1 1 0 0 0 1 1h19.5a1 1 0 0 0 1-1V67H44zm6-36.5a1 1 0 0 1 1-1h7.5a1 1 0 0 1 1 1V48H50zM45 37v-2a5 5 0 1 1 10 0v6.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 415 B

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<path d="M59.5 29h24a5 5 0 0 1 5 5v32a5 5 0 0 1-5 5h-24a5 5 0 0 1-5-5V34a5 5 0 0 1 5-5m-34 0h24a5 5 0 0 1 5 5v32a5 5 0 0 1-5 5h-24a5 5 0 0 1-5-5V34a5 5 0 0 1 5-5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.5 44a5 5 0 0 1 5 5v12H86V49a5 5 0 0 1 5-5h.5a5 5 0 0 1 5 5v21.5a5 5 0 0 1-5 5H18a5 5 0 0 1-5-5V49a5 5 0 0 1 5-5z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M25.25 57h24.5a4.75 4.75 0 0 1 4.75 4.75 4.75 4.75 0 0 1-4.75 4.75h-24.5a4.75 4.75 0 0 1-4.75-4.75A4.75 4.75 0 0 1 25.25 57m34 0h24.5a4.75 4.75 0 0 1 4.75 4.75 4.75 4.75 0 0 1-4.75 4.75h-24.5a4.75 4.75 0 0 1-4.75-4.75A4.75 4.75 0 0 1 59.25 57M18.5 75.5h8l-1.674 5.441A1.5 1.5 0 0 1 23.392 82H20a1.5 1.5 0 0 1-1.5-1.5zm72 0h-8l1.674 5.441A1.5 1.5 0 0 0 85.608 82H89a1.5 1.5 0 0 0 1.5-1.5z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M73.47 46.365v-17.62m-10.571 25.55V36.674M52.326 61.783V45.484M42.193 69.273v-15.86M28.979 77.761a1 1 0 0 1 1-1h53.624v6.489a1 1 0 0 1-1 1H29.98a1 1 0 0 1-1-1z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M26.777 57.497a1 1 0 0 1 1-1h2.846a1 1 0 0 1 1 1V83.25a1 1 0 0 1-1 1h-2.846a1 1 0 0 1-1-1z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="m73.113 28.633-41.492 32.93v-4.405l39.9-32.007a.7.7 0 0 1 .428-.151h3.724a1.762 1.762 0 1 1 0 3.524h-2.248a.5.5 0 0 0-.312.109M25.895 53.414a3.084 3.084 0 1 0 6.167 0 3.084 3.084 0 1 0-6.167 0m14.978 16.859a1 1 0 0 1 1-1h41.73v7.488h-42.73zm10.131-7.489a1 1 0 0 1 1-1h31.598v7.488H51.004z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M61.576 55.294a1 1 0 0 1 1-1h21.026v7.49H61.576z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M72.15 47.806a1 1 0 0 1 1-1h9.454a1 1 0 0 1 1 1v6.489H72.15z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<path d="M32 26.5a2.5 2.5 0 0 1 2.5-2.5H45a2.5 2.5 0 0 1 2.5 2.5V57H32zM47.5 52h29a2.5 2.5 0 0 1 2.5 2.5V57H47.5zm17.037 20.102C73.95 70.288 79 62.01 79 57H32c.5 7.5 8.072 7 7 11l-2.342 16.862a1 1 0 0 0 .99 1.138H66.72a1 1 0 0 0 .97-1.243z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 415 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M23.5 31H86a2.5 2.5 0 0 1 2.5 2.5v34A2.5 2.5 0 0 1 86 70H23.5a2.5 2.5 0 0 1-2.5-2.5v-34a2.5 2.5 0 0 1 2.5-2.5M50 70h9.5v8H50Zm-10 8h30M42.5 45.5h5m5 0h-5m0 0v9.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="m56.75 45.5 5 10 5-10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 500 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<path d="M28 23a2.5 2.5 0 0 1 2.5-2.5h24V85h-24a2.5 2.5 0 0 1-2.5-2.5zm2 65.5a1 1 0 0 0 1 1h47a1 1 0 0 0 1-1V85H30zM81 23a2.5 2.5 0 0 0-2.5-2.5h-24V85h24a2.5 2.5 0 0 0 2.5-2.5zM50.5 48v9.5m8-9.5v9.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 374 B

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M31.5 25h47a2.5 2.5 0 0 1 2.5 2.5v55a2.5 2.5 0 0 1-2.5 2.5h-47a2.5 2.5 0 0 1-2.5-2.5v-55a2.5 2.5 0 0 1 2.5-2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.5 37.5h47A2.5 2.5 0 0 1 81 40v42.5a2.5 2.5 0 0 1-2.5 2.5h-47a2.5 2.5 0 0 1-2.5-2.5V40a2.5 2.5 0 0 1 2.5-2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M38 60.5a17 17 0 1 0 34 0 17 17 0 1 0-34 0" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M43.5 60.5a11.5 11.5 0 1 0 23 0 11.5 11.5 0 1 0-23 0m22-29.5a2 2 0 1 0 4 0 2 2 0 1 0-4 0m7.5 0a2 2 0 1 0 4 0 2 2 0 1 0-4 0m-37.25 0h9.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 949 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M88.5 47h-20v14h20zm-12 4h4m7.984-8h-66v4h66zm-66 4h4v26.5a1.5 1.5 0 0 1-1.5 1.5h-1a1.5 1.5 0 0 1-1.5-1.5z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M24.866 35.597A2.5 2.5 0 0 1 27.197 34h56.577a2.5 2.5 0 0 1 2.33 1.594l2.35 6.044a1 1 0 0 1-.93 1.362H23.46a1 1 0 0 1-.933-1.361zM88.5 61h-20v12.5A1.5 1.5 0 0 0 70 75h17a1.5 1.5 0 0 0 1.5-1.5zm-12 4h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 623 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 90">
<path d="M75.702 13.011q.012-.225.012-.453c0-4.843-3.917-8.768-8.748-8.768H23.034c-4.83 0-8.747 3.925-8.747 8.768q0 .228.011.453M77.132 72.09q.59.091 1.206.092c4.348 0 7.873-3.533 7.873-7.892V20.45c0-4.359-3.525-7.892-7.873-7.892-.924 0-1.812.16-2.636.453a7.89 7.89 0 0 0-5.237 7.438v.148m0 0a8.7 8.7 0 0 1-3.499.73H23.034a8.7 8.7 0 0 1-3.499-.73m50.93 0V64.29a7.89 7.89 0 0 0 6.667 7.8M14.298 13.01a7.89 7.89 0 0 1 5.237 7.438v.148m0 0V64.29a7.89 7.89 0 0 1-6.666 7.8q-.59.091-1.206.092c-4.348 0-7.873-3.533-7.873-7.892V20.45c0-4.359 3.525-7.892 7.873-7.892.924 0 1.811.16 2.635.453M77.132 72.09c-1.585 8.05-8.667 14.12-17.164 14.12H30.032c-8.496 0-15.578-6.07-17.163-14.12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 848 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 65 65">
<path d="M56.968 18.845q0-.368-.05-.72M49.051 13.8H15.95a5.04 5.04 0 0 1-5.038-5.043 5.04 5.04 0 0 1 5.038-5.043h33.103a5.04 5.04 0 0 1 5.038 5.043 5.04 5.04 0 0 1-5.038 5.043m-33.103 0h-2.88a5.04 5.04 0 0 0-4.986 4.323M49.052 13.8h2.879a5.04 5.04 0 0 1 4.986 4.324m.051 24.826a4.32 4.32 0 0 0 4.318-4.323v-16.18a4.32 4.32 0 0 0-4.369-4.322m.051 24.826a4.32 4.32 0 0 1-4.318-4.323V22.447a4.32 4.32 0 0 1 4.267-4.322m-11.84 37.24h4.695c.736 0 1.446-.111 2.115-.317a7.21 7.21 0 0 0 5.081-6.889v-5.208M8.084 18.124h-.051a4.32 4.32 0 0 0-4.318 4.323v16.18a4.32 4.32 0 0 0 4.318 4.324 4.32 4.32 0 0 0 4.317-4.323V22.447a4.32 4.32 0 0 0-4.266-4.323m11.935 37.24h-4.79a7.2 7.2 0 0 1-2.04-.294 7.21 7.21 0 0 1-5.156-6.91v-5.21m37.045 12.414 5.177 5.183c.969.97 2.534.987 3.524.038a2.526 2.526 0 0 0 .038-3.605l-1.93-1.932m-6.81.316H20.02m-6.83-.294-1.909 1.91c-.998 1-.98 2.627.038 3.605.99.949 2.555.932 3.524-.038l5.177-5.183" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 170 75">
<g>
<path d="M3.885 6c0-1.243 1.068-2.25 2.386-2.25h157.458c1.318 0 2.386 1.007 2.386 2.25v63c0 1.243-1.068 2.25-2.386 2.25H6.27c-1.318 0-2.386-1.007-2.386-2.25z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.428 37.5c0-13.669 11.75-24.75 26.243-24.75h90.658c14.493 0 26.243 11.081 26.243 24.75s-11.75 24.75-26.243 24.75H39.67c-14.493 0-26.243-11.081-26.243-24.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.56 37.5c0 2.237-1.923 4.05-4.295 4.05s-4.294-1.813-4.294-4.05 1.923-4.05 4.294-4.05c2.372 0 4.295 1.813 4.295 4.05" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 857 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 140">
<g>
<path d="M3.862 6.224a2.36 2.36 0 0 1 2.362-2.362H133.97c1.197 0 2.168.97 2.168 2.168 0 71.857-58.251 130.108-130.108 130.108a2.17 2.17 0 0 1-2.168-2.168z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.31 32.207c0-10.437 8.46-18.897 18.897-18.897h87.628a2.13 2.13 0 0 1 2.13 2.13c0 58.833-47.692 106.526-106.524 106.526a2.13 2.13 0 0 1-2.13-2.131z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.207 27.483a4.724 4.724 0 1 1-9.449 0 4.724 4.724 0 0 1 9.449 0" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 794 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 200">
<path d="M3.879 14.073c0 5.618 4.516 10.172 10.088 10.172h132.065c5.571 0 10.088-4.554 10.088-10.172m-81.165 47.16V46.438c0-5.107-4.106-9.247-9.171-9.247H45.607c-5.065 0-9.17 4.14-9.17 9.247v14.795m89.877 0V46.438c0-5.107-4.107-9.247-9.172-9.247H96.966c-5.065 0-9.171 4.14-9.171 9.247v14.795m-83.916 0H156.12m0 22.192H3.88m9.17 112.673h133.9c5.065 0 9.171-4.14 9.171-9.247V13.15c0-5.107-4.106-9.247-9.171-9.247H13.05c-5.064 0-9.17 4.14-9.17 9.247v173.7c0 5.107 4.106 9.247 9.17 9.247" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 659 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 200">
<path d="M3.79 14.024c0 5.59 4.318 10.122 9.645 10.122h63.13c5.328 0 9.646-4.532 9.646-10.122m-22.797 46.93V46.23c0-5.082-3.926-9.201-8.769-9.201h-19.29c-4.842 0-8.768 4.12-8.768 9.201v14.723m-22.797 0h82.42m0 22.085H3.79m8.768 113.06h64.885c4.842 0 8.768-4.12 8.768-9.202V13.104c0-5.082-3.926-9.202-8.768-9.202H12.558c-4.843 0-8.768 4.12-8.768 9.202v173.792c0 5.082 3.925 9.202 8.768 9.202" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 565 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 60">
<path d="M22.466 3.692v10.085c0 1.21-.92 2.192-2.055 2.192h-.822c-1.136 0-2.056-.981-2.056-2.192V3.692m4.933 8.77h2.878c1.135 0 2.056-.982 2.056-2.193v-.877c0-1.21-.92-2.192-2.056-2.192h-2.878m-4.933 5.262h-2.878c-1.135 0-2.055-.982-2.055-2.193v-.877c0-1.21.92-2.192 2.055-2.192h2.878M5.611 3.692h28.777c1.136 0 2.056.981 2.056 2.192v33.45c0 9.374-7.125 16.973-15.914 16.973h-1.06c-8.79 0-15.915-7.599-15.915-16.972V5.885c0-1.212.92-2.193 2.056-2.193m4.11 15.785h20.556c1.136 0 2.056.981 2.056 2.192v17.807c0 6.874-5.225 12.447-11.67 12.447h-1.327c-6.445 0-11.67-5.573-11.67-12.447V21.67c0-1.21.92-2.192 2.056-2.192m13.567 7.892c0 1.937-1.472 3.508-3.288 3.508-1.817 0-3.29-1.57-3.29-3.508s1.473-3.507 3.29-3.507 3.288 1.57 3.288 3.507" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 909 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 50">
<path d="M17.318 12.182h-8.06c-.88 0-1.592.74-1.592 1.654v16.126c0 6.698 5.225 12.129 11.67 12.129h1.327c6.445 0 11.67-5.43 11.67-12.13V13.837c0-.914-.713-1.654-1.591-1.654H22.68m-.215-8.545v9.827c0 1.18-.92 2.136-2.055 2.136h-.822c-1.136 0-2.056-.956-2.056-2.136V3.637m10.689 8.545V3.637m-16.445 0v8.545M5.611 3.637h28.777c1.136 0 2.056.956 2.056 2.136v24.051c0 9.135-7.125 16.54-15.914 16.54h-1.06c-8.79 0-15.915-7.405-15.915-16.54V5.773c0-1.18.92-2.136 2.056-2.136M23.289 23.29c0 1.888-1.473 3.418-3.29 3.418s-3.288-1.53-3.288-3.418 1.472-3.418 3.289-3.418c1.816 0 3.288 1.53 3.288 3.418" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 764 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 35">
<path d="M57.12 20.488H45.473c-.514 0-1.008.124-1.372.345s-.568.52-.568.832m4.076-8.236h5.434M6.119 3.5h87.762c1.276 0 2.31.895 2.31 2v24c0 1.105-1.034 2-2.31 2H6.12c-1.275 0-2.31-.895-2.31-2v-24c0-1.105 1.035-2 2.31-2m51 6.4v14.118H45.474a2.04 2.04 0 0 1-1.372-.517 1.7 1.7 0 0 1-.568-1.248V11.665c0-.468.204-.917.568-1.248a2.04 2.04 0 0 1 1.372-.517z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 527 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 35">
<path d="m39.722 16.231 1.505-1.596m1.134 3.15 1.53-1.738M6.03 31.5h67.94c1.251 0 2.265-.895 2.265-2v-24c0-1.105-1.014-2-2.264-2H6.03c-1.251 0-2.265.895-2.265 2v24c0 1.105 1.014 2 2.265 2m41.966-14.305L46.92 17.1a3.4 3.4 0 0 1-1.813-.706l-4.583-3.62c-1.264-1-3.277-.326-3.475 1.161-.185 1.39-1.983 2.1-3.28 1.299l-1.532-.947c-.968-.513-2.2.103-2.2 1.1v3.463c0 1.559 1.43 2.823 3.196 2.823h14.51c1.404 0 2.542-1.005 2.542-2.245 0-1.153-.99-2.118-2.289-2.233" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 630 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 50">
<g>
<path d="M3.765 5.773c0-1.18 1.014-2.136 2.265-2.136h67.94c1.251 0 2.265.956 2.265 2.136v38.454c0 1.18-1.014 2.137-2.264 2.137H6.03c-1.251 0-2.265-.957-2.265-2.137z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M36.088 32.135c2.525 1.88 6.205 1.88 8.73 0s3.167-5.101 1.52-7.637l-5.879-8.043-5.893 8.043c-1.644 2.536-1.003 5.755 1.522 7.637" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 598 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 45">
<path d="M56.772 17.88h5.315m0 0h5.315m-5.315 0v9.214m9.832-9.214 5.315 9.45 5.314-9.45M6.224 41.4h127.552c1.305 0 2.362-.94 2.362-2.1V5.7c0-1.16-1.057-2.1-2.362-2.1H6.224c-1.305 0-2.362.94-2.362 2.1v33.6c0 1.16 1.057 2.1 2.362 2.1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 406 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50">
<path d="M39.452 12.433H10.548a4.398 4.398 0 1 1 0-8.796h28.904a4.398 4.398 0 0 1 0 8.796m-28.904 0H8.035a4.4 4.4 0 0 0-4.398 4.399V40.08a6.283 6.283 0 0 0 6.283 6.284h30.16a6.283 6.283 0 0 0 6.284-6.284V16.832c0-2.43-1.97-4.399-4.399-4.399h-2.513" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 421 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 48">
<path d="M38.579 17.168c1.326 2.526 1.947 6.015 1.947 9.071 0 10.018-8.07 18.139-18.025 18.139S4.475 36.257 4.475 26.239c0-3.056.586-6.552 1.912-9.078m32.192.007a3.59 3.59 0 0 1-3.333-.976m3.333.976a3.6 3.6 0 0 0 1.765-.976m-33.827.996-.13-.027a3.6 3.6 0 0 1-1.731-.969m30.59 0a18 18 0 0 0-5.848-3.932 17.93 17.93 0 0 0-13.796 0 18 18 0 0 0-5.848 3.932 3.59 3.59 0 0 1-3.237.996m28.729-.996a3.59 3.59 0 0 0 5.098 0m-33.827.996a3.6 3.6 0 0 1-1.861-.996 3.644 3.644 0 0 1 0-5.13 25.2 25.2 0 0 1 8.187-5.505A25.1 25.1 0 0 1 22.5 3.623a25.1 25.1 0 0 1 9.657 1.934 25.2 25.2 0 0 1 8.187 5.505 3.644 3.644 0 0 1 0 5.13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 786 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 60">
<g>
<path d="M3.84 5.884c0-1.21 1.048-2.192 2.34-2.192h107.64c1.293 0 2.34.981 2.34 2.192v48.23c0 1.212-1.047 2.193-2.34 2.193H6.18c-1.292 0-2.34-.981-2.34-2.192z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M103.056 15.093V9.83a.936.877 0 0 1 .936-.877h5.616a.936.877 0 0 1 .936.877v5.262a.936.877 0 0 1-.936.877h-5.616a.936.877 0 0 1-.936-.877m0 35.078v-5.262a.936.877 0 0 1 .936-.877h5.616a.936.877 0 0 1 .936.877v5.262a.936.877 0 0 1-.936.877h-5.616a.936.877 0 0 1-.936-.877m-93.6-35.077V9.83a.936.877 0 0 1 .936-.877h5.616a.936.877 0 0 1 .936.877v5.262a.936.877 0 0 1-.936.877h-5.616a.936.877 0 0 1-.936-.877m0 35.078v-5.262a.936.877 0 0 1 .936-.877h5.616a.936.877 0 0 1 .936.877v5.262a.936.877 0 0 1-.936.877h-5.616a.936.877 0 0 1-.936-.877" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1003 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 60">
<g>
<path d="M3.84 30c0-14.53 12.572-26.308 28.08-26.308h56.16c15.509 0 28.08 11.778 28.08 26.308s-12.571 26.307-28.08 26.307H31.92C16.412 56.307 3.84 44.53 3.84 30" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M80.592 15.093V9.83a.936.877 0 0 1 .936-.877h5.616a.936.877 0 0 1 .936.877v5.262a.936.877 0 0 1-.936.877h-5.616a.936.877 0 0 1-.936-.877m0 35.077v-5.262a.936.877 0 0 1 .936-.877h5.616a.936.877 0 0 1 .936.877v5.262a.936.877 0 0 1-.936.877h-5.616a.936.877 0 0 1-.936-.877M31.92 15.093V9.83a.936.877 0 0 1 .936-.877h5.616a.936.877 0 0 1 .936.877v5.262a.936.877 0 0 1-.936.876h-5.616a.936.877 0 0 1-.936-.876m0 35.077v-5.262a.936.877 0 0 1 .936-.877h5.616a.936.877 0 0 1 .936.877v5.262a.936.877 0 0 1-.936.876h-5.616a.936.877 0 0 1-.936-.876" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1004 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80">
<g>
<path d="M3.765 40a36.235 36.235 0 1 0 72.47 0 36.235 36.235 0 1 0-72.47 0" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="m56.26 26.96-3.843-3.843a.906.906 0 0 1 0-1.28l3.843-3.844a.906.906 0 0 1 1.281 0l3.844 3.843a.906.906 0 0 1 0 1.281l-3.844 3.844a.906.906 0 0 1-1.28 0m-.001 34.66-3.843-3.843a.906.906 0 0 1 0-1.281l3.843-3.844a.906.906 0 0 1 1.281 0l3.843 3.844a.906.906 0 0 1 0 1.28l-3.843 3.844a.906.906 0 0 1-1.281 0m-34.423-34.66-3.843-3.843a.906.906 0 0 1 0-1.281l3.843-3.844a.906.906 0 0 1 1.281 0l3.844 3.844a.906.906 0 0 1 0 1.28l-3.844 3.844a.906.906 0 0 1-1.28 0m-.001 34.66-3.843-3.843a.906.906 0 0 1 0-1.281l3.843-3.843a.906.906 0 0 1 1.281 0l3.844 3.843a.906.906 0 0 1 0 1.281l-3.844 3.843a.906.906 0 0 1-1.28 0" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 988 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 60">
<g>
<path d="M3.84 12.461c0-4.843 4.19-8.77 9.36-8.77h93.6c5.17 0 9.36 3.927 9.36 8.77v35.077c0 4.843-4.19 8.77-9.36 8.77H13.2c-5.17 0-9.36-3.927-9.36-8.77z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M103.056 15.093V9.83a.936.877 0 0 1 .936-.877h5.616a.936.877 0 0 1 .936.877v5.262a.936.877 0 0 1-.936.877h-5.616a.936.877 0 0 1-.936-.877m0 35.078v-5.262a.936.877 0 0 1 .936-.877h5.616a.936.877 0 0 1 .936.877v5.262a.936.877 0 0 1-.936.877h-5.616a.936.877 0 0 1-.936-.877m-93.6-35.077V9.83a.936.877 0 0 1 .936-.877h5.616a.936.877 0 0 1 .936.877v5.262a.936.877 0 0 1-.936.877h-5.616a.936.877 0 0 1-.936-.877m0 35.078v-5.262a.936.877 0 0 1 .936-.877h5.616a.936.877 0 0 1 .936.877v5.262a.936.877 0 0 1-.936.877h-5.616a.936.877 0 0 1-.936-.877" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 997 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 50">
<path d="M15 30.982v-1.71m3.086 5.128h1.542M15 39.527v-1.709M10.372 34.4h1.542M5.84 46.364h18.322c1.331 0 2.41-1.196 2.41-2.67V6.306c0-1.475-1.079-2.67-2.41-2.67H5.839c-1.331 0-2.41 1.195-2.41 2.67v37.386c0 1.475 1.079 2.67 2.41 2.67m14.56-29.91c0 3.304-2.417 5.982-5.4 5.982-2.981 0-5.4-2.678-5.4-5.981s2.419-5.982 5.4-5.982c2.983 0 5.4 2.678 5.4 5.982M18.858 34.4c0 2.36-1.727 4.273-3.857 4.273s-3.857-1.913-3.857-4.273S12.87 30.127 15 30.127s3.857 1.913 3.857 4.273" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 642 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 70">
<g>
<path d="M3.862 5.967c0-1.234 1.057-2.234 2.362-2.234h127.552c1.305 0 2.362 1 2.362 2.234v58.066c0 1.234-1.057 2.233-2.362 2.233H6.224c-1.305 0-2.362-1-2.362-2.233z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.531 15.346v-5.36a.945.893 0 0 1 .945-.893h5.67a.945.893 0 0 1 .944.893v5.36a.945.893 0 0 1-.945.894h-5.669a.945.893 0 0 1-.945-.894m113.379.001v-5.36a.945.893 0 0 1 .945-.894h5.67a.945.893 0 0 1 .944.894v5.36a.945.893 0 0 1-.945.893h-5.669a.945.893 0 0 1-.945-.893M9.53 60.013v-5.36a.945.893 0 0 1 .946-.893h5.669a.945.893 0 0 1 .945.893v5.36a.945.893 0 0 1-.945.894h-5.67a.945.893 0 0 1-.944-.894m113.38.001v-5.36a.945.893 0 0 1 .945-.894h5.669a.945.893 0 0 1 .945.894v5.36a.945.893 0 0 1-.945.893h-5.67a.945.893 0 0 1-.944-.893m-97.318-2.681h88.814" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160">
<g>
<path d="M156.12 66.202a2.38 2.38 0 0 1-2.379 2.38H94.272c-13.138 0-23.788 10.65-23.788 23.787v61.372a2.38 2.38 0 0 1-2.379 2.38H6.257a2.38 2.38 0 0 1-2.378-2.38V6.257A2.38 2.38 0 0 1 6.257 3.88h147.484a2.38 2.38 0 0 1 2.38 2.378z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.588 16.249v-5.71a.95.95 0 0 1 .951-.95h5.71a.95.95 0 0 1 .95.95v5.71a.95.95 0 0 1-.95.951h-5.71a.95.95 0 0 1-.951-.951m133.212.001v-5.71a.95.95 0 0 1 .951-.951h5.71a.95.95 0 0 1 .95.951v5.71a.95.95 0 0 1-.95.951h-5.71a.95.95 0 0 1-.951-.951m0 45.672v-5.71a.95.95 0 0 1 .952-.951h5.709a.95.95 0 0 1 .951.951v5.71a.95.95 0 0 1-.951.951h-5.71a.95.95 0 0 1-.95-.951M9.587 149.46v-5.708a.95.95 0 0 1 .952-.952h5.709a.95.95 0 0 1 .951.952v5.709a.95.95 0 0 1-.951.951h-5.71a.95.95 0 0 1-.951-.951m47.576 0v-5.709a.95.95 0 0 1 .952-.951h5.709a.95.95 0 0 1 .951.951v5.71a.95.95 0 0 1-.951.95h-5.71a.95.95 0 0 1-.951-.95m51.382-90.396H95.569a34.6 34.6 0 0 0-34.6 34.6v12.976" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60">
<path d="M3.692 5.884c0-1.21.981-2.192 2.192-2.192h48.23c1.212 0 2.193.981 2.193 2.192v48.23a2.19 2.19 0 0 1-2.192 2.193H5.885a2.19 2.19 0 0 1-2.193-2.192z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 329 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 90">
<path d="M45.944 41.767a2.29 2.29 0 0 0 2.29 2.29H83.92a2.29 2.29 0 0 1 2.29 2.29V83.92a2.29 2.29 0 0 1-2.29 2.29H6.08a2.29 2.29 0 0 1-2.29-2.29V6.08a2.29 2.29 0 0 1 2.29-2.29h37.576a2.29 2.29 0 0 1 2.29 2.29z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 383 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 50">
<path d="M38.188 8.7h10.598c1.211 0 2.193.956 2.193 2.136v28.2c0 1.18-.982 2.136-2.193 2.136H11.08c-1.21 0-2.192-.956-2.192-2.136v-28.2c0-1.18.981-2.136 2.192-2.136h10.599m10.885-5.128V15.11c0 1.18-.981 2.136-2.192 2.136h-.877c-1.21 0-2.192-.957-2.192-2.136V3.572m5.261 7.691h3.07c1.21 0 2.192-.956 2.192-2.136v-.855c0-1.18-.982-2.136-2.192-2.136h-3.07m-5.261 5.127h-3.07c-1.21 0-2.192-.956-2.192-2.136v-.855c0-1.18.982-2.136 2.193-2.136h3.069M5.817 46.299h48.231c1.21 0 2.192-.956 2.192-2.136V5.709c0-1.18-.981-2.137-2.192-2.137H5.818c-1.211 0-2.193.957-2.193 2.137v38.454c0 1.18.982 2.136 2.192 2.136M33.44 25.79c0 1.888-1.57 3.418-3.507 3.418s-3.508-1.53-3.508-3.418c0-1.887 1.57-3.418 3.508-3.418s3.507 1.53 3.507 3.418" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 897 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 50">
<path d="M53.173 12.182c-.468 0-.847.354-.847.79s.38.79.847.79zm21.979 1.58c.467 0 .846-.353.846-.79 0-.436-.379-.79-.846-.79zM6.01 3.572v.79h77.842v-1.58H6.01zM86.14 5.71h-.847v38.454h1.694V5.709zM83.85 46.3v-.79H6.01v1.58h77.842zM3.72 44.163h.847V5.709H2.873v38.454zm2.29 2.136v-.79c-.797 0-1.443-.603-1.443-1.346H2.873c0 1.616 1.404 2.926 3.137 2.926zm80.131-2.136h-.847c0 .743-.646 1.346-1.442 1.346v1.58c1.732 0 3.136-1.31 3.136-2.926zm-2.29-40.59v.79c.797 0 1.443.602 1.443 1.346h1.694c0-1.617-1.404-2.927-3.136-2.927zm-77.841 0v-.79c-1.733 0-3.137 1.31-3.137 2.926h1.694c0-.744.646-1.347 1.443-1.347zm5.494 37.599v.79h28.39v-1.58h-28.39zm30.68-2.136h.846v-28.2h-1.694v28.2zm-32.97-28.2h-.846v28.2h1.694v-28.2zM39.895 8.7v-.79h-5.669v1.58h5.669zm-22.721 0v-.79h-5.669v1.58h5.669zm-7.958 2.136h.847c0-.744.645-1.346 1.442-1.346V7.91c-1.732 0-3.136 1.31-3.136 2.926zm32.968 0h.847c0-1.616-1.404-2.927-3.136-2.927v1.58c.797 0 1.442.603 1.442 1.347zm-2.29 30.336v.79c1.733 0 3.137-1.31 3.137-2.926h-1.694c0 .743-.645 1.346-1.442 1.346zm-28.389 0v-.79c-.797 0-1.442-.603-1.442-1.346H8.368c0 1.616 1.404 2.926 3.136 2.926zm13.737-23.927v.79h.916v-1.58h-.916zm3.205-2.136h.847V3.572H27.6V15.11zM22.952 3.572h-.847V15.11h1.693V3.572zm3.205 13.673v.79c1.732 0 3.136-1.31 3.136-2.926H27.6c0 .743-.646 1.346-1.443 1.346zm-.916 0v-.79c-.797 0-1.443-.603-1.443-1.346h-1.693c0 1.616 1.404 2.926 3.136 2.926zm3.205-5.982v.79h3.206v-1.58h-3.206zm5.495-2.136h.847v-.855h-1.694v.855zm-2.29-2.991v-.79h-3.205v1.58h3.206zm2.29 2.136h.847c0-1.616-1.404-2.926-3.136-2.926v1.58c.796 0 1.442.603 1.442 1.346zm-2.29 2.991v.79c1.733 0 3.137-1.31 3.137-2.926h-1.694c0 .743-.646 1.346-1.442 1.346zm-11.905 0v.79h3.206v-1.58h-3.206zm3.206-5.127v-.79h-3.206v1.58h3.206zm-5.495 2.136h-.847v.855h1.694v-.855zm2.29-2.136v-.79c-1.733 0-3.137 1.31-3.137 2.926h1.694c0-.743.646-1.346 1.442-1.346zm0 5.127v-.79c-.797 0-1.443-.603-1.443-1.346H16.61c0 1.616 1.404 2.926 3.136 2.926zm9.615 14.527h-.847c0 1.452-1.26 2.628-2.816 2.628v1.58c2.49 0 4.51-1.884 4.51-4.208zM25.7 29.208v-.79c-1.555 0-2.816-1.176-2.816-2.628H21.19c0 2.324 2.02 4.209 4.51 4.209zm-3.663-3.418h.847c0-1.451 1.26-2.628 2.816-2.628v-1.58c-2.49 0-4.51 1.884-4.51 4.208zm3.663-3.418v.79c1.555 0 2.816 1.177 2.816 2.628h1.694c0-2.324-2.02-4.208-4.51-4.208zm24.268 18.8v.79h28.39v-1.58h-28.39zm30.68-2.136h.846v-28.2H79.8v28.2zM78.356 8.7v-.79h-28.39v1.58h28.39zm-30.68 2.136h-.846v28.2h1.694v-28.2zm2.29-2.136v-.79c-1.732 0-3.136 1.31-3.136 2.926h1.694c0-.744.646-1.346 1.442-1.346zm30.68 2.136h.846c0-1.616-1.404-2.927-3.136-2.927v1.58c.797 0 1.443.603 1.443 1.347zm-2.29 30.336v.79c1.732 0 3.136-1.31 3.136-2.926H79.8c0 .743-.646 1.346-1.443 1.346zm-28.39 0v-.79c-.796 0-1.442-.603-1.442-1.346H46.83c0 1.616 1.404 2.926 3.136 2.926zm3.206-28.2v.79h21.979v-1.58h-21.98z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 35">
<path d="m4.444 31.033 50.75-27.007m-50.752-.06 50.75 27.008M5.884 31.5h48.23c1.212 0 2.193-.895 2.193-2v-24c0-1.105-.981-2-2.192-2H5.885c-1.212 0-2.193.895-2.193 2v24c0 1.105.981 2 2.192 2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 363 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60">
<path d="M3.772 29.59 29.88 4.383M4.57 55.43 55.43 30M4.175 4.383l26.701 23.863m-27.184.877L55.43 55.43M28.41 3.692H5.884c-1.21 0-2.192.981-2.192 2.192v48.23c0 1.212.981 2.193 2.192 2.193h48.23a2.19 2.19 0 0 0 2.193-2.192V31.589c0-1.21-.981-2.192-2.192-2.192h-21.32a2.19 2.19 0 0 1-2.193-2.192V5.885a2.19 2.19 0 0 0-2.192-2.193" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 501 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 40">
<path d="M5.773 3.555h38.454a2.136 2.056 0 0 1 2.137 2.056v28.777a2.136 2.056 0 0 1-2.137 2.056H5.773a2.136 2.056 0 0 1-2.136-2.056V5.611a2.136 2.056 0 0 1 2.136-2.056" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 341 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 35">
<g>
<path d="M6.12 31.5h87.76a2.31 2 0 0 0 2.31-2v-24a2.31 2 0 0 0-2.31-2H6.12a2.31 2 0 0 0-2.31 2v24a2.31 2 0 0 0 2.31 2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.089 25.933c-.494.112-.776.546-.63.97s.664.675 1.157.564l-.263-.767zM90.912 9.067c.493-.112.775-.546.63-.97s-.664-.675-1.158-.564l.264.767zM11.875 26.956c.493-.112.775-.546.63-.97s-.664-.676-1.158-.564l.264.767zm3.988-2.556c-.493.112-.775.546-.63.969s.664.676 1.158.564l-.264-.766zm5.045.511c.493-.112.775-.545.63-.969s-.664-.676-1.158-.564l.264.766zm3.988-2.555c-.493.111-.775.545-.63.968.146.424.664.677 1.158.565l-.264-.767zm5.044.51c.494-.111.776-.545.63-.968-.145-.424-.664-.676-1.157-.565l.263.767zm3.99-2.555c-.495.112-.777.546-.63.97.145.423.663.675 1.157.563l-.264-.766zm5.043.511c.494-.112.776-.545.63-.969s-.664-.676-1.158-.564l.264.767zm3.989-2.555c-.494.111-.776.545-.63.969s.664.676 1.157.564l-.263-.767zm5.044.51c.494-.11.776-.545.63-.968-.146-.424-.664-.676-1.158-.565l.264.767zm3.989-2.555c-.494.112-.776.546-.63.97.145.423.664.675 1.157.564l-.264-.767zm5.044.511c.493-.111.775-.545.63-.969s-.664-.676-1.158-.564l.264.767zm3.988-2.555c-.493.112-.775.545-.63.969s.664.676 1.158.564l-.264-.767zm5.045.51c.493-.11.775-.545.63-.968-.146-.424-.664-.676-1.158-.564l.264.766zm3.988-2.555c-.493.112-.775.546-.63.97s.664.675 1.158.564l-.264-.767zm5.044.511c.494-.111.776-.545.63-.968-.145-.424-.664-.677-1.157-.565l.264.767zm3.99-2.555c-.494.112-.777.545-.63.969.145.423.663.676 1.157.564l-.264-.766zm5.043.511c.494-.112.776-.546.63-.969s-.664-.676-1.157-.564l.263.766zm3.989-2.556c-.494.112-.776.546-.63.97s.664.676 1.158.564l-.264-.767zM9.353 26.7l.263.767 2.259-.511-.264-.767-.264-.767-2.258.511zm6.774-1.533.264.766 4.517-1.022-.264-.767-.264-.766-4.517 1.022zm9.033-2.045.264.767 4.516-1.022-.264-.767-.263-.767-4.517 1.023zm9.033-2.044.264.766 4.516-1.022-.264-.766-.264-.767-4.516 1.022zm9.033-2.045.263.767 4.517-1.022-.264-.767-.264-.767-4.516 1.023zm9.032-2.044.264.767 4.517-1.023-.264-.766-.264-.767-4.516 1.022zm9.033-2.045.264.767 4.517-1.022-.264-.767-.264-.766-4.517 1.022zm9.033-2.044.264.767 4.516-1.023-.263-.766-.264-.767-4.517 1.022zm9.033-2.044.264.766 4.516-1.022-.264-.767-.263-.766-4.517 1.022zM88.39 8.81l.264.767 2.258-.511-.264-.767-.264-.767-2.258.511z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.089 9.067c-.494-.112-.776-.546-.63-.97s.664-.675 1.157-.564l-.263.767zm81.823 16.866c.493.112.775.546.63.97s-.664.675-1.158.564l.264-.767zM11.875 8.044c.493.112.775.546.63.97s-.664.676-1.158.564l.264-.767zm3.988 2.556c-.493-.112-.775-.546-.63-.969s.664-.676 1.158-.564l-.264.766zm5.045-.511c.493.112.775.545.63.969s-.664.676-1.158.564l.264-.766zm3.988 2.555c-.493-.111-.775-.545-.63-.968.146-.424.664-.677 1.158-.565l-.264.767zm5.044-.51c.494.111.776.545.63.968-.145.424-.664.676-1.157.565l.263-.767zm3.99 2.555c-.495-.112-.777-.546-.63-.97.145-.423.663-.675 1.157-.563l-.264.766zm5.043-.511c.494.112.776.545.63.969s-.664.676-1.158.564l.264-.767zm3.989 2.555c-.494-.111-.776-.545-.63-.969s.664-.676 1.157-.564l-.263.767zm5.044-.51c.494.11.776.545.63.968-.146.424-.664.676-1.158.565l.264-.767zm3.989 2.555c-.494-.112-.776-.546-.63-.97.145-.423.664-.675 1.157-.564l-.264.767zm5.044-.511c.493.111.775.545.63.969s-.664.676-1.158.564l.264-.767zm3.988 2.555c-.493-.112-.775-.545-.63-.969s.664-.676 1.158-.564l-.264.767zm5.045-.51c.493.11.775.545.63.968-.146.424-.664.676-1.158.564l.264-.766zm3.988 2.555c-.493-.112-.775-.546-.63-.97s.664-.675 1.158-.564l-.264.767zm5.044-.511c.494.111.776.545.63.968-.145.424-.664.677-1.157.565l.264-.767zm3.99 2.555c-.494-.112-.777-.545-.63-.969.145-.423.663-.676 1.157-.564l-.264.766zm5.043-.511c.494.112.776.546.63.969s-.664.676-1.157.564l.263-.766zm3.989 2.556c-.494-.112-.776-.546-.63-.97s.664-.676 1.158-.564l-.264.767zM9.353 8.3l.263-.767 2.259.511-.264.767-.264.767-2.258-.511zm6.774 1.533.264-.766 4.517 1.022-.264.767-.264.766-4.517-1.022zm9.033 2.045.264-.767 4.516 1.022-.264.767-.263.767-4.517-1.023zm9.033 2.044.264-.766 4.516 1.022-.264.766-.264.767-4.516-1.022zm9.033 2.045.263-.767 4.517 1.022-.264.767-.264.767-4.516-1.023zm9.032 2.044.264-.767 4.517 1.023-.264.766-.264.767-4.516-1.022zm9.033 2.045.264-.767 4.517 1.022-.264.767-.264.766-4.517-1.022zm9.033 2.044.264-.767 4.516 1.023-.263.766-.264.767-4.517-1.022zm9.033 2.044.264-.766 4.516 1.022-.264.767-.263.766-4.517-1.022zm9.033 2.045.264-.767 2.258.511-.264.767-.264.767-2.258-.511z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 25">
<path d="M18.36 3.15v2.567c0 1.012-1.034 1.833-2.31 1.833h-.924c-1.275 0-2.309-.82-2.309-1.833V3.15m73.905 0v2.567c0 1.012-1.034 1.833-2.31 1.833h-.924c-1.275 0-2.31-.82-2.31-1.833V3.15m-75.29 0H93.65c1.276 0 2.31.82 2.31 1.833V19.65c0 1.012-1.034 1.833-2.31 1.833H5.888c-1.275 0-2.31-.82-2.31-1.833V4.983c0-1.012 1.035-1.833 2.31-1.833" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 511 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 90">
<path d="M90 19.365v57.111m0-57.11V7.683m0 11.681c0 2.151-1.836 3.894-4.1 3.894H30.544a10.7 10.7 0 0 1-4.136-.825M90 19.365c0 2.151 1.836 3.894 4.1 3.894h55.356c1.472 0 2.871-.294 4.137-.825M90 76.476c0 5.376-4.59 9.735-10.25 9.735H30.543c-5.266 0-9.605-3.772-10.185-8.625M90 76.476c0 5.376 4.59 9.735 10.25 9.735h49.206c5.266 0 9.605-3.772 10.185-8.625M90 7.684c0-2.15-1.837-3.894-4.101-3.894H30.544c-5.661 0-10.25 4.358-10.25 9.735q0 .416.036.825M90 7.684c0-2.15 1.836-3.894 4.1-3.894h55.356c5.661 0 10.25 4.358 10.25 9.735q0 .416-.036.825m0 0a10.7 10.7 0 0 1 4.137-.825h2.05c5.662 0 10.252 4.358 10.252 9.734v45.43c0 5.376-4.59 9.734-10.251 9.734h-2.05c-1.484 0-2.894-.3-4.167-.837m0 0c-3.585-1.517-6.084-4.93-6.084-8.898V23.26q0-.417.036-.825c.32-3.622 2.728-6.68 6.077-8.084m-139.34 0c3.35 1.404 5.758 4.462 6.078 8.084q.036.408.036.825v45.43c0 3.967-2.5 7.38-6.085 8.897a10.7 10.7 0 0 1-4.166.837h-2.05c-5.662 0-10.251-4.358-10.251-9.735V23.26c0-5.376 4.59-9.734 10.25-9.734h2.05c1.473 0 2.872.294 4.138.825" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 170">
<path d="M93.673 20.687V83.69m0-63.004v-12.6m0 12.6c0 2.32-1.913 4.2-4.273 4.2H31.704a10.8 10.8 0 0 1-4.311-.89m66.28-3.31c0 2.32 1.914 4.2 4.274 4.2h64.106c2.36 0 4.274-1.88 4.274-4.2M93.673 83.692c0 5.8-4.783 10.501-10.684 10.501H31.704c-5.489 0-10.011-4.068-10.616-9.304m72.585-1.197c0 5.8 4.784 10.501 10.684 10.501h51.286c5.9 0 10.684-4.701 10.684-10.5m0-63.005V83.69m0-63.004v-12.6m0 12.6c0 2.32 1.914 4.2 4.273 4.2h57.696c1.534 0 2.993-.317 4.312-.89m-66.28 59.695v71.923c0 5.799 4.782 10.5 10.684 10.5h51.284c5.49 0 10.012-4.068 10.616-9.304V84.888M93.673 8.086c0-2.32-1.913-4.2-4.273-4.2H31.704c-5.9 0-10.684 4.7-10.684 10.5q0 .45.038.89m72.615-7.19c0-2.32 1.914-4.2 4.274-4.2h64.106c2.36 0 4.274 1.88 4.274 4.2m0 0c0-2.32 1.914-4.2 4.273-4.2h57.696c5.9 0 10.685 4.7 10.685 10.5 0 .293.024.587 0 .874m-.07 69.628c1.327.58 2.797.904 4.344.904h2.136c5.901 0 10.684-4.702 10.684-10.501V24.887c0-5.8-4.783-10.5-10.684-10.5h-2.136c-5.596 0-10.187 4.227-10.647 9.61q-.038.44-.038.89V75.29c0 4.28 2.605 7.961 6.342 9.597M21.059 15.276a10.8 10.8 0 0 0-4.312-.89H14.61c-5.9 0-10.684 4.701-10.684 10.5v50.405c0 5.8 4.783 10.5 10.684 10.5h2.137c1.546 0 3.016-.322 4.342-.903 3.737-1.636 6.342-5.317 6.342-9.597V24.887q0-.45-.037-.89c-.334-3.907-2.844-7.206-6.335-8.72" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 90">
<path d="M86.553 18.593V76.96m0-58.366V7.491m0 11.102c0 2.044-1.762 3.701-3.935 3.701H29.496c-1.413 0-2.756-.28-3.97-.784m61.027-2.917c0 2.044 1.762 3.701 3.935 3.701h59.024c2.174 0 3.935-1.657 3.935-3.7M86.553 76.958c0 5.11-4.404 9.252-9.837 9.252h-47.22c-5.054 0-9.218-3.585-9.775-8.198m66.832-1.054c0 5.11 4.404 9.252 9.837 9.252h47.22c5.433 0 9.837-4.142 9.837-9.252m0-58.366V76.96m0-58.366V7.491m0 11.102c0 2.044 1.762 3.701 3.936 3.701h53.122c1.413 0 2.756-.28 3.97-.784m-61.028 55.449c0 5.11 4.405 9.252 9.838 9.252h47.22c5.054 0 9.218-3.585 9.775-8.198M86.553 7.491c0-2.044-1.762-3.701-3.935-3.701H29.496c-5.433 0-9.838 4.142-9.838 9.252q0 .396.035.784m66.86-6.335c0-2.044 1.762-3.701 3.935-3.701h59.024c2.174 0 3.935 1.657 3.935 3.7m0 0c0-2.043 1.762-3.7 3.936-3.7h53.122c5.433 0 9.838 4.142 9.838 9.252 0 .258.022.517 0 .77m-.063 64.201c1.22.512 2.574.796 3.998.796h1.967c5.433 0 9.837-4.142 9.837-9.252V22.294c0-5.11-4.404-9.252-9.837-9.252h-1.967c-5.153 0-9.38 3.725-9.803 8.468q-.036.387-.035.784v47.263c0 3.771 2.399 7.015 5.84 8.456M19.693 13.826a10.3 10.3 0 0 0-3.97-.784h-1.967c-5.433 0-9.838 4.142-9.838 9.252v47.263c0 5.11 4.405 9.252 9.838 9.252h1.967c1.424 0 2.777-.284 3.998-.796 3.441-1.441 5.84-4.685 5.84-8.456V22.294a9 9 0 0 0-.035-.784c-.307-3.443-2.618-6.349-5.833-7.684" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60">
<path d="M41.4 35.261a.877.877 0 1 0-1.754 0zm4.384 6.138a.877.877 0 1 0 0-1.753zM41.4 44.03a.877.877 0 1 0-1.753 0zm-4.384-2.63a.877.877 0 1 0 0-1.754zM5.885 3.691v.877h48.23V2.815H5.885zm50.422 2.192h-.877v48.23h1.754V5.885zm-2.192 50.423v-.877H5.885v1.754h48.23zM3.692 54.115h.877V5.885H2.815v48.23zm2.192 2.192v-.877a1.315 1.315 0 0 1-1.315-1.315H2.815a3.07 3.07 0 0 0 3.07 3.069zm50.423-2.192h-.877c0 .726-.589 1.315-1.315 1.315v1.754a3.07 3.07 0 0 0 3.069-3.07zM54.115 3.692v.877c.726 0 1.315.589 1.315 1.315h1.754a3.07 3.07 0 0 0-3.07-3.069zm-48.23 0v-.877a3.07 3.07 0 0 0-3.07 3.07h1.754c0-.727.589-1.316 1.315-1.316zm19.73 36.83h-.877a5.26 5.26 0 0 1-5.262 5.262v1.754a7.015 7.015 0 0 0 7.016-7.015zm-6.139 6.139v-.877a5.26 5.26 0 0 1-5.261-5.261h-1.754a7.015 7.015 0 0 0 7.015 7.015zm-6.138-6.138h.877a5.26 5.26 0 0 1 5.261-5.262v-1.754a7.015 7.015 0 0 0-7.015 7.016zm6.138-6.139v.877a5.26 5.26 0 0 1 5.262 5.262h1.754a7.015 7.015 0 0 0-7.016-7.016zm27.185-14.908h-.877a5.26 5.26 0 0 1-5.261 5.262v1.754a7.015 7.015 0 0 0 7.015-7.016zm-6.138 6.139v-.877a5.26 5.26 0 0 1-5.262-5.262h-1.754a7.015 7.015 0 0 0 7.016 7.016zm-6.139-6.139h.877a5.26 5.26 0 0 1 5.262-5.261v-1.754a7.015 7.015 0 0 0-7.016 7.015zm6.139-6.138v.877a5.26 5.26 0 0 1 5.261 5.261h1.754a7.015 7.015 0 0 0-7.015-7.015zm4.384 27.185h-.877a3.51 3.51 0 0 1-3.507 3.507v1.754a5.26 5.26 0 0 0 5.261-5.261zm-4.384 4.384v-.877a3.51 3.51 0 0 1-3.508-3.507H35.26a5.26 5.26 0 0 0 5.262 5.261zm-4.385-4.384h.877a3.51 3.51 0 0 1 3.508-3.508V35.26a5.26 5.26 0 0 0-5.262 5.262zm4.385-4.385v.877a3.51 3.51 0 0 1 3.507 3.508h1.754a5.26 5.26 0 0 0-5.261-5.262zm0 .877h.876V35.26h-1.753v1.754zm3.508 3.508v.876h1.753v-1.753h-1.753zm-3.508 5.261h.876V44.03h-1.753v1.754zm-5.261-5.261v.876h1.753v-1.753h-1.753zm-9.647-21.047h-.877a5.26 5.26 0 0 1-5.262 5.262v1.754a7.015 7.015 0 0 0 7.016-7.016zm-6.139 6.139v-.877a5.26 5.26 0 0 1-5.261-5.262h-1.754a7.015 7.015 0 0 0 7.015 7.016zm-6.138-6.139h.877a5.26 5.26 0 0 1 5.261-5.261v-1.754a7.015 7.015 0 0 0-7.015 7.015zm6.138-6.138v.877a5.26 5.26 0 0 1 5.262 5.261h1.754a7.015 7.015 0 0 0-7.016-7.015z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 90">
<g>
<path d="M3.879 6.08c0-1.265 1.065-2.29 2.378-2.29h147.484c1.314 0 2.38 1.025 2.38 2.29v77.84c0 1.265-1.066 2.29-2.38 2.29H6.257c-1.313 0-2.378-1.025-2.378-2.29z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M138.994 19.358v-5.495a.952.916 0 0 1 .951-.915h5.71a.952.916 0 0 1 .95.915v5.495a.952.916 0 0 1-.95.916h-5.71a.952.916 0 0 1-.951-.916m-.001 56.778v-5.495a.952.916 0 0 1 .952-.915h5.709a.952.916 0 0 1 .951.915v5.495a.952.916 0 0 1-.951.916h-5.71a.952.916 0 0 1-.95-.916m-125.6-56.778v-5.495a.952.916 0 0 1 .951-.916h5.71a.952.916 0 0 1 .95.916v5.495a.952.916 0 0 1-.95.915h-5.71a.952.916 0 0 1-.951-.915m-.001 56.779v-5.495a.952.916 0 0 1 .952-.916h5.709a.952.916 0 0 1 .951.916v5.495a.952.916 0 0 1-.951.915h-5.71a.952.916 0 0 1-.95-.915" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1007 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 100">
<g>
<path d="M3.892 50c0-25.51 21.418-46.19 47.838-46.19h76.54c26.42 0 47.839 20.68 47.839 46.19S154.69 96.19 128.27 96.19H51.73C25.31 96.19 3.892 75.51 3.892 50" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M122.53 19.514V13.97a.957.924 0 0 1 .956-.924h5.74a.957.924 0 0 1 .958.924v5.543a.957.924 0 0 1-.957.924h-5.74a.957.924 0 0 1-.958-.924m.001 66.516v-5.543a.957.924 0 0 1 .957-.924h5.74a.957.924 0 0 1 .957.924v5.543a.957.924 0 0 1-.957.924h-5.74a.957.924 0 0 1-.957-.924M49.816 19.515v-5.543a.957.924 0 0 1 .957-.924h5.74a.957.924 0 0 1 .958.924v5.542a.957.924 0 0 1-.957.924h-5.74a.957.924 0 0 1-.958-.924m0 66.516v-5.543a.957.924 0 0 1 .957-.924h5.74a.957.924 0 0 1 .957.924v5.543a.957.924 0 0 1-.957.923h-5.74a.957.924 0 0 1-.957-.924" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1001 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 90">
<g>
<path d="M3.879 17.527c0-7.587 6.39-13.737 14.272-13.737h123.696c7.883 0 14.273 6.15 14.273 13.737v54.947c0 7.587-6.39 13.737-14.273 13.737H18.151c-7.882 0-14.272-6.15-14.272-13.737z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M138.994 19.358v-5.495a.952.916 0 0 1 .951-.915h5.71a.952.916 0 0 1 .95.915v5.495a.952.916 0 0 1-.95.916h-5.71a.952.916 0 0 1-.951-.916m-.001 56.778v-5.495a.952.916 0 0 1 .952-.915h5.709a.952.916 0 0 1 .951.915v5.495a.952.916 0 0 1-.951.916h-5.71a.952.916 0 0 1-.95-.916m-125.6-56.778v-5.495a.952.916 0 0 1 .951-.916h5.71a.952.916 0 0 1 .95.916v5.495a.952.916 0 0 1-.95.915h-5.71a.952.916 0 0 1-.951-.915m-.001 56.779v-5.495a.952.916 0 0 1 .952-.916h5.709a.952.916 0 0 1 .951.916v5.495a.952.916 0 0 1-.951.915h-5.71a.952.916 0 0 1-.95-.915" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
<g>
<path d="M3.826 55a51.174 51.174 0 1 0 102.349 0A51.174 51.174 0 1 0 3.826 55" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="m81.005 32.303-3.947-3.947a.93.93 0 0 1 0-1.316l3.947-3.948a.93.93 0 0 1 1.316 0l3.948 3.948a.93.93 0 0 1 0 1.316l-3.948 3.947a.93.93 0 0 1-1.316 0m0 54.897-3.947-3.948a.93.93 0 0 1 0-1.316l3.947-3.947a.93.93 0 0 1 1.316 0l3.948 3.947a.93.93 0 0 1 0 1.316L82.32 87.2a.93.93 0 0 1-1.316 0M27.04 32.304l-3.947-3.948a.93.93 0 0 1 0-1.315l3.948-3.948a.93.93 0 0 1 1.315 0l3.948 3.948a.93.93 0 0 1 0 1.315l-3.948 3.948a.93.93 0 0 1-1.315 0m-.001 54.604-3.947-3.948a.93.93 0 0 1 0-1.316l3.948-3.947a.93.93 0 0 1 1.315 0l3.948 3.947a.93.93 0 0 1 0 1.316l-3.948 3.948a.93.93 0 0 1-1.315 0" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 965 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 70">
<g>
<path d="M3.555 48.976c0 9.55 7.125 17.29 15.914 17.29h1.061c8.79 0 15.914-7.74 15.914-17.29V5.966c0-1.233-.92-2.233-2.056-2.233H5.611c-1.135 0-2.056 1-2.056 2.234z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.666 49.12c0 7.003 5.225 12.68 11.67 12.68h1.327c6.445 0 11.67-5.677 11.67-12.68V31.873c0-1.233-.92-2.233-2.056-2.233H9.722c-1.135 0-2.056 1-2.056 2.233zM33.792 3.733c1.465 0 2.652 1.29 2.652 2.882v12.103c0 1.592-1.187 2.882-2.652 2.882H6.208c-1.465 0-2.653-1.29-2.653-2.882V6.615c0-1.591 1.188-2.882 2.653-2.882z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M24.11 50.186c0 2.467-1.84 4.467-4.11 4.467s-4.111-2-4.111-4.467 1.84-4.466 4.11-4.466 4.112 2 4.112 4.466" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1008 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 55">
<g>
<path d="M3.555 34.56c0 9.263 7.125 16.774 15.914 16.774h1.061c8.79 0 15.914-7.51 15.914-16.775V5.833c0-1.196-.92-2.166-2.056-2.166H5.611c-1.135 0-2.056.97-2.056 2.166z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.666 34.7c0 6.793 5.225 12.3 11.67 12.3h1.327c6.445 0 11.67-5.507 11.67-12.3V14.5c0-1.197-.92-2.167-2.056-2.167H9.722c-1.135 0-2.056.97-2.056 2.167z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M24.11 35.733c0 2.394-1.84 4.334-4.11 4.334s-4.111-1.94-4.111-4.334 1.84-4.333 4.11-4.333 4.112 1.94 4.112 4.333" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 853 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 28">
<path d="M45.134 10.766H6.18c-1.292 0-2.34.848-2.34 1.894v2.68c0 1.045 1.048 1.893 2.34 1.893h38.954m0-6.467V5.288c0-1.046 1.048-1.894 2.34-1.894h26.704c1.292 0 2.34.848 2.34 1.894v5.478m-31.384 0h31.384m0 0h37.302c1.293 0 2.34.848 2.34 1.894v2.68c0 1.045-1.047 1.893-2.34 1.893H76.518m-31.384 0v5.479c0 1.046 1.048 1.894 2.34 1.894h26.704c1.292 0 2.34-.848 2.34-1.894v-5.479m-31.384 0h31.384" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 567 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 15">
<path d="M35.664 11.4c-.517 0-.936.269-.936.6s.419.6.936.6zm49.553 1.2c.517 0 .936-.269.936-.6s-.42-.6-.936-.6zM6.18 3v.6h107.64V2.4H6.18zm109.98 1.5h-.936v1.8h1.872V4.5zm-2.34 3.3v-.6H6.18v1.2h107.64zM3.84 6.3h.936V4.5H2.904v1.8zm2.34 1.5v-.6c-.775 0-1.404-.403-1.404-.9H2.904c0 1.16 1.467 2.1 3.276 2.1zm109.98-1.5h-.936c0 .497-.629.9-1.404.9v1.2c1.81 0 3.276-.94 3.276-2.1zM113.82 3v.6c.775 0 1.404.403 1.404.9h1.872c0-1.16-1.467-2.1-3.276-2.1zM6.18 3v-.6c-1.81 0-3.276.94-3.276 2.1h1.872c0-.497.629-.9 1.404-.9zm61.308 4.2h-.936V12h1.872V7.2zM52.512 12h.936V7.2h-1.872V12zm-16.848 0v.6h49.553v-1.2H35.664z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 784 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 45">
<path d="M6.336 41.4h227.328a2.418 2.1 0 0 0 2.418-2.1V5.7a2.418 2.1 0 0 0-2.418-2.1H6.336a2.418 2.1 0 0 0-2.418 2.1v33.6a2.418 2.1 0 0 0 2.418 2.1m43.744-5.88V9.48m46.432 26.04V9.48m46.433 26.04V9.48m46.433 26.04V9.48" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 393 B

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 60">
<path d="M93.87 20.25a3.3 3.3 0 0 0-1.072-2.407 3.8 3.8 0 0 0-2.586-.997c-.97 0-1.9.358-2.587.997a3.3 3.3 0 0 0-1.071 2.407c0 2.838 1.225 5.107 3.658 6.81h-.015m0 0 14.58 7.537c.57.295 1.046.726 1.377 1.25.331.523.505 1.12.505 1.727v1.401a3.3 3.3 0 0 1-1.071 2.407 3.8 3.8 0 0 1-2.587.998H77.393a3.8 3.8 0 0 1-2.586-.998 3.3 3.3 0 0 1-1.072-2.407v-1.4c0-.608.174-1.205.505-1.728a3.55 3.55 0 0 1 1.377-1.25zM6.284 3.691h167.433c1.32 0 2.392.981 2.392 2.192v48.23c0 1.212-1.071 2.193-2.392 2.193H6.284c-1.321 0-2.392-.981-2.392-2.192V5.885c0-1.212 1.07-2.193 2.392-2.193" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 743 B

+112 -33
View File
@@ -6,20 +6,20 @@ import logging
from datetime import timedelta
from pathlib import Path
from homeassistant.components.frontend import add_extra_js_url
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_interval
from . import websocket_api as hp_ws
from .const import (
ASSETS_DIR,
DOMAIN,
FILES_DIR,
FILES_URL,
FRONTEND_URL,
PLANS_DIR,
)
from .frontend_registration import (
async_remove_frontend_registration,
async_setup_frontend_registration,
PLANS_URL,
VERSION,
)
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
from .plans import collect_attachments, collect_plans, sweep_upload_temps
@@ -38,19 +38,11 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
hass.data.setdefault(DOMAIN, {})
hp_ws.async_register(hass)
from .frontend_assets import HouseplanFrontendAssetView
from .http_api import (
HouseplanContentView,
HouseplanDecorAssetUploadView,
HouseplanImportPreviewView,
HouseplanUploadView,
)
from .http_api import HouseplanContentView, HouseplanImportPreviewView, HouseplanUploadView
hass.http.register_view(HouseplanUploadView())
hass.http.register_view(HouseplanDecorAssetUploadView())
hass.http.register_view(HouseplanContentView())
hass.http.register_view(HouseplanImportPreviewView())
hass.http.register_view(HouseplanFrontendAssetView())
return True
@@ -91,21 +83,52 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
plans_path = Path(hass.config.path(PLANS_DIR))
files_path = Path(hass.config.path(FILES_DIR))
assets_path = Path(hass.config.path(ASSETS_DIR))
await hass.async_add_executor_job(
lambda: (
plans_path.mkdir(parents=True, exist_ok=True),
files_path.mkdir(parents=True, exist_ok=True),
assets_path.mkdir(parents=True, exist_ok=True),
)
lambda: (plans_path.mkdir(parents=True, exist_ok=True), files_path.mkdir(parents=True, exist_ok=True))
)
# NOTE (audit B1): plans and marker files are no longer static. They are
# served by HouseplanContentView, which requires auth. Only the manifest-
# gated frontend bundle stays public so Lovelace can load it. Registration
# failures are observable and recoverable, but never skip storage migration,
# repairs or housekeeping below.
await async_setup_frontend_registration(hass, entry, card_path)
# Static paths cannot be unregistered — register once per HA run.
if not hass.data[DOMAIN].get("static_registered"):
hass.data[DOMAIN]["static_registered"] = True
static_paths = []
try:
from homeassistant.components.http import StaticPathConfig
if card_path.exists():
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
# NOTE (audit B1): plans and marker files are NO LONGER static.
# They are served by HouseplanContentView, which requires auth.
# Only the card bundle stays public — Lovelace resources must be.
if static_paths:
await hass.http.async_register_static_paths(static_paths)
except ImportError: # very old HA versions
if card_path.exists():
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
if not card_path.exists():
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
return True
# Register the card. Preferably as a Lovelace resource (the frontend AWAITS
# resources before rendering dashboards, so the card is available even on a cold
# start of the mobile app). If the resource registry is unavailable (YAML-mode
# Lovelace, old versions) — fall back to extra_module_url.
module_url = f"{FRONTEND_URL}?v={VERSION}"
registered = await _register_lovelace_resource(hass, module_url)
if not registered:
add_extra_js_url(hass, module_url)
# Tell the user exactly where the card lives — the #1 support issue is people adding a
# Lovelace resource pointing at the on-disk path (/custom_components/...), which HA does
# not serve (wrong MIME → "Custom element doesn't exist"). The correct served URL is below.
if registered:
_LOGGER.info("House Plan card auto-registered as a Lovelace resource: %s", module_url)
else:
_LOGGER.info(
"House Plan card is served at %s . Lovelace resources look YAML-managed — add it "
"manually under `resources:` as { url: %s, type: module }. Do NOT use the on-disk "
"path /custom_components/houseplan/frontend/houseplan-card.js (HA does not serve it).",
module_url, module_url,
)
# One-time move to the square canvas (v1.48.0). Coordinates used to be
# normalised against a per-space aspect ratio; the canvas is now always
@@ -213,9 +236,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]})
hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]})
if recovered_import:
current = (await data.config_store.async_load() or {}).get("config") or {}
await recorder.async_purge_orphans(current)
await recorder.async_refresh()
current = (await data.config_store.async_load() or {}).get("config") or {}
live_ids = {str(marker.get("id")) for marker in current.get("markers") or []}
for marker_id in list(recorder.book.data):
if marker_id not in live_ids:
await recorder.async_delete(marker_id)
await async_check_plan_files(hass, entry)
@@ -283,8 +309,61 @@ async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -
return True
async def async_remove_entry(
hass: HomeAssistant, entry: HouseplanConfigEntry
) -> None:
"""Clean up frontend registration and notice on integration removal."""
await async_remove_frontend_registration(hass, entry)
async def async_remove_entry(hass: HomeAssistant, entry) -> None:
"""Clean up on integration removal: drop our Lovelace resource entry."""
try:
resources = _lovelace_resources(hass)
if resources is None or not hasattr(resources, "async_delete_item"):
return
for item in list(resources.async_items()):
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL:
await resources.async_delete_item(item["id"])
_LOGGER.debug("House Plan Lovelace resource removed: %s", item.get("url"))
except Exception as err: # noqa: BLE001 — best-effort cleanup
_LOGGER.debug("Could not remove the Lovelace resource on uninstall: %s", err)
def _lovelace_resources(hass: HomeAssistant):
lovelace = hass.data.get("lovelace")
resources = getattr(lovelace, "resources", None)
if resources is None and isinstance(lovelace, dict):
resources = lovelace.get("resources")
return resources
async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool:
"""Register (or update) the card in the Lovelace resource registry.
Returns True on success. Idempotent: if a resource with our path exists —
update the URL on version change; otherwise create it. Any exception → False
(fall back to extra_module_url).
"""
try:
resources = _lovelace_resources(hass)
if resources is None:
return False
# the resource registry must be loaded
if hasattr(resources, "loaded") and not resources.loaded:
await resources.async_load()
resources.loaded = True
elif hasattr(resources, "async_get_info"):
await resources.async_get_info()
# only storage mode allows creating items
if not hasattr(resources, "async_create_item"):
return False
base = FRONTEND_URL
existing = [
item for item in resources.async_items()
if str(item.get("url", "")).split("?", 1)[0] == base
]
if existing:
item = existing[0]
if item.get("url") != module_url and hasattr(resources, "async_update_item"):
await resources.async_update_item(item["id"], {"url": module_url})
return True
await resources.async_create_item({"res_type": "module", "url": module_url})
_LOGGER.debug("House Plan card registered as a Lovelace resource: %s", module_url)
return True
except Exception as err: # noqa: BLE001 — any failure → fallback
_LOGGER.debug("Could not register the Lovelace resource (%s), falling back to extra_module_url", err)
return False
@@ -1,144 +0,0 @@
"""Bounded, shared integrity verification for content-addressed decor assets."""
from __future__ import annotations
import hashlib
import logging
import stat
import threading
from collections import OrderedDict
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
ASSET_INTEGRITY_CACHE_ENTRIES = 256
ASSET_HASH_CHUNK_BYTES = 64 * 1024
ASSET_INTEGRITY_FOLLOWER_TIMEOUT_SECONDS = 30.0
_HASS_DATA_KEY = "asset_integrity_verifier"
@dataclass(frozen=True)
class FileSignature:
"""File version facts available without reading its content."""
size: int
mtime_ns: int
ctime_ns: int
@dataclass(frozen=True)
class _CacheEntry:
signature: FileSignature
digest: str
@dataclass
class _Flight:
event: threading.Event
digest: str | None = None
def _signature(path: Path) -> FileSignature:
current = path.stat()
if not stat.S_ISREG(current.st_mode):
raise OSError("asset is not a regular file")
return FileSignature(
size=current.st_size,
mtime_ns=current.st_mtime_ns,
ctime_ns=current.st_ctime_ns,
)
def _stream_sha256(path: Path) -> str:
"""Hash a blob without retaining its bytes in memory."""
digest = hashlib.sha256()
with path.open("rb") as stream:
while chunk := stream.read(ASSET_HASH_CHUNK_BYTES):
digest.update(chunk)
return digest.hexdigest()
class AssetIntegrityVerifier:
"""Thread-safe LRU digest cache with per-file-version single-flight."""
def __init__(
self,
max_entries: int = ASSET_INTEGRITY_CACHE_ENTRIES,
*,
hasher: Callable[[Path], str] | None = None,
event_factory: Callable[[], threading.Event] | None = None,
) -> None:
if max_entries < 1:
raise ValueError("max_entries must be positive")
self._max_entries = max_entries
self._hasher = hasher or _stream_sha256
self._event_factory = event_factory or threading.Event
self._lock = threading.Lock()
self._cache: OrderedDict[str, _CacheEntry] = OrderedDict()
self._inflight: dict[tuple[str, FileSignature], _Flight] = {}
def verify(self, path: Path, expected_digest: str) -> bool:
"""Return whether one stable file version has the expected digest."""
try:
canonical = str(path.resolve())
except (OSError, RuntimeError):
return False
try:
before = _signature(path)
except OSError:
with self._lock:
self._cache.pop(canonical, None)
return False
key = (canonical, before)
with self._lock:
cached = self._cache.get(canonical)
if cached is not None and cached.signature == before:
self._cache.move_to_end(canonical)
return cached.digest == expected_digest
if cached is not None:
self._cache.pop(canonical, None)
flight = self._inflight.get(key)
owner = flight is None
if owner:
flight = _Flight(self._event_factory())
self._inflight[key] = flight
assert flight is not None
if not owner:
if not flight.event.wait(ASSET_INTEGRITY_FOLLOWER_TIMEOUT_SECONDS):
return False
return flight.digest == expected_digest
digest: str | None = None
stable = False
try:
digest = self._hasher(path)
# Never publish a digest for bytes that changed while they were read.
stable = _signature(path) == before
except Exception as err: # noqa: BLE001 - filesystem/hash seam fails dark
_LOGGER.debug("House Plan asset integrity check failed: %s", err)
finally:
with self._lock:
if stable and digest is not None:
self._cache[canonical] = _CacheEntry(before, digest)
self._cache.move_to_end(canonical)
while len(self._cache) > self._max_entries:
self._cache.popitem(last=False)
flight.digest = digest
self._inflight.pop(key, None)
flight.event.set()
return stable and digest == expected_digest
def get_asset_integrity_verifier(hass: Any) -> AssetIntegrityVerifier:
"""Return the single verifier shared by HTTP and WS on this HA instance."""
domain_data = hass.data.setdefault(DOMAIN, {})
verifier = domain_data.get(_HASS_DATA_KEY)
if not isinstance(verifier, AssetIntegrityVerifier):
verifier = AssetIntegrityVerifier()
domain_data[_HASS_DATA_KEY] = verifier
return verifier

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