Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2ff2acb7a |
@@ -1,14 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.pdf binary
|
||||
*.mp4 binary
|
||||
*.webm binary
|
||||
*.zip binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
message_file=$1
|
||||
|
||||
# Git-generated merge commits do not represent an independently authored
|
||||
# product change and inherit provenance from their parents.
|
||||
case "${message_file##*/}" in
|
||||
MERGE_MSG) exit 0 ;;
|
||||
esac
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel)
|
||||
node "$repo_root/scripts/validate-commit-provenance.mjs" \
|
||||
--message-file "$message_file" --staged --check-hook-mode
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# PROCESS.md 10.1: the blocking process gate lives here, because commits go
|
||||
# straight to dev without pull requests and GitHub blocks nothing on its side.
|
||||
# CI still runs the same script (10.3), but by then the code is already in dev —
|
||||
# that catch-up pass reports, it does not prevent.
|
||||
#
|
||||
# Git feeds one line per ref on stdin:
|
||||
# <local ref> <local sha> <remote ref> <remote sha>
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel)
|
||||
gate="$repo_root/scripts/process-gate.mjs"
|
||||
zero=$(printf '%040d' 0)
|
||||
|
||||
# The gate reasons about commits. A repository without it — an old checkout, a
|
||||
# bisect, a worktree from before the script existed — must still be pushable.
|
||||
if [ ! -f "$gate" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Reading issue status needs gh, and a hook that cannot work on a train is a
|
||||
# hook people disable. Offline the checks that need no network still run, and the
|
||||
# strict pass happens in CI, where gh is always present.
|
||||
issues_flag=""
|
||||
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
|
||||
issues_flag="--issues"
|
||||
else
|
||||
echo "process-gate: gh недоступен, проверка статуса issue пропущена — её выполнит CI" >&2
|
||||
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
|
||||
continue
|
||||
fi
|
||||
|
||||
# Tags carry no process state of their own: the commit they point at was
|
||||
# already checked when it was pushed.
|
||||
case "$local_ref" in
|
||||
refs/tags/*) continue ;;
|
||||
esac
|
||||
|
||||
if [ "$remote_sha" = "$zero" ]; then
|
||||
# A branch that does not exist on the remote yet. Everything it adds on top
|
||||
# of dev is new, so that is the range — not the whole history, which would
|
||||
# drag in every violation committed before the gate existed.
|
||||
base=$(git merge-base "$local_sha" refs/remotes/origin/dev 2>/dev/null || true)
|
||||
if [ -z "$base" ]; then
|
||||
echo "process-gate: не нашёл общего предка с origin/dev, проверяю последние 20 коммитов" >&2
|
||||
base="$local_sha~20"
|
||||
fi
|
||||
else
|
||||
base="$remote_sha"
|
||||
fi
|
||||
|
||||
echo "process-gate: $local_ref, диапазон ${base}..${local_sha}" >&2
|
||||
# shellcheck disable=SC2086
|
||||
if ! node "$gate" --range "${base}..${local_sha}" --target-ref "$remote_ref" $issues_flag >&2; then
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$status" -ne 0 ]; then
|
||||
cat >&2 <<'EOF'
|
||||
|
||||
Push остановлен: нарушен процесс (PROCESS.md §10.2).
|
||||
|
||||
Починить надо причину, а не симптом. Если нарушение уже опубликовано, его
|
||||
исправляет следующий коммит плюс issue с меткой `process` — не force-push
|
||||
(§12, правило 17).
|
||||
|
||||
Обойти проверку можно через `git push --no-verify`, и тогда то же самое найдёт
|
||||
job `process-gate` в Validate — уже после того, как код окажется в dev.
|
||||
EOF
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Bug report
|
||||
description: Something in House Plan does not work as expected
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: House Plan version
|
||||
placeholder: v1.13.0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: ha_version
|
||||
attributes:
|
||||
label: Home Assistant version
|
||||
placeholder: "2026.6"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened / what did you expect?
|
||||
description: Steps to reproduce help a lot.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / browser console errors
|
||||
description: "Settings → System → Logs (search: houseplan) and the browser console (F12)."
|
||||
render: text
|
||||
- type: textarea
|
||||
id: diagnostics
|
||||
attributes:
|
||||
label: Diagnostics
|
||||
description: "Settings → Devices & services → House Plan → ⋯ → Download diagnostics (personal fields are redacted automatically)."
|
||||
@@ -1,8 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Telegram chat (@ha_houseplan)
|
||||
url: https://t.me/ha_houseplan
|
||||
about: Questions, setup help, ideas and screenshots — the fastest way to get an answer.
|
||||
- name: 💡 GitHub discussions
|
||||
url: https://github.com/Matysh/houseplan-card/discussions
|
||||
about: Longer-form ideas and show-and-tell.
|
||||
@@ -1,14 +0,0 @@
|
||||
name: Feature request
|
||||
description: An idea to make House Plan better
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What problem would this solve?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: How do you imagine it working?
|
||||
@@ -1,96 +0,0 @@
|
||||
name: Анонс релиза
|
||||
# Telegram notifications for t.me/ha_houseplan (owner request, 2026-08-07).
|
||||
# Stable releases are announced; prereleases are deliberately silent.
|
||||
# workflow_dispatch exists purely as a connectivity test button and therefore
|
||||
# remains allowed to send a test message.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch: {}
|
||||
workflow_call:
|
||||
inputs:
|
||||
reusable:
|
||||
required: true
|
||||
type: boolean
|
||||
tag:
|
||||
required: true
|
||||
type: string
|
||||
release_name:
|
||||
required: true
|
||||
type: string
|
||||
url:
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
required: true
|
||||
type: boolean
|
||||
ref:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
TELEGRAM_BOT_TOKEN:
|
||||
required: true
|
||||
TELEGRAM_CHAT_ID:
|
||||
required: true
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
telegram:
|
||||
name: Оповещение в Telegram (только стабильные)
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.prerelease == false) || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out release notes for a reusable call
|
||||
if: ${{ inputs.reusable == true }}
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- name: Send to Telegram
|
||||
env:
|
||||
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
CALLED: ${{ inputs.reusable }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
INPUT_NAME: ${{ inputs.release_name }}
|
||||
INPUT_URL: ${{ inputs.url }}
|
||||
INPUT_PRE: ${{ inputs.prerelease }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
RELEASE_NAME: ${{ github.event.release.name }}
|
||||
RELEASE_URL: ${{ github.event.release.html_url }}
|
||||
RELEASE_PRE: ${{ github.event.release.prerelease }}
|
||||
# The body goes through env, never through shell interpolation —
|
||||
# release notes are arbitrary text.
|
||||
RELEASE_BODY: ${{ github.event.release.body }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$EVENT" = "workflow_dispatch" ] && [ "$CALLED" != "true" ]; then
|
||||
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
|
||||
else
|
||||
if [ "$CALLED" = "true" ]; then
|
||||
TAG=$INPUT_TAG
|
||||
NAME=$INPUT_NAME
|
||||
URL=$INPUT_URL
|
||||
PRE=$INPUT_PRE
|
||||
BODY=$(cat docs/RELEASE-NOTES.md)
|
||||
else
|
||||
TAG=$RELEASE_TAG
|
||||
NAME=$RELEASE_NAME
|
||||
URL=$RELEASE_URL
|
||||
PRE=$RELEASE_PRE
|
||||
BODY=$RELEASE_BODY
|
||||
fi
|
||||
if [ "$PRE" = "true" ]; then
|
||||
echo "Prerelease Telegram announcement is disabled"
|
||||
exit 0
|
||||
fi
|
||||
KIND="🏠 Релиз"
|
||||
SUMMARY=$(printf '%s' "$BODY" | head -c 2500)
|
||||
TEXT=$(printf '%s houseplan-card %s — %s\n\n%s\n\n%s' \
|
||||
"$KIND" "$TAG" "$NAME" "$SUMMARY" "$URL")
|
||||
fi
|
||||
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
|
||||
@@ -1,154 +0,0 @@
|
||||
# Скриншоты документации снимаются здесь и только здесь (#246).
|
||||
#
|
||||
# Съёмка на машине исполнителя даёт байтово разный PNG при одинаковом кадре:
|
||||
# сглаживание и хинтинг зависят от окружения. Измерено на истории — пересъёмка
|
||||
# в #231 изменила два файла из девяти на 7–8 байт, набор с беты все девять
|
||||
# целиком. Одно окружение убирает этот шум насовсем.
|
||||
#
|
||||
# Джоба ничего не коммитит: она публикует артефакт, который человек принимает
|
||||
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
|
||||
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
|
||||
# попадают в репозиторий через явное решение, а не через бота.
|
||||
#
|
||||
# Снимать здесь больше не обязанность, а удобство (#401). Приёмка проверяет не
|
||||
# место съёмки, а её воспроизводимость: каждый кадр, не объявленный изменённым,
|
||||
# должен совпасть с закоммиченным байт-в-байт. Эта джоба потому и удобна, что
|
||||
# среда у неё та же, в которой снят закоммиченный набор, — но принять получится
|
||||
# из любой, где кадры воспроизводятся, и не получится ни из одной, где нет.
|
||||
name: Скриншоты документации
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Ветка или SHA, с которого снимать
|
||||
required: false
|
||||
default: dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
capture:
|
||||
name: Съёмка и сверка скриншот-индекса
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Тот же кэш и тот же отказ от --with-deps, что в smoke/golden (#175, #206):
|
||||
# системные библиотеки Chromium уже в образе раннера.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
- name: Build the bundle the screenshots must come from
|
||||
run: npm run build
|
||||
# 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
|
||||
# Вердикт до всякой приёмки. Само число изменившихся файлов ничего не
|
||||
# говорит: набор, снятый другим браузером, меняет их все, и это нормально
|
||||
# ровно один раз — при переходе на канонический прогон. Сравнивать надо
|
||||
# браузер: тот же 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)
|
||||
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 "изменился продукт. Смотрите на кадры: если изменение ожидаемое, принимайте."
|
||||
elif [ "$before" != "$after" ]; then
|
||||
echo "ВЕРДИКТ: браузер другой, поэтому переписаны все кадры сразу."
|
||||
echo "Это ожидаемо один раз — при переходе на канонический прогон."
|
||||
echo "Если Chromium сменился неожиданно, сверьте закреплённую версию в package-lock."
|
||||
else
|
||||
echo "ВЕРДИКТ: ничего не изменилось, принимать нечего."
|
||||
fi
|
||||
- name: Upload candidate
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: docs-screenshots
|
||||
path: |
|
||||
docs/images/*.png
|
||||
docs/images/screenshots.json
|
||||
if-no-files-found: error
|
||||
@@ -1,92 +0,0 @@
|
||||
name: Мутационный гейт
|
||||
|
||||
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
|
||||
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
|
||||
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
|
||||
# защитой, и это хуже его отсутствия.
|
||||
#
|
||||
# Прогон дорогой, поэтому он не входит в Validate и не идёт на каждый push.
|
||||
# Его место — перед стабильным релизом (PROCESS.md §8) и раз в неделю по
|
||||
# расписанию, чтобы дрейф тестов не копился до релиза. Дешёвая половина —
|
||||
# «якоря патчей живы, guard-файлы существуют» — идёт с обычными юнитами:
|
||||
# test/mutation-gate.test.mjs.
|
||||
#
|
||||
# #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle),
|
||||
# компиляция тестов в worktree стартует с тёплого test-build (инкрементальный
|
||||
# tsc), а реестр режется на четыре чересполосных шарда — полный прогон
|
||||
# укладывается в десятки минут вместо часов. Локальный дифф-режим:
|
||||
# node scripts/mutation-gate.mjs --changed origin/dev..HEAD
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Git ref whose mutation guards must be proved
|
||||
required: false
|
||||
default: dev
|
||||
schedule:
|
||||
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
|
||||
- cron: '20 5 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: mutation-gate
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
mutants:
|
||||
name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 4)"
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
# Шард ~64 мутантов × свой guard; бандл собирают только браузерные гварды.
|
||||
# Час — потолок против зависшего Chromium.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
|
||||
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: |
|
||||
# Точные версии — в tests_backend/requirements.txt (#392): без них
|
||||
# резолвер выбирает набор сам, и «зелёный backend» значит разное
|
||||
# в разные дни.
|
||||
pip install -r tests_backend/requirements.txt
|
||||
pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest '
|
||||
|
||||
- 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
|
||||
|
||||
- name: Реестр применим к текущему коду
|
||||
run: node scripts/mutation-gate.mjs --check
|
||||
|
||||
- name: Тёплый test-build для инкрементальной компиляции мутантов
|
||||
run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||||
|
||||
- name: Каждый тест ловит свою поломку
|
||||
run: node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4
|
||||
@@ -1,257 +0,0 @@
|
||||
name: Полные бенчмарки производительности
|
||||
|
||||
on:
|
||||
# Every main promotion is a stable-release candidate and must have an
|
||||
# exact-SHA full comparison before stable assets are published.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: "0 4 * * 1"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
comparison_ref:
|
||||
description: "Optional baseline tag, branch or SHA; empty uses the candidate parent"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: full-performance-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
performance:
|
||||
name: Бенчмарки рендера и геометрии
|
||||
# Every profile keeps base and candidate sequential on one hosted runner.
|
||||
# Independent profile pairs run in parallel: cross-profile timing is never
|
||||
# compared, while serialising all profile pairs cannot fit the job timeout.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
profile:
|
||||
- large-house
|
||||
- isometric
|
||||
- plan-snap
|
||||
- blend
|
||||
- overlay
|
||||
- space-default
|
||||
- space-glow
|
||||
steps:
|
||||
- name: Check out candidate
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: candidate
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Resolve comparison SHA
|
||||
id: base
|
||||
working-directory: candidate
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
MANUAL_BASE: ${{ inputs.comparison_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
|
||||
git fetch --force --tags --prune --unshallow origin
|
||||
else
|
||||
git fetch --force --tags --prune origin
|
||||
fi
|
||||
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -n "$MANUAL_BASE" ]; then
|
||||
sha="$(git rev-parse "${MANUAL_BASE}^{commit}" 2>/dev/null || true)"
|
||||
source="manual comparison ref $MANUAL_BASE"
|
||||
elif [ "$EVENT_NAME" = "push" ] && [ -n "$PUSH_BEFORE_SHA" ] && ! printf '%s' "$PUSH_BEFORE_SHA" | grep -Eq '^0+$'; then
|
||||
sha="$PUSH_BEFORE_SHA"
|
||||
source="push before"
|
||||
else
|
||||
sha="$(git rev-parse HEAD^ 2>/dev/null || true)"
|
||||
source="candidate parent"
|
||||
fi
|
||||
requested_sha="$sha"
|
||||
|
||||
usable=true
|
||||
reason=""
|
||||
if [ -z "$sha" ] || ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then
|
||||
usable=false
|
||||
reason="commit is not present after fetching all remote refs"
|
||||
elif [ "$source" = "push before" ] && ! git merge-base --is-ancestor "$sha" HEAD; then
|
||||
usable=false
|
||||
reason="commit is no longer an ancestor of the pushed revision"
|
||||
fi
|
||||
|
||||
if [ "$usable" != true ]; then
|
||||
parent_sha="$(git rev-parse HEAD^ 2>/dev/null || true)"
|
||||
if [ -n "$parent_sha" ] && [ "$parent_sha" != "$(git rev-parse HEAD)" ]; then
|
||||
sha="$parent_sha"
|
||||
source="candidate parent (unusable requested-base fallback)"
|
||||
echo "::warning::Comparison SHA ${requested_sha:-none} is unusable ($reason); using candidate parent $sha."
|
||||
usable=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$usable" != true ]; then
|
||||
fallback_tag=""
|
||||
fallback_sha=""
|
||||
head_sha="$(git rev-parse HEAD)"
|
||||
while IFS= read -r tag; do
|
||||
case "$tag" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
tag_sha="$(git rev-list -n 1 "$tag")"
|
||||
if [ "$tag_sha" != "$head_sha" ]; then
|
||||
fallback_tag="$tag"
|
||||
fallback_sha="$tag_sha"
|
||||
break
|
||||
fi
|
||||
done < <(git tag --merged HEAD --sort=-version:refname)
|
||||
if [ -z "$fallback_sha" ]; then
|
||||
echo "::error::No usable comparison commit or previous release tag is reachable from HEAD."
|
||||
exit 1
|
||||
fi
|
||||
sha="$fallback_sha"
|
||||
source="release tag $fallback_tag"
|
||||
echo "::warning::Using $fallback_tag ($sha) as the comparison base."
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "${sha}:demo/bundle-freshness.mjs" 2>/dev/null; then
|
||||
echo "::warning::Comparison $sha predates HP-PERF-01; using candidate parent HEAD^."
|
||||
sha="$(git rev-parse HEAD^)"
|
||||
source="candidate parent (HP-PERF-01 compatibility)"
|
||||
fi
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "Comparison base: $sha ($source)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Check out base SHA
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ steps.base.outputs.sha }}
|
||||
path: baseline
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
candidate/package-lock.json
|
||||
baseline/package-lock.json
|
||||
|
||||
- name: Install candidate and baseline dependencies
|
||||
run: npm ci --prefix candidate && npm ci --prefix baseline
|
||||
|
||||
# То же, что в validate.yml: кэш браузеров, apt не трогаем (#206).
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('candidate/package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
working-directory: candidate
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Build both exact source trees
|
||||
run: |
|
||||
npm --prefix candidate run build
|
||||
(cd candidate && node scripts/bundle-sync.mjs)
|
||||
npm --prefix baseline run build
|
||||
if [ -f baseline/scripts/bundle-sync.mjs ]; then
|
||||
(cd baseline && node scripts/bundle-sync.mjs)
|
||||
else
|
||||
echo "::notice::The comparison base predates bundle-sync.mjs; materializing its freshly built bundle with the legacy copy path."
|
||||
mkdir -p baseline/custom_components/houseplan/frontend baseline/demo/srv/assets
|
||||
cp baseline/dist/houseplan-card.js baseline/custom_components/houseplan/frontend/houseplan-card.js
|
||||
cp baseline/dist/houseplan-card.js baseline/demo/srv/assets/houseplan-card.js
|
||||
fi
|
||||
|
||||
- name: Capture base and candidate profile
|
||||
working-directory: candidate
|
||||
env:
|
||||
PROFILE: ${{ matrix.profile }}
|
||||
run: |
|
||||
mkdir -p ../artifacts/performance
|
||||
case "$PROFILE" in
|
||||
large-house)
|
||||
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
|
||||
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
|
||||
;;
|
||||
isometric)
|
||||
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
|
||||
;;
|
||||
plan-snap)
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
|
||||
;;
|
||||
blend)
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
|
||||
;;
|
||||
overlay)
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/overlay-candidate.json
|
||||
if ! grep -q "glow_enabled" ../baseline/src/logic.ts; then
|
||||
echo "Base predates independent Glow; bootstrap relative overlay baseline, keep absolute gate"
|
||||
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
|
||||
fi
|
||||
;;
|
||||
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
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Enforce relative and absolute performance budget
|
||||
working-directory: candidate
|
||||
env:
|
||||
PROFILE: ${{ matrix.profile }}
|
||||
run: |
|
||||
case "$PROFILE" in
|
||||
large-house)
|
||||
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
|
||||
;;
|
||||
isometric)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
|
||||
;;
|
||||
plan-snap)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
|
||||
;;
|
||||
blend)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
|
||||
;;
|
||||
overlay)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
|
||||
;;
|
||||
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
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: full-performance-${{ matrix.profile }}
|
||||
path: artifacts/performance
|
||||
@@ -1,970 +0,0 @@
|
||||
name: Ревью-конвейер
|
||||
|
||||
# Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это
|
||||
# сообщение: она порождает событие, событие запускает следующий шаг.
|
||||
#
|
||||
# S4-spec-review -> ревью ТЗ -> S5-ready | S3-spec
|
||||
# S7-code-review -> код-ревью -> слияние в dev -> S8-merged | S6-in-progress
|
||||
#
|
||||
# Три вещи, без которых конвейер молча не работает:
|
||||
#
|
||||
# 1. Метки переставляются токеном HP_PROCESS_TOKEN, а не GITHUB_TOKEN. GitHub
|
||||
# намеренно не запускает workflow от событий, вызванных GITHUB_TOKEN, чтобы
|
||||
# не было циклов — цепочка оборвалась бы после первого шага.
|
||||
# 2. Этот файл обязан лежать в ветке по умолчанию (main). Для события `issues`
|
||||
# GitHub берёт workflow только оттуда, независимо от того, что в dev.
|
||||
# 3. Многострочный текст внутри `run:` — только через heredoc. Строка с нулевым
|
||||
# отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
|
||||
# Проверять не только YAML, но и каждый `run` через `bash -n`.
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
concurrency:
|
||||
# Два события по одному issue не должны запускать два прогона.
|
||||
group: process-issue-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
# Обязательно: claude-code-action получает OIDC-токен для авторизации
|
||||
# GitHub App. Без этого прогон падает с «Could not fetch an OIDC token».
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
name: "Страж: ребейз на dev и предпосылки ревью"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
stage: ${{ steps.decide.outputs.stage }}
|
||||
cycle: ${{ steps.decide.outputs.cycle }}
|
||||
spent: ${{ steps.decide.outputs.spent }}
|
||||
limit: ${{ steps.decide.outputs.limit }}
|
||||
steps:
|
||||
- id: decide
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
LABEL: ${{ github.event.label.name }}
|
||||
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
|
||||
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
|
||||
SMALL: ${{ contains(github.event.issue.labels.*.name, 'small') }}
|
||||
TRIVIAL: ${{ contains(github.event.issue.labels.*.name, 'trivial') }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
# Этап определяется первым: от него зависит, какие вердикты считать.
|
||||
stage=""; marker=""
|
||||
case "$LABEL" in
|
||||
S4-spec-review) stage="spec"; marker="SPEC-REVIEW" ;;
|
||||
S7-code-review) stage="code"; marker="CODE-REVIEW" ;;
|
||||
*) echo "метка $LABEL конвейер не запускает" ;;
|
||||
esac
|
||||
|
||||
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
|
||||
limit=4
|
||||
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
|
||||
|
||||
# Считаются ДВЕ РАЗНЫЕ величины, и это не педантизм (#227).
|
||||
#
|
||||
# `attempt` — сколько раз ревью уже отработало на этом этапе. Он нужен
|
||||
# только для имени документа и метки: два захода с одинаковым номером
|
||||
# означают, что второй документ перезапишет первый и артефакт ревью
|
||||
# исчезнет.
|
||||
#
|
||||
# `spent` — сколько циклов израсходовано из бюджета §4. Цикл — это
|
||||
# «отправка на ревью → вердикт с блокирующими находками → возврат
|
||||
# автору», поэтому бюджет тратят ТОЛЬКО жёлтые и красные вердикты.
|
||||
# Зелёный ничего на правки не вернул и цикла не образует.
|
||||
#
|
||||
# Раньше обе роли исполнял один счётчик всех вердиктов, и конвейер
|
||||
# наказывал за то, что предписывал сам: при неудавшемся слиянии он
|
||||
# велит вернуть S7-code-review после ребейза, и этот заход добивал
|
||||
# бюджет. На #225 (лёгкий трек, лимит 2) последовательность
|
||||
# жёлтый → зелёный → ребейз дала review-4 на задаче с зелёным ревью и
|
||||
# зелёным CI: работа встала, хотя после вердикта не было ни одной
|
||||
# правки продуктового кода.
|
||||
#
|
||||
# Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал
|
||||
# цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по
|
||||
# имени документа в теле комментария; документа нет — вердикт не
|
||||
# посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы
|
||||
# работу досрочно: из двух ошибок выбрана обратимая.
|
||||
attempt=1; spent=0; spent_list=""
|
||||
if [ -n "$stage" ]; then
|
||||
comments=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json comments)
|
||||
of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))]"
|
||||
# Блокирующим считается вердикт, у которого в строке вердикта стоит
|
||||
# «жёлтый» или «красный». Регистр и окружение слова не важны.
|
||||
blocking="$of_stage | map(select(.body | test(\"Вердикт:[^\\n]*(жёлт|красн)\"; \"i\")))"
|
||||
attempt=$(( $(printf '%s' "$comments" | jq -r "$of_stage | length") + 1 ))
|
||||
spent=$(printf '%s' "$comments" | jq -r "$blocking | length")
|
||||
spent_list=$(printf '%s' "$comments" | jq -r "$blocking | map(\"- \" + .url) | join(\"\\n\")")
|
||||
fi
|
||||
|
||||
# Отказ обязан быть виден в issue, а не только в логе прогона.
|
||||
# Ревьюшная метка обещает работу; если конвейер её не начал и промолчал,
|
||||
# задача стоит в этом статусе бесконечно и никто об этом не узнаёт.
|
||||
# Так и вышло на #123: чужой issue довели до S4-spec-review, guard
|
||||
# отказался за 9 секунд, и в issue не было ни слова.
|
||||
#
|
||||
# Пишем только когда пытались запустить ревью, то есть stage опознан.
|
||||
# Иначе комментарий уходил бы на каждую смену любой метки.
|
||||
refuse() {
|
||||
echo "$1"
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
|
||||
"Конвейер ревью не запущен: $2
|
||||
|
||||
Метка \`$LABEL\` обещает работу, которая не начнётся, поэтому статус лучше вернуть в предыдущий — иначе задача простоит здесь бесконечно. [Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
|
||||
stage=""
|
||||
}
|
||||
|
||||
# Автор issue здесь не проверяется (решение владельца 2026-08-13).
|
||||
# Проверка стоит на входе в процесс, а не на каждом шаге: как только
|
||||
# задача получила статусную метку, она в работе, и кто её завёл — не
|
||||
# имеет значения. Само присвоение метки и есть явное подтверждение
|
||||
# владельца, причём проверенное платформой: метки может ставить только
|
||||
# тот, у кого есть право записи в репозиторий. Прежняя проверка здесь
|
||||
# дублировала эту гарантию и заставляла переоформлять чужие отчёты
|
||||
# своими issue — чистая работа впустую, как на #123.
|
||||
if [ -z "$stage" ]; then
|
||||
:
|
||||
elif [ "$BLOCKED" = "true" ]; then
|
||||
refuse "стоит blocked — конвейер не запускается" \
|
||||
"на issue стоит \`blocked\` — задача ждёт внешнего решения. Снять метку, когда решение принято."
|
||||
elif [ "$EXHAUSTED" = "true" ]; then
|
||||
# Метку снимает владелец, а не конвейер: автоматика, отменяющая
|
||||
# остановку работы, дороже ручного снятия. Но пересчёт печатается —
|
||||
# метка могла остаться от прежнего правила, когда бюджет тратил и
|
||||
# зелёный вердикт (#227).
|
||||
stale=""
|
||||
if [ "$spent" -lt "$limit" ]; then
|
||||
stale=" Пересчёт по действующему правилу: блокирующих циклов $spent из $limit — метка могла остаться от прежнего правила, когда бюджет тратил любой вердикт. Снять её может владелец."
|
||||
fi
|
||||
refuse "стоит review-4 — решение за владельцем" \
|
||||
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4).$stale"
|
||||
elif [ "$spent" -ge "$limit" ]; then
|
||||
echo "блокирующих циклов этапа $stage: $spent из $limit — лимит исчерпан"
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" --add-label review-4
|
||||
# Перечень учтённого обязателен: иначе владельцу приходится читать
|
||||
# всю ленту, чтобы понять, из чего сложился счёт.
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
|
||||
"Лимит циклов ревью исчерпан: блокирующих циклов $spent из $limit на этапе \`$stage\` (заход $attempt). Следующего захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4).
|
||||
|
||||
Учтены вердикты с блокирующими находками — зелёные бюджет не тратят:
|
||||
$spent_list"
|
||||
stage=""
|
||||
else
|
||||
echo "этап $stage, заход $attempt, блокирующих циклов $spent из $limit"
|
||||
fi
|
||||
echo "stage=$stage" >> "$GITHUB_OUTPUT"
|
||||
echo "cycle=$attempt" >> "$GITHUB_OUTPUT"
|
||||
echo "spent=$spent" >> "$GITHUB_OUTPUT"
|
||||
echo "limit=$limit" >> "$GITHUB_OUTPUT"
|
||||
|
||||
review:
|
||||
name: "Ревью (Claude): вердикт в issue"
|
||||
needs: guard
|
||||
if: needs.guard.outputs.stage != ''
|
||||
runs-on: ubuntu-latest
|
||||
# Время — единственный настоящий ограничитель зациклившегося прогона.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
# Иначе в конфиге git остаётся креденшел GITHUB_TOKEN, и push с
|
||||
# мёртвым PAT молча уходит от github-actions[bot] — 403 при
|
||||
# contents: read. Отказ обязан быть громким и правильным.
|
||||
persist-credentials: false
|
||||
|
||||
# Живость PAT проверяется ДО ревью. На #150 истёкший токен обнаружился
|
||||
# только на публикации документа — после сорока минут работы ревьюера.
|
||||
- name: Секрет HP_PROCESS_TOKEN жив
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::error::HP_PROCESS_TOKEN пуст — секрет удалён или недоступен"
|
||||
exit 1
|
||||
fi
|
||||
if ! login=$(gh api user -q .login 2>/dev/null); then
|
||||
echo "::error::HP_PROCESS_TOKEN не аутентифицируется — истёк или отозван. Обновить: Settings -> Secrets and variables -> Actions -> HP_PROCESS_TOKEN"
|
||||
exit 1
|
||||
fi
|
||||
echo "токен жив, действует от: $login"
|
||||
|
||||
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
|
||||
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
|
||||
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
|
||||
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
|
||||
# тяжёлых job, здесь его не было.
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
|
||||
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
|
||||
# ревьюер прочтёт dev и не найдёт того, что должен оценивать.
|
||||
- name: Перейти на ветку задачи
|
||||
id: branch
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
# Свежая по последнему коммиту, а не первая по алфавиту: на #150 рядом
|
||||
# жили ветка ТЗ и ветка реализации, и head -1 выбрал устаревшую.
|
||||
git fetch -q origin "+refs/heads/issue/${NUM}-*:refs/remotes/origin/issue/${NUM}-*" || true
|
||||
branches=$(git for-each-ref --sort=-committerdate \
|
||||
--format='%(refname:lstrip=3)' "refs/remotes/origin/issue/${NUM}-*")
|
||||
branch=$(printf '%s\n' "$branches" | head -1)
|
||||
if [ "$(printf '%s\n' "$branches" | grep -c .)" -gt 1 ]; then
|
||||
echo "::warning::веток issue/${NUM}-* несколько ($(echo $branches | tr '\n' ' ')) — выбрана свежая по коммиту: $branch. Устаревшую следует удалить."
|
||||
fi
|
||||
if [ -n "$branch" ]; then
|
||||
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"
|
||||
fi
|
||||
|
||||
# Ревьюер обязан смотреть тот же код, который уедет в dev (#257). Раньше
|
||||
# ревью шло по ветке как есть, а слияние делало ребейз — проверенный SHA и
|
||||
# слитый SHA были разными коммитами. Пока расхождение с dev текстовое,
|
||||
# ребейз упирается в конфликт и это видно; смысловое расхождение git
|
||||
# склеивает молча, и в dev уезжает комбинация, которую никто не читал.
|
||||
# Именно так пришёл регресс #234.
|
||||
#
|
||||
# Заодно снимается плата за конфликт: он обнаруживался ПОСЛЕ сорока минут
|
||||
# ревью и потраченных лимитов подписки, хотя виден за пять секунд до них.
|
||||
#
|
||||
# Этап spec не затрагивается: ветку ТЗ в dev никто не сливает, и трогать
|
||||
# чужую ветку без нужды — лишний риск.
|
||||
- name: Привести ветку к dev
|
||||
id: rebase
|
||||
if: needs.guard.outputs.stage == 'code' && steps.branch.outputs.name != ''
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
# rebase, в отличие от commit, не принимает -c user.*: он запускает
|
||||
# свои процессы и требует личность в окружении, иначе падает с
|
||||
# «unable to auto-detect email address».
|
||||
GIT_AUTHOR_NAME: claude[bot]
|
||||
GIT_AUTHOR_EMAIL: 209825114+claude[bot]@users.noreply.github.com
|
||||
GIT_COMMITTER_NAME: claude[bot]
|
||||
GIT_COMMITTER_EMAIL: 209825114+claude[bot]@users.noreply.github.com
|
||||
run: |
|
||||
git fetch -q origin dev
|
||||
if git merge-base --is-ancestor origin/dev HEAD; then
|
||||
echo "ветка содержит весь dev — ребейз не нужен"
|
||||
exit 0
|
||||
fi
|
||||
behind=$(git rev-list --count "HEAD..origin/dev")
|
||||
before=$(git rev-parse "origin/$BRANCH")
|
||||
echo "dev впереди на $behind коммит(ов) — привожу ветку"
|
||||
if ! git rebase origin/dev; then
|
||||
# Список снимается ДО 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 "::warning::ветка $BRANCH не ребейзится на dev без конфликта — ревью не запускается"
|
||||
printf 'конфликтуют:\n%s\n' "${files:-(git не назвал файлы)}"
|
||||
exit 0
|
||||
fi
|
||||
# --force-with-lease с явным ожидаемым значением обязателен: между
|
||||
# fetch и push автор мог запушить коммит, и слепой --force потерял бы
|
||||
# его молча. Расхождение lease — падение прогона, а не предупреждение:
|
||||
# ревью пошло бы по коду, которого на ветке уже нет.
|
||||
if ! git push -q --force-with-lease="refs/heads/$BRANCH:$before" \
|
||||
"https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:refs/heads/$BRANCH"; then
|
||||
echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора"
|
||||
exit 1
|
||||
fi
|
||||
# Локальная ссылка обновляется тоже: шаг слияния берёт origin/$BRANCH,
|
||||
# и без этого он ребейзил бы заново уже приведённое.
|
||||
git fetch -q origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"
|
||||
short_before=$(git rev-parse --short "$before")
|
||||
short_after=$(git rev-parse --short HEAD)
|
||||
echo "note=Ветка приведена к dev конвейером до ревью: поверх легло $behind коммит(ов) dev, $short_before -> $short_after. После ребейза это другой код (§7.2) — разбор полный, а не по дельте." >> "$GITHUB_OUTPUT"
|
||||
echo "ветка $BRANCH приведена к dev: $short_before -> $short_after"
|
||||
|
||||
# Материал ревью — конкретный SHA (#312). Вердикт применим только к
|
||||
# нему: если во время ревью в ветку прилетит коммит, шаг слияния обязан
|
||||
# это заметить и отказаться, а не молча увезти в dev непроверенный код.
|
||||
- name: Зафиксировать SHA материала ревью
|
||||
id: material
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "материал ревью: $(git rev-parse --short HEAD)"
|
||||
|
||||
# Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона
|
||||
# метка меняется всегда» при этом держится: возврат в S6-in-progress —
|
||||
# тоже смена метки, и автор не ждёт впустую.
|
||||
- name: Конфликт с dev — вернуть автору без ревью
|
||||
if: steps.rebase.outputs.conflict == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
CONFLICTS: ${{ steps.rebase.outputs.conflicts }}
|
||||
run: |
|
||||
cat > /tmp/stale.md <<EOF
|
||||
**Ревью не запускалось:** ветка \`$BRANCH\` не ребейзится на \`dev\` без конфликта. Код никто не читал, вердикта нет, цикл ревью не израсходован.
|
||||
|
||||
Конфликтуют:
|
||||
|
||||
\`\`\`
|
||||
$CONFLICTS
|
||||
\`\`\`
|
||||
|
||||
Проверка стоит до ревью намеренно: конфликт всё равно вернул бы задачу, но уже после сорока минут работы ревьюера и потраченных лимитов.
|
||||
|
||||
Задача переведена в \`S6-in-progress\`. Осталось:
|
||||
|
||||
1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;
|
||||
2. запушить ветку;
|
||||
3. вернуть метку \`S7-code-review\`.
|
||||
|
||||
[Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale.md
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label S6-in-progress --remove-label S7-code-review
|
||||
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
|
||||
|
||||
# Ревьюер перегонял 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: Установить зависимости
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
run: npm ci
|
||||
|
||||
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
|
||||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.pw.outputs.cache-hit != 'true'
|
||||
# Без --with-deps: системные библиотеки Chromium предустановлены в
|
||||
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
|
||||
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
|
||||
# библиотека когда-нибудь пропадёт из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
env:
|
||||
# Вне рабочей копии: восстановление дерева ревьюером не должно
|
||||
# уничтожать его собственный артефакт (#220).
|
||||
REVIEW_DOC: ${{ runner.temp }}/review-document.md
|
||||
with:
|
||||
# Подписка, а не отдельный счёт API: токен выпускается через
|
||||
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
Ты ревьюер проекта House Plan. Язык ответа — русский.
|
||||
|
||||
Issue: #${{ github.event.issue.number }}
|
||||
Репозиторий: ${{ github.repository }}
|
||||
Этап: ${{ needs.guard.outputs.stage }}
|
||||
spec — ревью ТЗ (PROCESS.md §2.4)
|
||||
code — код-ревью (PROCESS.md §2.7)
|
||||
Заход: r${{ needs.guard.outputs.cycle }} · блокирующих циклов израсходовано ${{ needs.guard.outputs.spent }} из ${{ needs.guard.outputs.limit }}
|
||||
Бюджет §4 тратят только жёлтые и красные вердикты: зелёный
|
||||
ничего не вернул на правки и цикла не образует (#227).
|
||||
Номер захода нужен для имени документа — два документа с
|
||||
одинаковым номером затёрли бы друг друга.
|
||||
|
||||
${{ steps.rebase.outputs.note }}
|
||||
|
||||
**Если цикл не первый — объём разбора по дельте, а не заново**
|
||||
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
|
||||
всех раундов, и повторный цикл заново выводил продуктовую рамку и
|
||||
перепроверял AC, которых правка не касалась: r2 по #150 стоил
|
||||
полного прогона ради одной строки в тестовой фикстуре.
|
||||
|
||||
Порядок для r2 и дальше:
|
||||
1. найди вердикт предыдущего раунда в комментариях issue и SHA,
|
||||
на котором он получен. SHA в вердикте не назван — это находка;
|
||||
2. объяви дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла
|
||||
ТЗ или тела issue для spec. Дельта — предмет этого раунда;
|
||||
3. по каждой находке предыдущего раунда покажи, чем именно она
|
||||
закрыта: строка кода или текста, а не заявление автора;
|
||||
4. заново проверяй только те AC, чьё доказательство дельта
|
||||
задевает. Остальные наследуй;
|
||||
5. в документе обязателен раздел «Унаследовано из r<N-1>»: что
|
||||
принято без повторной проверки, со ссылкой на документ того
|
||||
раунда и SHA, на котором вывод получен. Без этого перечня
|
||||
сокращение — молчаливое доверие, а такой тихий успех уже
|
||||
дважды стоил дня (#171, #207).
|
||||
|
||||
Разбор остаётся ПОЛНЫМ, если дельта не локальна: ребейз на ушедший
|
||||
вперёд dev (после ребейза это другой код, §7.2), смена контракта
|
||||
поведения, задета новая подсистема, либо объём дельты сопоставим с
|
||||
исходной задачей. Сомневаешься — разбирай полностью и скажи почему.
|
||||
|
||||
Сокращается объём РАЗБОРА, а не строгость: правка по замечанию
|
||||
способна сломать AC, который предыдущий раунд признал выполненным —
|
||||
так появилась регрессия #102. Поэтому граница не «только находки», а
|
||||
«находки плюс всё, до чего дотягивается дельта».
|
||||
|
||||
Прочитай в этом порядке, прежде чем судить:
|
||||
1. docs/SCOPE.md — зачем продукт существует и для кого. Он
|
||||
ограничитель: «features are built, improved and accepted only
|
||||
if they serve a job listed here». Первый вопрос к задаче —
|
||||
какую строку Core user jobs она закрывает.
|
||||
2. AGENTS.md и PROCESS.md — процесс, классы изменений, трейлеры,
|
||||
лимит циклов, формат вердикта.
|
||||
3. Тело issue #${{ github.event.issue.number }} и все комментарии.
|
||||
4. Если меняется видимое поведение — docs/USER-GUIDE.ru.md:
|
||||
терминология интерфейса берётся оттуда, а не изобретается.
|
||||
5. Канонический документ затронутой подсистемы: docs/SUN.md,
|
||||
LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md,
|
||||
CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md.
|
||||
|
||||
Для этапа spec: если issue помечен small, ТЗ живёт в теле issue и
|
||||
файла в docs/specs/ быть не должно. Иначе ТЗ — docs/specs/<NN>-*.md.
|
||||
Проверь обязательные разделы §7.1, однозначность каждого AC и
|
||||
указание способа доказательства. Отдельно проверь, что автор не
|
||||
выдал догадку за решение: утверждение о поведении, которого нет ни
|
||||
в одном документе и которое не помечено как предположение, —
|
||||
замечание. Не бывает сложной задачи без единого открытого вопроса.
|
||||
|
||||
Владельцу задаются только продуктовые вопросы: что человек видит или
|
||||
делает и каков объём видимых изменений в этом issue. Технический
|
||||
вопрос, вынесенный владельцу, — тоже замечание: ты его снимаешь и
|
||||
решаешь по существу в своём вердикте.
|
||||
|
||||
Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD`
|
||||
и `git diff origin/dev...HEAD`. Ручного тестирования в цикле нет,
|
||||
поэтому именно ты отвечаешь на вопрос «оно вообще работает».
|
||||
По каждому AC: либо он доказан автотестом и ты убедился, что тест
|
||||
умеет падать, либо разобран по коду с явной записью «проверено
|
||||
чтением, не исполнением». «Verified» без названной команды и её
|
||||
результата доказательством не является. Зависимости уже установлены
|
||||
workflow, Chromium тоже — `npm ci` выполнять не нужно. Проверь
|
||||
трейлеры Issue и User-Visible, при User-Visible: yes — правки в оба
|
||||
changelog в том же коммите.
|
||||
|
||||
**Объём гейтов соразмерен задаче.** Прогонять весь набор на каждой
|
||||
правке — не тщательность, а потеря времени: полные наборы это
|
||||
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
|
||||
|
||||
${{ steps.validated.outputs.note }}
|
||||
|
||||
Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые,
|
||||
и в повторном раунде тоже: код изменился, а стоят они минуты:
|
||||
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
|
||||
копий бандла. Плюс `node scripts/check-docs.mjs`, если diff трогает
|
||||
`src/**`: отпечаток скриншотов документации считается по всему
|
||||
`src/**`, поэтому любая правка фронтенда делает его устаревшим —
|
||||
выбирать тут нечего. Пропуск этого шага в #230 и #234 оставил `dev`
|
||||
с красным job `docs` до следующей задачи (#237).
|
||||
|
||||
Если diff трогает геометрию или ссылки на неё — рёбра комнат,
|
||||
записи толщины, `layout`, `marker.space`, `open_spans` — обязательны
|
||||
инварианты модели (#254): `npm test` уже гоняет их на всех моделях
|
||||
проекта, а на конкретной конфигурации они проверяются командой
|
||||
`npm run invariants -- --config <экспорт или ответ config/get>`.
|
||||
Три вопроса, на которые они отвечают, и все три уже стоили
|
||||
продукту дефектов: не исчезла ли запись толщины (#253), разрешима ли
|
||||
каждая ссылка (#244, #252) и равен ли ключ записи толщины ключу
|
||||
решёточного ребра (#258, #259). Последний сравнивает строки без
|
||||
допусков: сдвиг ключа на один шаг решётки равен допуску первых двух,
|
||||
поэтому они на нём промахиваются. Если задача меняет геометрию, а
|
||||
инварианты в отчёте не названы — это непрогнанный гейт, а не мелочь.
|
||||
|
||||
По необходимости, и «необходимость» определяется diff'ом и AC:
|
||||
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс те,
|
||||
что печатает `node scripts/smoke-select.mjs --base <base> --head <head>`.
|
||||
Сколько их всего — считает `ls demo/smoke_*.mjs | wc -l`; вшитое
|
||||
в этот текст число трижды расходилось с деревом, поэтому его
|
||||
здесь больше нет. Прогон всех уместен только когда задача
|
||||
действительно задевает всё. Выбирать по теме недостаточно: регресс #234 поймал
|
||||
`smoke_wall_junctions`, который по названию про стыки стен, а не
|
||||
про толщину отрезка. Инструмент печатает три вида ответа, и они
|
||||
разные: «прямое совпадение» — смок называет изменённый символ,
|
||||
«зарегистрированная связь» — смок проверяет следствие контракта,
|
||||
не называя его, «НЕОПРЕДЕЛЁННОСТЬ» — связь не доказана, и это не
|
||||
разрешение ничего не прогонять. Вывод инструмента прикладывается
|
||||
к комментарию ревью вместе с решением по каждой строке: прогнал
|
||||
либо не прогнал и почему. Слабые связи (одно распространённое
|
||||
имя) — повод посмотреть, а не обязанность прогонять;
|
||||
- `npm run golden:verify` — если diff может изменить видимый
|
||||
результат: рендер, геометрия, стили, слои;
|
||||
- `python -m pytest tests_backend -q` — если тронут
|
||||
`custom_components/**/*.py`;
|
||||
- performance-профили — если названы в AC либо тронуты
|
||||
чувствительные к перфу пути.
|
||||
|
||||
**Одно число — один источник.** Если дифф добавляет или меняет
|
||||
величину, видимую пользователю, назови в отчёте прямо: какое число
|
||||
видно дважды (превью против записи, подпись против площади,
|
||||
подсветка инструмента против сохранённого значения) и один ли у него
|
||||
источник. Три дефекта подряд имели именно эту причину — #234, #233 и
|
||||
способ, которым #234 обнаружили. Механическая часть закреплена
|
||||
тестом `test/single-source-numbers.test.mjs`, смысловая — твоя.
|
||||
|
||||
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
|
||||
тем тестам, которые ты прогонял.
|
||||
|
||||
**В комментарии обязателен перечень: какие гейты прогнал, какие нет и
|
||||
почему.** Это условие честности такого сужения: непрогнанный гейт
|
||||
становится видимым решением, а не молчаливым пропуском. Раздел «чего
|
||||
не проверял» в документе ревью — не формальность, а главный его
|
||||
раздел на коротких задачах.
|
||||
|
||||
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
|
||||
|
||||
Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней
|
||||
же — без High это жёлтый вердикт и возврат автору, отдельный issue
|
||||
НЕ заводится (решение владельца 2026-08-19, #202: заведение и
|
||||
обслуживание issue дороже правки на месте); Low либо правится,
|
||||
либо снимается с записью. Жёлтый вердикт допустим и при полностью
|
||||
выполненных AC, если изменение не решает заявленный сценарий или
|
||||
ухудшает смежный. Продуктовое рассуждение расширяет вопросы, но не
|
||||
отменяет AC и не даёт права менять скоуп.
|
||||
|
||||
Только Medium-находку ВНЕ скоупа задачи (попутный дефект соседнего
|
||||
поведения, который в этой ветке чинить нельзя) заведи отдельным
|
||||
issue со ссылкой на #${{ github.event.issue.number }} и метками:
|
||||
тип, приоритет, S1-new. «Оставили в тексте ревью» закрытием не
|
||||
считается и прямо запрещено §12.
|
||||
|
||||
Напиши полный документ ревью в файл, путь которого лежит в
|
||||
переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория).
|
||||
|
||||
Почему не в docs/reviews: документ там был некоммитнутым файлом того
|
||||
же дерева, которое ты мутируешь, проверяя «умеет ли тест падать». На
|
||||
#220 три раунда подряд документ исчезал — восстановление дерева
|
||||
(`git checkout -- .`, `git clean -fd`) сносит собственный артефакт
|
||||
ревью, потому что он untracked. В репозиторий его положит шаг
|
||||
публикации, взяв из REVIEW_DOC; тебе трогать docs/reviews не нужно.
|
||||
|
||||
В самом репозитории не создавай файлов вообще: любые изменения в
|
||||
рабочей копии будут отброшены. Имя документа в docs/reviews шаг
|
||||
публикации соберёт сам — SPEC-REVIEW для этапа spec, CODE-REVIEW для
|
||||
code, с номером issue и заходом.
|
||||
|
||||
Содержание документа: скоуп, как проверялось, находки с
|
||||
воспроизведением, что проверено и корректно, чего не проверял. Для
|
||||
r2 и дальше добавь два раздела: «Закрытие раунда r<N-1>» — таблица
|
||||
«находка | чем закрыта | где это видно», и «Унаследовано из r<N-1>» —
|
||||
что принято без повторной проверки, с документом и SHA.
|
||||
|
||||
Затем оставь в issue краткий комментарий: вердикт, ключевые находки
|
||||
и ссылка на документ. Первой строкой — вердикт в формате §7.2:
|
||||
`Вердикт: зелёный/жёлтый/красный · заход r${{ needs.guard.outputs.cycle }} · блокирующих циклов ${{ needs.guard.outputs.spent }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…`
|
||||
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору жёлтым)
|
||||
|
||||
Затем верни JSON по схеме. Это последнее действие и оно обязательно:
|
||||
без него метка не переставится и конвейер встанет.
|
||||
claude_args: |
|
||||
--max-turns 150
|
||||
--allowedTools Read,Write,Grep,Glob,Bash,mcp__github__add_issue_comment,mcp__github__issue_write,mcp__github__issue_read
|
||||
--json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"}},"required":["verdict","high","medium","summary"]}'
|
||||
|
||||
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
|
||||
# решает этот шаг, а не модель: всё остальное откатывается.
|
||||
- name: Опубликовать документ ревью
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
SOURCE: ${{ runner.temp }}/review-document.md
|
||||
MATERIAL_SHA: ${{ steps.branch.outputs.sha }}
|
||||
MATERIAL_TREE: ${{ steps.branch.outputs.tree }}
|
||||
MATERIAL_SPECS: ${{ steps.branch.outputs.specs }}
|
||||
run: |
|
||||
# Ветки задачи может не быть: у задач, размеченных до появления
|
||||
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
|
||||
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
|
||||
# комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что
|
||||
# ничего не сделал. Документ ложится туда же, где лежит само ТЗ.
|
||||
target="${BRANCH:-dev}"
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::warning::ветки задачи нет — документ ревью ляжет в dev"
|
||||
fi
|
||||
marker=CODE-REVIEW
|
||||
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
|
||||
doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
|
||||
# Документ спасается ПЕРВЫМ делом. Ревьюер мог написать его по старому
|
||||
# пути прямо в рабочую копию, а дальше эта копия будет отброшена
|
||||
# целиком — и вместе с ней пропал бы артефакт (#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
|
||||
# Документ приезжает извне репозитория (#220). Три раунда подряд он
|
||||
# терялся, пока лежал некоммитнутым файлом в том же дереве, которое
|
||||
# ревьюер мутирует и затем восстанавливает: `git checkout -- .` плюс
|
||||
# `git clean -fd` сносят собственный артефакт ревью, потому что он
|
||||
# untracked. Теперь его место — RUNNER_TEMP, и уборка дерева ему не
|
||||
# страшна.
|
||||
if [ -f "$SOURCE" ]; then
|
||||
mkdir -p docs/reviews
|
||||
cp "$SOURCE" "$doc"
|
||||
echo "документ взят из $SOURCE ($(wc -c < "$doc") байт)"
|
||||
# Якоря дописывает конвейер, а не ревьюер (#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 не найден — документа для публикации нет"
|
||||
fi
|
||||
# Индексируется ровно один путь, а не каталог: `git add docs/reviews`
|
||||
# забрал бы всё, что там окажется, а после reset там не должно быть
|
||||
# ничего постороннего — но полагаться на «не должно» здесь нельзя.
|
||||
git add -- "$doc" 2>/dev/null || true
|
||||
if git diff --cached --quiet; then
|
||||
# Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит
|
||||
# документ сам, своим app-токеном мимо этого шага (CODE-REVIEW-150-r1,
|
||||
# коммиттер GitHub). Провал — когда файла нет и на ветке.
|
||||
git fetch -q origin "$target"
|
||||
if git cat-file -e "origin/$target:$doc" 2>/dev/null; then
|
||||
echo "документ уже опубликован ревьюером: $doc"
|
||||
exit 0
|
||||
fi
|
||||
# Ревью без артефакта запрещено (PROCESS.md §2.4/§10.4/§12). Раньше
|
||||
# здесь стоял warning с exit 0: на #150 оба вердикта ревью ТЗ
|
||||
# остались только комментариями, метки переставились, и пропажу
|
||||
# заметило лишь следующее ревью — issue #171. Падение ДО шага с
|
||||
# меткой сохраняет инвариант «метка не сменилась = прогон упал».
|
||||
echo "::error::вердикт есть, а документа нет: ни $SOURCE, ни $doc в рабочей копии, ни $doc в $target — ревью без артефакта (#171, #220)"
|
||||
exit 1
|
||||
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
|
||||
docs: review document for #$NUM
|
||||
|
||||
Issue: #$NUM
|
||||
User-Visible: no
|
||||
EOF
|
||||
# Второй рубеж, и он главный: что пуш ДОБАВИТ в целевую ветку. Первый
|
||||
# судит намерение шага, этот — результат, а расходились они именно
|
||||
# тогда, когда база оказывалась не той.
|
||||
git diff --name-only "origin/$target...HEAD" | node scripts/review-doc-guard.mjs
|
||||
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
|
||||
# либо dev, поэтому push нужен с явным перебазированием при гонке:
|
||||
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
|
||||
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$target"; then
|
||||
git fetch -q origin "$target"
|
||||
if ! git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
rebase "origin/$target"; then
|
||||
git rebase --abort || true
|
||||
# Тоже вердикт без артефакта: раньше exit 0 переставил бы метку.
|
||||
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
|
||||
# Постусловие: до ветки дошёл именно ожидаемый файл. Коммит с
|
||||
# документом, названным не по формату, — тот же вердикт без
|
||||
# артефакта, только дороже в обнаружении.
|
||||
git fetch -q origin "$target"
|
||||
if ! git cat-file -e "origin/$target:$doc" 2>/dev/null; then
|
||||
echo "::error::коммит в $target опубликован, но ожидаемого $doc в нём нет — файл назван не по формату (#171)"
|
||||
exit 1
|
||||
fi
|
||||
echo "документ опубликован в $target: $doc"
|
||||
|
||||
# Материал раунда обязан быть достижим с 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'
|
||||
env:
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
run: |
|
||||
verdict=$(echo "$OUT" | jq -r '.verdict')
|
||||
high=$(echo "$OUT" | jq -r '.high')
|
||||
echo "вердикт: $verdict, High: $high"
|
||||
|
||||
# Вперёд двигает ТОЛЬКО зелёный. Жёлтый и красный возвращают
|
||||
# автору: на прогоне #111 жёлтый означал, что AC описывает неверное
|
||||
# изменение контракта — реализовать такое ТЗ значит сделать ошибку
|
||||
# по инструкции. Оба считаются циклом.
|
||||
if [ "$verdict" = "green" ] && [ "$high" -eq 0 ]; then
|
||||
green=true
|
||||
case "$STAGE" in
|
||||
spec) from=S4-spec-review; to=S5-ready ;;
|
||||
code) from=S7-code-review; to=S8-merged ;;
|
||||
esac
|
||||
else
|
||||
green=false
|
||||
case "$STAGE" in
|
||||
spec) from=S4-spec-review; to=S3-spec ;;
|
||||
code) from=S7-code-review; to=S6-in-progress ;;
|
||||
esac
|
||||
fi
|
||||
echo "green=$green" >> "$GITHUB_OUTPUT"
|
||||
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. Значит слияние обязано произойти
|
||||
# ДО метки, иначе она врёт в промежутке.
|
||||
#
|
||||
# При конфликте шаг НЕ падает и метку не оставляет на месте. Первая
|
||||
# редакция делала именно так, и это оказалось тупиком: автор ждёт смену
|
||||
# метки, метка не менялась, и он тридцать раз опрашивал впустую, чтобы
|
||||
# затем отчитаться «лимит исчерпан» — при зелёном вердикте. Инвариант
|
||||
# теперь жёстче: ПОСЛЕ ПРОГОНА РЕВЬЮ МЕТКА МЕНЯЕТСЯ ВСЕГДА.
|
||||
- name: Слить ветку в dev
|
||||
id: merge
|
||||
if: needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
|
||||
run: |
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::ветки задачи нет — сливать нечего"
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
git fetch -q origin dev "$BRANCH"
|
||||
# #312: сливается только проверенный код. Допустимые вершины ветки:
|
||||
# сам SHA материала либо он же плюс ровно один коммит публикации
|
||||
# документа ревью (дифф только docs/reviews/). Любой другой коммит —
|
||||
# ветка уехала после ревью, вердикт к ней не применим: возврат в
|
||||
# S6-in-progress через merged=false, как при конфликте.
|
||||
actual=$(git rev-parse "origin/$BRANCH")
|
||||
reviewed="$MATERIAL_SHA"
|
||||
fresh=false
|
||||
if [ "$actual" = "$reviewed" ]; then
|
||||
fresh=true
|
||||
elif [ "$(git rev-parse "$actual^" 2>/dev/null)" = "$reviewed" ] \
|
||||
&& [ -z "$(git diff --name-only "$reviewed" "$actual" -- . ':!docs/reviews')" ]; then
|
||||
fresh=true
|
||||
fi
|
||||
if [ "$fresh" != true ]; then
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ветка $BRANCH уехала после проверенного SHA $reviewed (сейчас $actual) — слияние отменено (#312)"
|
||||
cat > /tmp/stale-verdict.md <<EOF
|
||||
**Слияние отменено: ветка изменилась после проверенного материала (#312).**
|
||||
|
||||
Ревью выполнялось на \\`$(git rev-parse --short "$reviewed")\\`, а вершина ветки сейчас \\`$(git rev-parse --short "$actual")\\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.
|
||||
|
||||
Задача переведена в \\`S6-in-progress\\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \\`S7-code-review\\` — новый заход ревью проверит актуальный код.
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale-verdict.md
|
||||
exit 0
|
||||
fi
|
||||
git checkout -q -B merge-into-dev "$actual"
|
||||
if ! git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
rebase origin/dev; then
|
||||
git rebase --abort || true
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ветка $BRANCH не сливается в dev без конфликта"
|
||||
cat > /tmp/conflict.md <<EOF
|
||||
**Код-ревью зелёное — вердикт выше в силе, переделывать работу не нужно.** Не удалось только слияние: ветка \`$BRANCH\` конфликтует с \`dev\`.
|
||||
|
||||
Задача переведена в \`S6-in-progress\`, потому что работа вернулась к автору. Осталась не правка кода, а ребейз:
|
||||
|
||||
1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;
|
||||
2. запушить ветку;
|
||||
3. вернуть метку \`S7-code-review\`.
|
||||
|
||||
Повторный прогон ревью — не формальность: после ребейза на новый \`dev\` это другой код, и принимать его без проверки нельзя. Цикл считается по этапу, лимит на код-ревью тратится отдельно от ревью ТЗ.
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/conflict.md
|
||||
exit 0
|
||||
fi
|
||||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev
|
||||
echo "merged=true" >> "$GITHUB_OUTPUT"
|
||||
echo "слито в dev: $(git rev-parse --short HEAD)"
|
||||
|
||||
- name: Переставить метку
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
FROM: ${{ steps.decide.outputs.from }}
|
||||
# Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к
|
||||
# автору: метка утверждала бы, что код в dev, а его там нет.
|
||||
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to }}
|
||||
run: |
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label "$TO" --remove-label "$FROM"
|
||||
echo "$FROM -> $TO"
|
||||
|
||||
- name: Позвать владельца, если ревью упало
|
||||
if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
# Тело через heredoc, а не многострочный --body: строка с нулевым
|
||||
# отступом обрывает блок YAML и оставляет незакрытую кавычку.
|
||||
cat > /tmp/failure.md <<EOF
|
||||
Автоматическое ревью не отработало: [прогон]($RUN_URL). Статусная метка не менялась, задача осталась на месте.
|
||||
|
||||
Если вердикт выше всё же опубликован — сбой произошёл после него. Перестановку метки в этом случае выполняет чат обслуживания или владелец, но не автор задачи: автор не толкует вердикт о своей же работе.
|
||||
EOF
|
||||
gh issue comment "${{ github.event.issue.number }}" \
|
||||
--repo "${{ github.repository }}" --body-file /tmp/failure.md
|
||||
@@ -1,258 +0,0 @@
|
||||
name: Публикация пре-релиза (ручная)
|
||||
run-name: Publish ${{ inputs.tag }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Exact prerelease tag, for example v1.61.0-beta.4"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: publish-prerelease-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: "Гейт: зелёная Проверка и релизный контракт"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.candidate.outputs.sha }}
|
||||
tag: ${{ steps.candidate.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Pin the current dev candidate
|
||||
id: candidate
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$REF_NAME" = "dev" || {
|
||||
echo "::error::Prereleases must be dispatched from the dev branch, got $REF_NAME"
|
||||
exit 1
|
||||
}
|
||||
SHA=$(git rev-parse HEAD)
|
||||
git fetch origin dev
|
||||
test "$(git rev-parse origin/dev)" = "$SHA" || {
|
||||
echo "::error::The dispatched SHA is no longer the origin/dev tip"
|
||||
exit 1
|
||||
}
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
- name: Verify version, changelogs and bilingual release notes
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
|
||||
- name: Require green Validate for this exact SHA
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ steps.candidate.outputs.sha }}
|
||||
run: node scripts/release-gate.mjs "$SHA"
|
||||
|
||||
publish:
|
||||
name: Публикация тега и релиза
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
url: ${{ steps.verify.outputs.url }}
|
||||
newly_published: ${{ steps.release.outputs.newly_published }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.gate.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Build and verify both release assets before publication
|
||||
env:
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
npm run build
|
||||
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
|
||||
npm run bundle:budget
|
||||
VERSION=${TAG#v}
|
||||
grep -RFq "$VERSION" dist
|
||||
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
|
||||
node scripts/verify-houseplan-zip.mjs houseplan.zip \
|
||||
custom_components/houseplan/frontend "$VERSION"
|
||||
test -s dist/houseplan-card.js
|
||||
test -s houseplan.zip
|
||||
- name: Create or verify the annotated tag
|
||||
env:
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
SHA: ${{ needs.gate.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}")
|
||||
if [ -n "$REMOTE" ]; then
|
||||
PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}')
|
||||
test -n "$PEELED" || {
|
||||
echo "::error::Existing remote tag $TAG is not annotated"
|
||||
exit 1
|
||||
}
|
||||
test "$PEELED" = "$SHA" || {
|
||||
echo "::error::Existing tag $TAG points to $PEELED, expected $SHA"
|
||||
exit 1
|
||||
}
|
||||
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
|
||||
test "$(git cat-file -t "refs/tags/$TAG")" = "tag"
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$TAG" "$SHA" -m "$TAG"
|
||||
git push origin "$TAG"
|
||||
fi
|
||||
- name: Stage, verify and publish the prerelease
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \
|
||||
--draft --prerelease --title "$TAG" --notes-file docs/RELEASE-NOTES.md
|
||||
fi
|
||||
WAS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)
|
||||
echo "newly_published=$WAS_DRAFT" >> "$GITHUB_OUTPUT"
|
||||
gh release upload "$TAG" dist/houseplan-card.js houseplan.zip \
|
||||
--repo "$GITHUB_REPOSITORY" --clobber
|
||||
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,isDraft,isPrerelease,assets,url)
|
||||
export RELEASE_JSON TAG
|
||||
node <<'NODE'
|
||||
const release = JSON.parse(process.env.RELEASE_JSON);
|
||||
if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch');
|
||||
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
|
||||
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
|
||||
}
|
||||
NODE
|
||||
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease \
|
||||
--title "$TAG" --notes-file docs/RELEASE-NOTES.md
|
||||
- name: Verify the public release and assets
|
||||
id: verify
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
SHA: ${{ needs.gate.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,isDraft,isPrerelease,assets,url)
|
||||
export RELEASE_JSON TAG
|
||||
node <<'NODE'
|
||||
const release = JSON.parse(process.env.RELEASE_JSON);
|
||||
if (release.tagName !== process.env.TAG || release.isDraft || !release.isPrerelease)
|
||||
throw new Error('release is not a public prerelease for the requested tag');
|
||||
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
|
||||
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
|
||||
}
|
||||
NODE
|
||||
test "$(git rev-list -n 1 "$TAG")" = "$SHA"
|
||||
URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url")
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n- assets: `houseplan-card.js`, `houseplan.zip`\n' \
|
||||
"$TAG" "$SHA" "$URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Verify HACS prerelease discovery order
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
EXPECTED_TAG: ${{ needs.gate.outputs.tag }}
|
||||
with:
|
||||
script: |
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const first = releases.find((release) => release.prerelease && !release.draft);
|
||||
if (first?.tag_name !== process.env.EXPECTED_TAG) {
|
||||
core.setFailed(
|
||||
`HACS prerelease discovery is stale: ${first?.tag_name ?? 'none'} precedes ` +
|
||||
process.env.EXPECTED_TAG,
|
||||
);
|
||||
}
|
||||
|
||||
# PROCESS.md 10.2 item 10: closing issues and stripping status labels happens
|
||||
# because a beta was published, not because someone remembered to do it. The
|
||||
# manual step was skipped twice, and both times it broke the invariant that a
|
||||
# closed issue carries no status label — the one thing `verify` relies on.
|
||||
#
|
||||
# A manual step after a successful release is the worst kind: by the time it is
|
||||
# due, the work already looks finished, which is exactly why it gets forgotten.
|
||||
close-merged:
|
||||
name: Закрытие вошедших issue
|
||||
needs: [gate, publish]
|
||||
if: ${{ needs.publish.outputs.newly_published == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# Deliberately the stock token, not a PAT: events caused by GITHUB_TOKEN do
|
||||
# not start workflows, so removing the label cannot wake the review
|
||||
# pipeline. A PAT here would build a cascade out of a bookkeeping step.
|
||||
issues: write
|
||||
steps:
|
||||
- name: Close the S8-merged queue and strip status labels
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
URL: ${{ needs.publish.outputs.url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Only the owner's issues take part in the process; issues filed by
|
||||
# anyone else never carry status labels and are not ours to close.
|
||||
numbers=$(gh issue list --repo "$REPO" --state open --label S8-merged \
|
||||
--author Matysh --limit 100 --json number --jq '.[].number')
|
||||
if [ -z "$numbers" ]; then
|
||||
echo "the S8-merged queue is empty, nothing to close"
|
||||
else
|
||||
for n in $numbers; do
|
||||
gh issue comment "$n" --repo "$REPO" \
|
||||
--body "Выпущено в \`$TAG\` · [релиз]($URL)"
|
||||
# Label first, then close. If the run dies between the two steps an
|
||||
# open issue without a status is visible and fixable in the flow;
|
||||
# the reverse order would recreate the exact breakage this job is
|
||||
# here to prevent.
|
||||
gh issue edit "$n" --repo "$REPO" --remove-label S8-merged
|
||||
gh issue close "$n" --repo "$REPO" --reason completed
|
||||
echo "closed #$n"
|
||||
done
|
||||
fi
|
||||
# Targeted at the defect that actually recurs, not at the invariant in
|
||||
# general: no closed issue may still carry S8-merged.
|
||||
leftover=$(gh issue list --repo "$REPO" --state closed --label S8-merged \
|
||||
--limit 100 --json number --jq 'length')
|
||||
test "$leftover" = "0" || {
|
||||
echo "::error::$leftover closed issues still carry S8-merged"
|
||||
exit 1
|
||||
}
|
||||
|
||||
announce:
|
||||
name: Комментарий о публикации
|
||||
needs: [gate, publish]
|
||||
if: ${{ needs.publish.outputs.newly_published == 'true' }}
|
||||
uses: ./.github/workflows/announce.yml
|
||||
with:
|
||||
reusable: true
|
||||
tag: ${{ needs.gate.outputs.tag }}
|
||||
release_name: ${{ needs.gate.outputs.tag }}
|
||||
url: ${{ needs.publish.outputs.url }}
|
||||
prerelease: true
|
||||
ref: ${{ needs.gate.outputs.tag }}
|
||||
secrets: inherit
|
||||
@@ -1,41 +0,0 @@
|
||||
name: HACS-zip к релизу
|
||||
# hacs.json declares zip_release + filename=houseplan.zip, so every release
|
||||
# (prereleases included) must carry the asset — HACS installs from it and
|
||||
# GitHub's public download counter becomes a free per-version install metric
|
||||
# (owner request, 2026-08-08). Like announce.yml, the workflow file lives at
|
||||
# the TAGGED commit: betas cut from dev pick it up as soon as this file is on
|
||||
# dev, stable tags once it reaches main.
|
||||
# workflow_dispatch lets us attach the zip to an EXISTING release (needed
|
||||
# once for the latest stable after the hacs.json change reaches main).
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing release tag to attach the zip to"
|
||||
required: true
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
zip:
|
||||
name: Собрать houseplan.zip и приложить к релизу
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
env:
|
||||
EVENT_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||
run: echo "tag=${EVENT_TAG:-$INPUT_TAG}" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ steps.tag.outputs.tag }}
|
||||
- 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
|
||||
- name: Upload asset
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "${{ steps.tag.outputs.tag }}" houseplan.zip --clobber --repo "$GITHUB_REPOSITORY"
|
||||
@@ -1,102 +0,0 @@
|
||||
name: "Релиз: ассеты после зелёной проверки"
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
jobs:
|
||||
# AUD-159B7-02: publishing a GitHub Release used to BE the gate — this
|
||||
# workflow only built and uploaded, so an asset shipped while both Validate
|
||||
# runs for the very same commit were red. The asset now waits for a green
|
||||
# Validate of the EXACT commit the tag points at, and is withheld otherwise.
|
||||
#
|
||||
# Needs a push with a token that has the `workflow` scope (the ordinary
|
||||
# Personal Access Token used for `git push` refuses workflow file updates).
|
||||
gate:
|
||||
name: "Гейт: зелёная Проверка точного SHA тега"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Require a green Validate for this exact commit
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# HEAD is the peeled commit even when TAG is annotated. Do not trust
|
||||
# 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"
|
||||
node scripts/release-gate.mjs "$SHA"
|
||||
- name: Require full performance for a stable release
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA=$(git rev-parse HEAD)
|
||||
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Полные бенчмарки производительности"
|
||||
build:
|
||||
name: Сборка бандла и загрузка ассетов
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- run: npm ci && npm run build
|
||||
- name: Verify compositor frame continuity for a stable release
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
run: |
|
||||
npx playwright install --with-deps chromium
|
||||
node scripts/bundle-sync.mjs
|
||||
npm run continuity:screencast
|
||||
- name: Upload failed continuity frames
|
||||
if: ${{ failure() && !github.event.release.prerelease }}
|
||||
uses: actions/upload-artifact@v7
|
||||
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
|
||||
- name: Attach card to release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: dist/houseplan-card.js
|
||||
hacs-discovery:
|
||||
name: HACS-видимость пре-релиза (порядок бет)
|
||||
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
|
||||
# sorting SemVer. A valid asset can therefore be invisible to beta users
|
||||
# (beta.10 appeared after beta.9). Keep the release asset, but
|
||||
# make that distribution failure impossible to miss in the release run.
|
||||
if: ${{ github.event.release.prerelease }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Verify the published tag is the prerelease HACS will discover
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const first = releases.find((r) => r.prerelease && !r.draft);
|
||||
const expected = context.payload.release.tag_name;
|
||||
if (first?.tag_name !== expected) {
|
||||
core.setFailed(
|
||||
`HACS prerelease discovery is stale: GitHub returns ${first?.tag_name ?? 'none'} before ${expected}. ` +
|
||||
`Use an rc/new version line or correct the release ordering before announcing the update.`,
|
||||
);
|
||||
}
|
||||
@@ -1,865 +0,0 @@
|
||||
name: Проверка (CI)
|
||||
|
||||
on:
|
||||
push:
|
||||
# The branch commit is the release-gate authority. An annotated tag points
|
||||
# to the same SHA and must not duplicate the browser validation jobs.
|
||||
branches:
|
||||
- '**'
|
||||
# Документы ревью конвейер пишет пачками — 340 коммитов за месяц, и каждый
|
||||
# гонял лёгкую половину Validate впустую (≈15 часов раннера в месяц).
|
||||
# Релизного кандидата это не затрагивает: тег всегда стоит на коммите,
|
||||
# который меняет версию и бандл, а не только `docs/reviews/**`.
|
||||
paths-ignore:
|
||||
- 'docs/reviews/**'
|
||||
pull_request:
|
||||
|
||||
# A new push supersedes an unfinished validation for the same branch or PR.
|
||||
# Exact-SHA release gates never depend on an obsolete commit.
|
||||
concurrency:
|
||||
group: validate-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Четыре секундных скрипта жили четырьмя job, каждая со своим checkout и
|
||||
# setup-node ради нескольких секунд работы (#336). Здесь они шаги одной job.
|
||||
#
|
||||
# Независимость при этом сохранена намеренно: у каждого шага
|
||||
# `continue-on-error`, поэтому упавший провенанс не прячет результат
|
||||
# процессного гейта, а вердикт в конце падает, если упал хоть один. Прежняя
|
||||
# запись «job независимый: краснеет сам и не роняет остальные» продолжает
|
||||
# действовать — на уровне шагов, с той же гранулярностью в логе.
|
||||
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' }
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
|
||||
- name: "Документация: гайды, ченджлоги, скриншот-индекс"
|
||||
id: docs
|
||||
continue-on-error: true
|
||||
run: node scripts/check-docs.mjs --external
|
||||
|
||||
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
|
||||
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
|
||||
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
|
||||
- name: "Процесс: process.yml идентичен в main и dev"
|
||||
id: workflow_sync
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git fetch --quiet origin main dev
|
||||
if diff <(git show origin/main:.github/workflows/process.yml) \
|
||||
<(git show origin/dev:.github/workflows/process.yml); then
|
||||
echo "main и dev идентичны"
|
||||
else
|
||||
echo "РАСХОЖДЕНИЕ: process.yml в main и dev различаются."
|
||||
echo "Конвейер исполняет версию из ветки по умолчанию, поэтому"
|
||||
echo "правку нужно отправить в обе ветки."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до
|
||||
# #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 }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
DEVELOPMENT_BRANCH: dev
|
||||
run: |
|
||||
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
|
||||
node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range
|
||||
|
||||
# Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на
|
||||
# машине автора, но их можно обойти `--no-verify`, а коммиты идут прямо в
|
||||
# dev без PR — GitHub на своей стороне не блокирует ничего. Это последнее
|
||||
# место, где нарушение правила №1 ловится машиной. Откат — удалить шаг,
|
||||
# скрипт остаётся рабочим.
|
||||
- name: "Процессный гейт: диапазон, трейлеры, статусы issue"
|
||||
id: process_gate
|
||||
continue-on-error: true
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ steps.range.outputs.base || github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
DEVELOPMENT_BRANCH: dev
|
||||
TARGET_REF: ${{ github.ref }}
|
||||
# Публичный репозиторий: штатного токена хватает на чтение issue.
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
|
||||
node scripts/process-gate.mjs --github-range --issues
|
||||
|
||||
# Вердикт обязан быть громким и перечислять всё упавшее сразу: иначе
|
||||
# слияние job стоило бы автору лишнего круга «починил одно — узнал о
|
||||
# втором».
|
||||
- name: Вердикт предполётных проверок
|
||||
env:
|
||||
DOCS: ${{ steps.docs.outcome }}
|
||||
WORKFLOW_SYNC: ${{ steps.workflow_sync.outcome }}
|
||||
PROVENANCE: ${{ steps.provenance.outcome }}
|
||||
PROCESS_GATE: ${{ steps.process_gate.outcome }}
|
||||
run: |
|
||||
fail=0
|
||||
check() {
|
||||
if [ "$2" = "success" ]; then
|
||||
echo "ok $1"
|
||||
else
|
||||
echo "FAIL $1 ($2)"
|
||||
echo "- **$1**: $2" >> "$GITHUB_STEP_SUMMARY"
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY"
|
||||
check "документация" "$DOCS"
|
||||
check "process.yml в main и dev" "$WORKFLOW_SYNC"
|
||||
check "провенанс коммитов" "$PROVENANCE"
|
||||
check "процессный гейт" "$PROCESS_GATE"
|
||||
exit $fail
|
||||
|
||||
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
|
||||
# относящееся к ним. НА DEV ФИЛЬТРОВ НЕТ: гейт беты принимает «зелёный Validate
|
||||
# на точном SHA», и если объём прогона зависит от diff, «зелёный» перестаёт
|
||||
# значить одно и то же — кандидат релиза (манифесты + changelog) пропустил бы
|
||||
# браузерные тесты, а прогон с пропущенными job всё равно success. Фильтры
|
||||
# экономят на ветках задач, где Validate — ранний сигнал: настоящую приёмку
|
||||
# там делает код-ревью, которое гоняет гейты само (#127).
|
||||
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 }}
|
||||
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) это дало зелёный статус ветки
|
||||
# без единого исполненного тяжёлого гейта.
|
||||
- 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
|
||||
- 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"
|
||||
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"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Диапазон считается от последнего ДОКАЗАННО зелёного предка (#387),
|
||||
# а не от головы предыдущего пуша: его прогон штатно отменяется
|
||||
# следующим пушем. PR — исключение: там база задана событием.
|
||||
base="$GREEN_BASE"
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||||
if [ -z "$base" ]; then
|
||||
echo "база не определена — полный прогон без классификации"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
files=$(git diff --name-only "$base" "$HEAD_SHA")
|
||||
printf '%s\n' "$files" | head -50
|
||||
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
|
||||
{
|
||||
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
|
||||
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|scripts/support-relay/|pytest\.ini$)')"
|
||||
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
|
||||
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
|
||||
# сборки) ПЛЮС оснастка именно этой job. Маркер в кэше пишет только успешный
|
||||
# прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми
|
||||
# же входами уже завершилась успешно.
|
||||
#
|
||||
# Это НЕ фильтр путей из job `changes` (на dev они отключены намеренно): там
|
||||
# объём прогона угадывается по путям и «зелёный» начинает значить разное,
|
||||
# здесь эквивалентность входов доказана хешем.
|
||||
#
|
||||
# Свойство, снимающее главный риск: релизный кандидат бампает версию, а
|
||||
# CARD_VERSION и package.json входят в фингерпринт, поэтому ключи кандидата
|
||||
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
|
||||
reuse:
|
||||
name: "Переиспользование: это дерево уже проверено"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
smoke: ${{ steps.probe.outputs.smoke }}
|
||||
golden: ${{ steps.probe.outputs.golden }}
|
||||
performance_smoke: ${{ steps.probe.outputs.performance_smoke }}
|
||||
backend: ${{ steps.probe.outputs.backend }}
|
||||
smoke_key: ${{ steps.keys.outputs.smoke }}
|
||||
golden_key: ${{ steps.keys.outputs.golden }}
|
||||
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
|
||||
backend_key: ${{ steps.keys.outputs.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Ключи переиспользования
|
||||
id: keys
|
||||
run: |
|
||||
for job in smoke golden performance_smoke backend; do
|
||||
key=$(node scripts/gate-reuse.mjs --job="$job")
|
||||
echo "$job=$key" >> "$GITHUB_OUTPUT"
|
||||
echo "$job: $key"
|
||||
done
|
||||
# lookup-only: маркер только проверяется, но не восстанавливается —
|
||||
# сохранять его в этой job нечего, она ничего не прогоняла.
|
||||
- name: Маркер smoke
|
||||
id: m_smoke
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-smoke-${{ steps.keys.outputs.smoke }}
|
||||
lookup-only: true
|
||||
- name: Маркер golden
|
||||
id: m_golden
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-golden-${{ steps.keys.outputs.golden }}
|
||||
lookup-only: true
|
||||
- name: Маркер performance_smoke
|
||||
id: m_perf
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
|
||||
lookup-only: true
|
||||
- name: Маркер backend
|
||||
id: m_backend
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-backend-${{ steps.keys.outputs.backend }}
|
||||
lookup-only: true
|
||||
- name: Что переиспользуем
|
||||
id: probe
|
||||
env:
|
||||
SMOKE: ${{ steps.m_smoke.outputs.cache-hit }}
|
||||
GOLDEN: ${{ steps.m_golden.outputs.cache-hit }}
|
||||
PERF: ${{ steps.m_perf.outputs.cache-hit }}
|
||||
BACKEND: ${{ steps.m_backend.outputs.cache-hit }}
|
||||
run: |
|
||||
# Пропуск обязан быть громким: молчаливый skip — тот самый тихий
|
||||
# успех, который уже дважды стоил нам дня (#171, #207).
|
||||
waive() {
|
||||
if [ "$2" = "true" ]; then
|
||||
echo "$1=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::$1 не прогоняется: входы побайтово те же, что в предыдущем успешном прогоне (#208)"
|
||||
echo "- **$1** переиспользована: входы не менялись" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "$1=false" >> "$GITHUB_OUTPUT"
|
||||
echo "- $1: прогоняется" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
}
|
||||
echo "### Переиспользование гейтов (#208)" >> "$GITHUB_STEP_SUMMARY"
|
||||
waive smoke "$SMOKE"
|
||||
waive golden "$GOLDEN"
|
||||
waive performance_smoke "$PERF"
|
||||
waive backend "$BACKEND"
|
||||
|
||||
hacs:
|
||||
name: "HACS: валидация репозитория"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: HACS validation
|
||||
uses: hacs/action@main
|
||||
with:
|
||||
category: integration
|
||||
|
||||
hassfest:
|
||||
name: "Hassfest: манифест интеграции"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Hassfest validation
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
|
||||
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
|
||||
cache: npm
|
||||
- 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
|
||||
# Бандл собирается один раз и уезжает артефактом (#336). Прежде каждая из
|
||||
# пяти браузерных job собирала его заново: `bundle:sync` — это `tsc
|
||||
# --noEmit` плюс rollup, около минуты на job.
|
||||
#
|
||||
# Подмену артефакта ловить не нужно отдельной проверкой: `assertFreshDemoBundle`
|
||||
# сверяет вшитый в бандл отпечаток с `sourceFingerprint` выкачанного
|
||||
# дерева, и каждая браузерная job делает это перед первым кадром.
|
||||
- name: Выложить бандл для браузерных job
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
# Несколько браузерных контрактов импортируют чистые функции из
|
||||
# скомпилированного тестового дерева. Оно создаётся `npm test`, но не
|
||||
# входит в production-бандл и потому едет отдельным артефактом (#336).
|
||||
- name: Выложить тестовые модули для смоков
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: card-test-build
|
||||
path: test-build/
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
smoke:
|
||||
name: Смоки в браузере (шард ${{ matrix.shard }} из 3)
|
||||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
|
||||
# критическим путём всего Validate. Три шарда режут его примерно вдвое;
|
||||
# цена — трижды `npm ci` и сборка, около двух оплаченных минут раннера.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
|
||||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||||
- name: Забрать собранный бандл
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist
|
||||
- name: Забрать тестовые модули
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: card-test-build
|
||||
path: test-build
|
||||
- name: Разложить бандл по копиям
|
||||
run: node scripts/bundle-sync.mjs
|
||||
# Гард исключений внутри карточки объявлен в 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 }}
|
||||
SHARDS: '3'
|
||||
run: |
|
||||
fail=0
|
||||
index=0
|
||||
ran=0
|
||||
mkdir -p /tmp/smoke-logs
|
||||
# Деление по порядковому номеру файла: список отсортирован, поэтому
|
||||
# разбиение детерминировано и не зависит от времени прогона.
|
||||
for f in demo/smoke_*.mjs; do
|
||||
index=$((index + 1))
|
||||
if [ $(( (index - 1) % SHARDS + 1 )) -ne "$SHARD" ]; then continue; fi
|
||||
ran=$((ran + 1))
|
||||
name=$(basename "$f" .mjs)
|
||||
if timeout --kill-after=10s 180s 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
|
||||
done
|
||||
echo "--- шард ${SHARD}/${SHARDS}: прогнано ${ran} из ${index}"
|
||||
# Пустой шард — признак, что деление сломалось, а не что работы нет.
|
||||
if [ "$ran" -eq 0 ]; then echo "шард пуст: проверьте деление"; exit 1; fi
|
||||
exit $fail
|
||||
- name: Upload smoke logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: smoke-logs-${{ matrix.shard }}
|
||||
path: /tmp/smoke-logs
|
||||
|
||||
# Маркер переиспользования пишется ОДИН раз и только когда прошли все шарды:
|
||||
# частично прогнанная матрица не имеет права выглядеть как выполненная работа.
|
||||
smoke_done:
|
||||
name: "Смоки: все шарды зелёные"
|
||||
needs: [smoke, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "smoke прогнана успешно (3 шарда)" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-smoke-${{ needs.reuse.outputs.smoke_key }}
|
||||
|
||||
golden:
|
||||
name: Golden-кадры против принятых эталонов
|
||||
# Deterministic visual correctness stays in every prerelease gate: it is
|
||||
# inexpensive and catches a different class of regressions than timings.
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.golden != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
|
||||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||||
- name: Забрать собранный бандл
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: card-bundle
|
||||
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: |
|
||||
if find demo/golden/baselines -maxdepth 1 -name '*.png' -print -quit | grep -q .; then
|
||||
echo "has_baselines=true" >> "$GITHUB_OUTPUT"
|
||||
npm run golden:verify
|
||||
else
|
||||
echo "has_baselines=false" >> "$GITHUB_OUTPUT"
|
||||
npm run golden:capture
|
||||
fi
|
||||
- name: Upload golden candidates/diffs
|
||||
if: failure() || steps.golden.outputs.has_baselines == 'false'
|
||||
uses: actions/upload-artifact@v7
|
||||
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: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "golden прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-golden-${{ needs.reuse.outputs.golden_key }}
|
||||
|
||||
performance_smoke:
|
||||
name: "Перф-смок: бюджет времени кадра"
|
||||
# Candidate-only catastrophic-regression guard for ordinary pushes and
|
||||
# prereleases. The expensive same-runner comparison lives in performance.yml.
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.performance_smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# 15 минут не хватало, когда установка браузера шла через apt: замер
|
||||
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
|
||||
# job укладывается в те же минуты, что и раньше.
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
|
||||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||||
- name: Забрать собранный бандл
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist
|
||||
- name: Разложить бандл по копиям
|
||||
run: node scripts/bundle-sync.mjs
|
||||
- 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
|
||||
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
|
||||
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
|
||||
- name: Бюджеты ограничений стыков (#330)
|
||||
run: npm run benchmark:junction-limits
|
||||
- name: Upload performance smoke report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: performance-smoke
|
||||
path: artifacts/performance-smoke
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "performance_smoke прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
|
||||
|
||||
backend:
|
||||
name: "Бэкенд: pytest в Home Assistant"
|
||||
needs: [changes, reuse]
|
||||
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# Browser fixtures are generated by their real ESM factories and then
|
||||
# validated through the Python CONFIG_SCHEMA/LAYOUT_SCHEMA in the same test.
|
||||
- uses: actions/setup-node@v7
|
||||
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
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "backend прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-backend-${{ needs.reuse.outputs.backend_key }}
|
||||
@@ -1,16 +0,0 @@
|
||||
node_modules/
|
||||
tsout/
|
||||
test-build/
|
||||
*.log
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv-backend/
|
||||
artifacts/
|
||||
.agents/
|
||||
|
||||
# Копия бандла для стенда: её собирает `npm run bundle:sync`, а в репозитории
|
||||
# она только росла — 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/
|
||||
@@ -1,438 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
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/`.
|
||||
- **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.
|
||||
|
||||
## Read this first
|
||||
|
||||
**`docs/SCOPE.md` before anything else.** It was fixed with the owner and states
|
||||
its own authority: features are built, improved and accepted **only** if they
|
||||
serve a job listed there. It carries the mission, the three personas, the core
|
||||
user jobs and the out-of-scope list.
|
||||
|
||||
Its central consequence: **View mode is the product for two of the three
|
||||
personas.** Editors are admin-only tools and must never leak interactions into
|
||||
View.
|
||||
|
||||
For work that changes visible behaviour, also read `docs/USER-GUIDE.ru.md` —
|
||||
interface wording comes from there and is not invented, or the UI starts speaking
|
||||
developer.
|
||||
|
||||
Then `PROCESS.md` (the full process), `docs/STATUS.md` (where the release line
|
||||
is), and for non-trivial changes `docs/ARCHITECTURE.md` plus the canonical
|
||||
document of the subsystem you touch: `SUN.md`, `LIGHT.md`, `CANVAS.md`,
|
||||
`WALL-THICKNESS.md`, `UX-MODES.md`, `CONFIG-COMPATIBILITY.md`,
|
||||
`TOUCH-SUPPORT.md`.
|
||||
|
||||
Standard commands live in `package.json` scripts, `CONTRIBUTING.md` and
|
||||
`docs/DEVELOPMENT.md`.
|
||||
|
||||
## Canonical backlog and status
|
||||
|
||||
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical
|
||||
task records: problem, scope, acceptance criteria and discussion.
|
||||
|
||||
**Status lives in labels:** `S1-new`, `S2-analysis`, `S3-spec`, `S4-spec-review`,
|
||||
`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`, plus `blocked` on top
|
||||
of a status and `rejected` on a closed issue. Exactly one `S*` label per open
|
||||
issue. Labels are the whole of it: GitHub Projects is no longer used.
|
||||
|
||||
**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
|
||||
contract, no migration, no i18n, no perf or touch impact, at most three checkable
|
||||
AC, **and expected behaviour already on record** — nothing left to decide. Code
|
||||
review is never skipped on either track; it is what stands in for testing.
|
||||
`PROCESS.md` §5 and §5.1 hold the criteria.
|
||||
|
||||
An issue filed by an outsider is worked exactly like one of the owner's own, once
|
||||
the owner has decided to take it. The check sits **at the entrance**, not on every
|
||||
step: while an issue carries no status label it is outside the process and the
|
||||
invariants do not apply to it; once a label is on, the task is in flight and **who
|
||||
filed it stops mattering**.
|
||||
|
||||
Applying that first label *is* the owner's explicit decision, and the platform
|
||||
already guarantees it — only someone with write access can label. The earlier rule
|
||||
made outside reports be refiled as the owner's own issues, which turned out to be
|
||||
work for nothing: on #123 the spec was already written by the time the guard
|
||||
refused.
|
||||
|
||||
Specs, audits and ADRs may live under `docs/`, but must link to their issue and
|
||||
must not become a parallel task list. When repository documentation disagrees with
|
||||
Issues, the issue wins.
|
||||
|
||||
## Rule #1
|
||||
|
||||
> Changing product code without an issue is forbidden. Code changes only when the
|
||||
> issue exists and sits in "Ready for development" or later.
|
||||
|
||||
Check before touching product code:
|
||||
|
||||
```
|
||||
gh issue view <NN> --repo Matysh/houseplan-card --json number,state,labels
|
||||
```
|
||||
|
||||
The label must be one of `S5-ready`, `S6-in-progress`, `S7-code-review`. Anything
|
||||
else — refuse and say why. "Issue #83 is in `S2-analysis`, code is off limits.
|
||||
Start with the spec?" is the correct answer, not a smaller patch.
|
||||
|
||||
## Change classes
|
||||
|
||||
| Class | Paths | Issue required |
|
||||
|---|---|---|
|
||||
| **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` |
|
||||
|
||||
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`,
|
||||
`pytest.ini`, `.gitignore`, `.gitattributes`, `.githooks/**` and the rest of
|
||||
`.github/**` are class B. Where paths overlap, **D beats A**: the built bundle
|
||||
lives inside `custom_components/houseplan/frontend/` and would otherwise read as
|
||||
product source.
|
||||
|
||||
## Commits
|
||||
|
||||
Hooks install themselves: `package.json` runs `"prepare": "node
|
||||
scripts/install-hooks.mjs"`, so `npm ci` sets `core.hooksPath` in every fresh
|
||||
clone. Verify with `git config core.hooksPath` — expect `.githooks`.
|
||||
|
||||
Every non-merge commit carries **terminal** trailers:
|
||||
|
||||
```text
|
||||
Issue: #123
|
||||
User-Visible: yes
|
||||
```
|
||||
|
||||
One `Issue:` line per issue if a commit closes several. `User-Visible: no` for
|
||||
tests, refactors, tooling and documentation that does not change the product.
|
||||
`User-Visible: yes` requires edits to **both** changelogs — `docs/CHANGELOG.md`
|
||||
and `docs/CHANGELOG.ru.md` — in the same commit.
|
||||
|
||||
A commit touching `demo/golden/baselines/**` additionally requires:
|
||||
|
||||
```text
|
||||
Release: v1.62.0-beta.9
|
||||
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/<run-id>
|
||||
```
|
||||
|
||||
Never invent a review link and never rewrite published history to satisfy
|
||||
trailers. `.githooks/commit-msg` and the `provenance` CI job both run
|
||||
`scripts/validate-commit-provenance.mjs`.
|
||||
|
||||
Branch: `issue/<NN>-slug`. Direct commits to `dev`, no PR — the owner's decision;
|
||||
CI checks after the fact, and a violation is fixed with a follow-up commit, never
|
||||
a force-push.
|
||||
|
||||
**Push after every task, not before a beta.** While work sits unpushed there is
|
||||
nothing to review, and reviewing twenty tasks at once is not review. `dev` may hold
|
||||
unreviewed code while a task is in flight; what matters is its state when the
|
||||
reviewer says it is accepted.
|
||||
|
||||
**Standing permission: push `issue/<NN>-slug` without asking.** The reviewer runs
|
||||
in CI and can only read what is on the remote — an unpushed spec or commit means
|
||||
the review either stalls or judges the wrong tree. Pushing a task branch publishes
|
||||
nothing to users and does not touch the integration branch, so it needs no command.
|
||||
|
||||
**Do not merge into `dev` by hand.** On a green code review the pipeline rebases
|
||||
the task branch onto `dev`, pushes it, and only then sets `S8-merged` — the label
|
||||
asserts the code is in `dev`, so the merge has to happen first or the label lies
|
||||
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. 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.
|
||||
|
||||
Everything else still requires the owner's explicit command: pushing `main`,
|
||||
creating tags, publishing betas and releases, closing issues.
|
||||
|
||||
## Working trees (#115)
|
||||
|
||||
One checkout, one `HEAD`: two agents sharing a directory inherit each other's
|
||||
branch, and twice in one hour a commit landed on someone else's task branch that
|
||||
way. The layout is therefore fixed:
|
||||
|
||||
- **`houseplan-card-src/houseplan-card`** — the author's tree. Task branches live
|
||||
here; nobody else commits in it. Unfamiliar local changes belong to the author
|
||||
or the owner — never reset or clean them away.
|
||||
- **`houseplan-card-src/hp-dev`** — the owner's worktree, permanently on `dev`. For owner-side operations that must not disturb the
|
||||
author's tree: pushing `dev`, restoring a hook's executable bit, emergencies.
|
||||
- **The reviewer and the infrastructure agent own no local tree.** The reviewer
|
||||
runs in CI on a fresh checkout. The infrastructure agent reads via `git show`
|
||||
and publishes through the GitHub API; it makes no local commits at all, so it
|
||||
needs no `HEAD` of its own. Its scratch worktrees live outside the repo and are
|
||||
pruned after use.
|
||||
|
||||
A worktree is only usable on the machine that created it: the `.git` file records
|
||||
an absolute path in that machine's format. One created from a Linux sandbox is
|
||||
dead on Windows and vice versa — create worktrees on the machine that will use
|
||||
them, which for `hp-dev` means the owner's.
|
||||
|
||||
## Two-agent workflow
|
||||
|
||||
**Codex** writes analysis, specs and all product code. **Claude** reviews specs and
|
||||
code and owns infrastructure and distribution. The owner rules on disputes, closes
|
||||
issues and commands releases.
|
||||
|
||||
Author and reviewer are different models, which is what "a fresh session without
|
||||
implementation context" means in practice. The reviewer never edits product code;
|
||||
the author never grades their own work.
|
||||
|
||||
**Infrastructure-only work runs outside this flow.** CI, scripts, labels, demo
|
||||
stands, the landing page and distribution are Claude's alone, and running them
|
||||
through spec-writing and review buys nothing: the spec would restate what is
|
||||
already unambiguous, and author and reviewer would be the same role. So no spec
|
||||
file, no spec review, no code review, no walk through `S1`…`S8`.
|
||||
|
||||
The test for "infrastructure only" is mechanical: **not a single class A file** —
|
||||
nothing under `src/**`, no `custom_components/**/*.py`, no manifests, no i18n. A
|
||||
task that touches class A even once is not infrastructure and takes the full flow;
|
||||
there is no such thing as "mostly infrastructure". The strictness is deliberate:
|
||||
a loose reading would turn this into the route by which product changes skip
|
||||
review.
|
||||
|
||||
What stays mandatory either way: an issue exists, both trailers are on every
|
||||
commit, `typecheck`, `test` and `build` are green, and any non-obvious decision is
|
||||
written down in the code or the issue rather than kept in someone's head.
|
||||
|
||||
**Review starts by itself.** Applying `S4-spec-review` or `S7-code-review` fires the
|
||||
pipeline, which reviews without anyone asking and takes ten to forty-five minutes.
|
||||
|
||||
**Having applied one of those labels, wait for the result instead of ending the
|
||||
session.** Reporting "handed over for review" stops a conveyor that could have kept
|
||||
moving on its own. An agent has no clock — it exists only during its own turn — so
|
||||
waiting means polling: every 90 seconds, at most 30 times. A single long sleep hits
|
||||
the command timeout. Watch the **label**, not the comment: the label is the state,
|
||||
the comment only explains it. Do not wait at all while `blocked` is set — the task
|
||||
is waiting on the owner, not on the reviewer. On exhausting the attempts, stop and
|
||||
tell the owner: a failed run leaves the label where it was, forever.
|
||||
|
||||
What the new label means:
|
||||
|
||||
| Now reads | What happened | What you do |
|
||||
|---|---|---|
|
||||
| `S5-ready` | the spec is accepted | write the code |
|
||||
| `S3-spec` | the spec came back | read the verdict, revise, re-apply `S4-spec-review` |
|
||||
| `S6-in-progress` | the code came back | revise, re-apply `S7-code-review` — **or**, if the verdict was green and only the merge conflicted, just rebase and re-apply. The comment says which |
|
||||
| `S8-merged` | accepted and already in `dev` | nothing |
|
||||
| `review-4` | the cycle limit is spent | stop, the owner decides |
|
||||
|
||||
**After a review run the label always changes.** If it did not, the run itself
|
||||
failed rather than the work — say so to the owner instead of polling on.
|
||||
|
||||
**A failed pre-release gate does not send the issue back to review.** The
|
||||
implementation loop runs only typecheck, unit and build; golden, browser smokes,
|
||||
performance and the full HA harness run before a beta, which is after the code
|
||||
review has passed and the issue sits in `S8-merged`. Some defects cannot surface
|
||||
any earlier.
|
||||
|
||||
Fix it, re-run what failed, and a green run is enough for the release to continue.
|
||||
The issue stays in `S8-merged`. Record the **exact command and its result** in the
|
||||
issue — "verified" without a command proves nothing. Trailers as usual, and
|
||||
`User-Visible: yes` still means both changelogs in the same commit.
|
||||
|
||||
The exception covers repairing the defect the gate named, not carrying on
|
||||
development under the name of a repair. It goes through the normal flow — a new
|
||||
issue, or back to `S6-in-progress` — if the fix changes a behaviour contract, gives
|
||||
the user something new, reaches a subsystem the task never touched, or is
|
||||
comparable in size to the task itself. And editing the gate so it stops failing is
|
||||
concealment, not repair; the exception is a defect proven to be **in the fixture**,
|
||||
as on #89, where the sun sat at azimuth 180° and the only window faced north, so no
|
||||
ray was ever built.
|
||||
|
||||
Baselines are still accepted only via `npm run golden:accept -- --reviewed` on a
|
||||
complete Linux CI artefact. "So the gate goes green" is not a reason.
|
||||
|
||||
The exchange happens in **issue comments** — there is no local message bus. Verdict
|
||||
format:
|
||||
|
||||
```text
|
||||
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → in-task | #… · Document: …
|
||||
```
|
||||
|
||||
High blocks. A Medium finding INSIDE the task's scope is fixed within the task:
|
||||
with no High findings the verdict is yellow, the author fixes it and the fix
|
||||
passes another review cycle — no separate issue (owner's decision 2026-08-19,
|
||||
#202: filing and servicing an issue costs far more than fixing in place). Only
|
||||
a Medium finding OUTSIDE the scope becomes its own issue — foreign scope is
|
||||
never patched from this branch. Low is fixed or waived with a note
|
||||
in the review document. A yellow verdict is legitimate even when every acceptance
|
||||
criterion passes, if the change does not solve the stated scenario or degrades a
|
||||
neighbouring one.
|
||||
|
||||
**Four review cycles** (two on the light track). The counter lives in the document
|
||||
name, `-r1`…`-r4`; the fourth adds the `review-4` label. There is no fifth attempt:
|
||||
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.
|
||||
|
||||
## Specs
|
||||
|
||||
`docs/specs/<NN>-<slug>.md`, linked to its issue in both directions. Required
|
||||
sections are in `PROCESS.md` §7.1, plus two product ones: which persona meets this,
|
||||
on which surface, at what moment; and what the person sees before and after, in one
|
||||
sentence without implementation terms.
|
||||
|
||||
**Ambiguity is asked, not guessed — but only product ambiguity.** A guess written as
|
||||
fact is the worst kind of defect: it passes review because it looks like a decision.
|
||||
|
||||
The owner answers exactly two kinds of question: **what a person sees or does**, and
|
||||
**how much user-visible change belongs in this issue**. Behaviour in a boundary case,
|
||||
which persona wins when two conflict, what counts as acceptable degradation, whether
|
||||
a neighbouring behaviour is in scope here or becomes its own issue.
|
||||
|
||||
Everything a user cannot observe is yours to settle: where state is stored, which
|
||||
module carries the guard, naming, file layout, test strategy, migration mechanics,
|
||||
development policy. Decide it, record it in an explicit "assumed, change freely"
|
||||
block, and let the reviewer challenge it. A technical disagreement between author and
|
||||
reviewer is settled by the verdict, not by the owner; it reaches him only when the
|
||||
cycle limit is exhausted.
|
||||
|
||||
Split a mixed question instead of escalating all of it. "Where does this state live"
|
||||
is technical. "Does it survive a page reload and follow the plan across screens" is
|
||||
product. Ask the second, decide the first.
|
||||
|
||||
Ask in one batched issue comment, each question carrying a proposed default, and put
|
||||
`blocked` on top of `S3-spec` while waiting. A question with a default costs the
|
||||
owner seconds; one without costs him minutes.
|
||||
|
||||
## Gates
|
||||
|
||||
```
|
||||
npm run typecheck
|
||||
npm test
|
||||
npm run build
|
||||
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:
|
||||
|
||||
```
|
||||
npm run bundle:sync # dist → custom_components + demo/srv/assets (#255)
|
||||
npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
|
||||
```
|
||||
|
||||
During the implementation cycle the fast gates always run. Since 2026-08-14 the
|
||||
owner's machine also carries Playwright with Chromium (Windows) and a full WSL
|
||||
environment, which changes one thing (#151): **before moving an issue to
|
||||
`S7-code-review`, run the smokes named in its AC locally** — `node
|
||||
demo/smoke_<name>.mjs`. A red smoke that reaches the review costs a cycle; run
|
||||
locally it costs a minute. Precedent: on #89 a fixture error lived through a
|
||||
whole review round that a local run would have caught immediately.
|
||||
|
||||
The full smoke set, `golden` and `performance_smoke` still belong to the
|
||||
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
|
||||
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
|
||||
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
|
||||
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
|
||||
|
||||
**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.
|
||||
Locally only the pure subset runs; `python -m pytest tests_backend/ -q` without
|
||||
Home Assistant **silently skips** `test_ha_*.py` (`conftest.py` ignores them when
|
||||
`homeassistant` is not importable), so a green result proves nothing. Say so in the
|
||||
report instead of claiming the backend was verified. Cloud agents have the harness
|
||||
at `.venv-backend/bin/python`.
|
||||
|
||||
**Running the app / smoke suite**: build a fresh bundle and copy it into the demo
|
||||
assets first, then run `node demo/smoke_*.mjs`. No real Home Assistant server is
|
||||
required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
|
||||
|
||||
**Golden images**: `npm run golden:capture` and `npm run golden:verify` refuse a
|
||||
stale demo bundle. Build and copy first, then review `artifacts/golden/actual/` and
|
||||
`diff/`. Update baselines only with `npm run golden:accept -- --reviewed`, using the
|
||||
complete Linux CI artifact; never accept a partial scenario or images merely to make
|
||||
CI green. See `demo/golden/README.md`.
|
||||
|
||||
**Freshness contract**: the embedded fingerprint covers `src/` plus Rollup,
|
||||
TypeScript and package-lock build inputs. Every browser check must verify it
|
||||
before trusting a result — benchmarks, golden runs and documentation captures
|
||||
call `assertFreshDemoBundle` themselves, and smokes get it from `launch()` in
|
||||
`demo/serve.mjs` (#236). A missing or mismatched fingerprint is a hard failure,
|
||||
not a warning; `HP_ALLOW_STALE_BUNDLE=1` skips the check for debugging and says
|
||||
so out loud. A smoke against a stale bundle does not fail cleanly: part of its
|
||||
assertions go red and part stay green, which reads as a logic defect.
|
||||
|
||||
**CI is pinned to an exact SHA.** The release gate accepts only a `completed
|
||||
success` run for the candidate's SHA, not "the last green one"; a new push cancels
|
||||
an unfinished Validate for the same branch. Gate jobs, matching the actual
|
||||
`validate.yml` (#191): `docs`, `provenance`, `process-gate`, `hacs`, `hassfest`,
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job
|
||||
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
|
||||
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
|
||||
went red after the #113 merge.
|
||||
|
||||
**"Verified" without a named command and its result is not evidence.**
|
||||
|
||||
## Environments
|
||||
|
||||
**Local Windows checkout** is the day-to-day environment: Node 22 as in CI, Python
|
||||
3.13 in a venv, `gh` authenticated. `.venv-backend` does **not** exist there — it is
|
||||
provisioned only by cloud agent startup scripts, which also run `npm ci` and install
|
||||
Playwright Chromium.
|
||||
|
||||
Known environment-sensitive smoke: `demo/smoke_opening_measure.mjs` fails two
|
||||
sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned
|
||||
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.
|
||||
|
||||
## Labs flags
|
||||
|
||||
`src/labs.ts` is the single registry and resolver for hidden presentation
|
||||
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.
|
||||
|
||||
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
|
||||
after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The
|
||||
smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does
|
||||
not. This is a harness limitation, not a card bug.
|
||||
|
||||
## Promotion rule
|
||||
|
||||
Every new feature or material behaviour change must be published as a beta/RC
|
||||
before it can enter a stable release, even when its local audit is clean. The
|
||||
stable release commit is promotion-only: version fields, generated bundle
|
||||
snapshots and changelog/release metadata. Do not add feature source code in
|
||||
that commit. An explicit owner-requested emergency hotfix is the only exception
|
||||
and must be called out in the release handoff.
|
||||
|
||||
A `Release vX.Y.Z-beta.N candidate` commit is **not** promotion-only: it carries
|
||||
the work itself and follows the ordinary rules, trailers included.
|
||||
|
||||
Issues are closed in a batch when a beta ships, not when implementation ends: that
|
||||
way a bug found in the beta returns to the same task, and the beta announcement can
|
||||
list what went in. Status labels are stripped as the issues close.
|
||||
@@ -1,119 +0,0 @@
|
||||
# Contributing to House Plan
|
||||
|
||||
Thanks for your interest! The project is one HACS package: a storage **integration**
|
||||
(`custom_components/houseplan/`, Python) and a **Lovelace card** (`src/`, TypeScript + Lit).
|
||||
|
||||
## Changelog
|
||||
|
||||
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
|
||||
writing code? The **[Telegram chat @ha_houseplan](https://t.me/ha_houseplan)**
|
||||
is the quickest route to the author and other users. Bugs and concrete feature
|
||||
requests still belong in [issues](https://github.com/Matysh/houseplan-card/issues).
|
||||
|
||||
## Backlog and work status
|
||||
|
||||
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the only
|
||||
active backlog. An issue owns scope and acceptance criteria; its **labels** own
|
||||
priority and workflow status — `PROCESS.md` §9 holds the vocabulary. Before
|
||||
starting planned work, link it to an existing issue or create one, and keep it
|
||||
current until the verified result is closed. Design specs and ADRs may support an
|
||||
issue, but they do not replace it or maintain a separate checklist.
|
||||
|
||||
## Five-minute setup
|
||||
|
||||
```bash
|
||||
git clone --filter=blob:none 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
|
||||
npm run build # tsc + rollup → dist/houseplan-card.js
|
||||
pip install pytest voluptuous && python -m pytest tests_backend -q # pure backend tests
|
||||
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.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **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.
|
||||
- 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) —
|
||||
see `resolveToggleIntent` in `src/device-toggle.ts`; don't weaken it.
|
||||
- Every commit follows the issue and trailer contract in `PROCESS.md`.
|
||||
- Follow the Integration Quality Scale where applicable —
|
||||
`custom_components/houseplan/quality_scale.yaml` tracks the self-assessment.
|
||||
|
||||
## Architecture
|
||||
|
||||
Start with `docs/ARCHITECTURE.md` (data model, WS API, coordinate system) and
|
||||
`docs/STATUS.md` (current state). Release: bump the version in `package.json`,
|
||||
`manifest.json`, `const.py`, `CARD_VERSION`, tag `vX.Y.Z`, publish a GitHub release —
|
||||
the workflow attaches the card bundle.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"source": "674e589ad841c6580f31d1a495bb207f13e6931e",
|
||||
"fingerprint": "a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e",
|
||||
"files": 30,
|
||||
"builtAt": "2026-09-29T06:30:42.564Z"
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 JB (justbusiness)
|
||||
|
||||
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,160 +0,0 @@
|
||||
# 🏠 House Plan — a live home map for Home Assistant
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||
📘 **[Full user guide](docs/USER-GUIDE.md)** · 🇷🇺 **[Русский](README.ru.md)** · 🗂 **[Project issues](https://github.com/Matysh/houseplan-card/issues)**
|
||||
|
||||
<!-- docs-section: overview -->
|
||||
|
||||
## Your whole home at a glance
|
||||
|
||||
House Plan turns Home Assistant into a live map of your home. Upload a plan or
|
||||
draw rooms directly on the dashboard, bind them to Home Assistant areas, and
|
||||
the area's devices appear automatically. You can immediately see where a light
|
||||
is on, a door is open, a room is too cold, Zigbee signal is weak, or a leak
|
||||
sensor has fired.
|
||||
|
||||

|
||||
|
||||
Setup is entirely graphical: no floor-plan YAML, Inkscape, or external editor.
|
||||
Plan data and device positions live on the Home Assistant server and stay in
|
||||
sync across screens.
|
||||
|
||||
> **Edit on a desktop computer.** View and kiosk are fully supported on phones
|
||||
> and tablets. The editors are designed primarily for a mouse and keyboard;
|
||||
> individual touch editing operations may be awkward or unavailable. See the
|
||||
> exact [touch support contract](docs/TOUCH-SUPPORT.md).
|
||||
|
||||
<!-- docs-section: features -->
|
||||
|
||||
## What House Plan provides
|
||||
|
||||
- **Live state and safe actions.** Lights and other safe devices can toggle from
|
||||
the plan; a lock cannot be opened by an accidental plan tap.
|
||||
- **Three built-in editors.** Plan creates rooms, walls and openings; Device
|
||||
places and configures markers; Background adds lines, labels and furniture.
|
||||
- **Area-aware rooms.** New devices appear automatically, while room cards can
|
||||
show temperature, humidity, light state and average LQI.
|
||||
- **Light and environment.** Room fills, lamp Glow, wall shadows, a day-cycle
|
||||
backdrop and sunlight through windows.
|
||||
- **Doors, windows, gates and vacuums.** Openings follow real contacts and locks;
|
||||
a robot can show its position, dock and travelled path.
|
||||
- **Several floors and screens.** Space tabs, swipe navigation, local viewport,
|
||||
and a separate initial floor for each card.
|
||||
- **Wall-display kiosk.** A plan-only view with fullscreen navigation and icon
|
||||
sizes saved for that display.
|
||||
|
||||

|
||||
|
||||
<!-- docs-section: first-run -->
|
||||
|
||||
## Your first working room
|
||||
|
||||
1. Install the integration and add the card to a dashboard.
|
||||
2. Create the first **space**: upload SVG/PNG/JPG/WebP, reuse an uploaded image,
|
||||
or choose no image and draw the plan by hand.
|
||||
3. In Plan, select **Room outline**, place vertices, and click the first point to
|
||||
close the outline.
|
||||
4. Name the room and bind it to a Home Assistant area. Use “No area” for a room
|
||||
that has no devices.
|
||||
5. Open Device: devices from the bound area are already placed; drag their
|
||||
markers to the correct positions.
|
||||
6. Optionally use Background for lines, text and furniture.
|
||||
7. Return to View. The plan now displays live state and accepts safe actions.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Every workflow and edge case is in the [full user guide](docs/USER-GUIDE.md).
|
||||
The [Background editor contract](docs/DECOR-EDITOR.md) and
|
||||
[vacuum guide](docs/VACUUM.md) are the authorities for those subsystems.
|
||||
|
||||
<!-- docs-section: installation -->
|
||||
|
||||
## Installation
|
||||
|
||||
### HACS
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
House Plan is in the HACS default catalog — no custom repository needed.
|
||||
|
||||
1. In HACS search for **House Plan** and install it.
|
||||
2. Restart Home Assistant.
|
||||
3. Open **Settings → Devices & services → Add integration → House Plan**.
|
||||
|
||||
The card is registered automatically. If you manage Lovelace resources
|
||||
manually, use the URL served by the integration:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: 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.
|
||||
|
||||
### Add the card
|
||||
|
||||
Create a dashboard view (Panel works best) and add the card in the UI or as:
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
title: House plan
|
||||
```
|
||||
|
||||
Different screens may start on different spaces:
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
default_floor: ground
|
||||
```
|
||||
|
||||
All cards share server-side rooms and coordinates. Current mode, viewport and
|
||||
selected space remain local to the screen. Revision checks and live sync cover
|
||||
concurrent clients, but avoid editing the same object in two browsers at once.
|
||||
|
||||
## Detailed documentation
|
||||
|
||||
- [Full user guide](docs/USER-GUIDE.md)
|
||||
- [Mouse/touch/keyboard matrix](docs/USER-GUIDE.md#6-navigation-zoom-and-input)
|
||||
- [Plan tools](docs/USER-GUIDE.md#plan-tools-at-a-glance)
|
||||
- [Background editor](docs/DECOR-EDITOR.md)
|
||||
- [Robot vacuums](docs/VACUUM.md)
|
||||
- [Touch support](docs/TOUCH-SUPPORT.md)
|
||||
|
||||
<!-- docs-section: support -->
|
||||
|
||||
## Support and feedback
|
||||
|
||||
- Questions and plan examples: [Telegram @ha_houseplan](https://t.me/ha_houseplan).
|
||||
- Bugs and proposals: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues).
|
||||
- Before reporting, update House Plan, restart HA and hard-refresh the page.
|
||||
Include the version, browser, logs and reproduction steps; private entity IDs
|
||||
may be replaced with fictional ones.
|
||||
|
||||
Documentation screenshots are produced by the reproducible
|
||||
`npm run build && node demo/docs/capture.mjs` command using synthetic data only. Scenario version,
|
||||
source fingerprint and every image hash are recorded in the
|
||||
[screenshot index](docs/images/screenshots.json).
|
||||
|
||||
License: [MIT](LICENSE).
|
||||
@@ -1,165 +0,0 @@
|
||||
# 🏠 House Plan — живой план дома для Home Assistant
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||
📘 **[Полное руководство](docs/USER-GUIDE.ru.md)** · 🇬🇧 **[English](README.md)** · 🗂 **[Задачи проекта](https://github.com/Matysh/houseplan-card/issues)**
|
||||
|
||||
<!-- docs-section: overview -->
|
||||
|
||||
## Дом целиком — одним взглядом
|
||||
|
||||
House Plan превращает Home Assistant в живую карту дома. Загрузите изображение
|
||||
плана или нарисуйте комнаты прямо на дашборде, свяжите их с зонами Home
|
||||
Assistant — и устройства появятся на плане автоматически. Сразу видно, где
|
||||
горит свет, открыта дверь, слишком холодно, слабый Zigbee-сигнал или сработал
|
||||
датчик протечки.
|
||||
|
||||

|
||||
|
||||
Настройка выполняется в графическом интерфейсе: без YAML-разметки, Inkscape и
|
||||
внешнего редактора плана. Данные плана и расположение устройств хранятся на
|
||||
сервере Home Assistant и синхронизируются между экранами.
|
||||
|
||||
> **Редактируйте на компьютере.** Режим просмотра и киоск полноценно работают
|
||||
> на телефонах и планшетах. Редакторы рассчитаны прежде всего на мышь и
|
||||
> клавиатуру; на touch отдельные операции могут быть неудобны или недоступны.
|
||||
> Подробный контракт: [поддержка touch](docs/TOUCH-SUPPORT.md).
|
||||
|
||||
<!-- docs-section: features -->
|
||||
|
||||
## Что умеет House Plan
|
||||
|
||||
- **Живые состояния и безопасные действия.** Свет и другие безопасные устройства
|
||||
переключаются с плана; замок нельзя открыть случайным нажатием.
|
||||
- **Три встроенных редактора.** «План» создаёт комнаты, стены и проёмы;
|
||||
«Устройства» размещает и настраивает маркеры; «Подложка» добавляет линии,
|
||||
подписи и мебель.
|
||||
- **Комнаты, связанные с зонами HA.** Новые устройства появляются автоматически,
|
||||
а карточки комнат показывают температуру, влажность, свет и средний LQI.
|
||||
- **Свет и окружение.** Заливки комнат, Glow от ламп, тени от стен, дневной фон и
|
||||
солнечные лучи из окон.
|
||||
- **Двери, окна, ворота и пылесосы.** Проёмы отражают реальные датчики и замки;
|
||||
робот показывает позицию, базу и пройденный путь.
|
||||
- **Несколько этажей и экранов.** Вкладки пространств, жесты переключения,
|
||||
локальный масштаб и отдельный стартовый этаж для каждой карточки.
|
||||
- **Киоск для настенного экрана.** Только план, полноэкранная навигация и размеры
|
||||
значков, сохранённые отдельно для этого устройства.
|
||||
|
||||

|
||||
|
||||
<!-- docs-section: first-run -->
|
||||
|
||||
## Первая рабочая комната
|
||||
|
||||
1. Установите интеграцию и добавьте карточку на дашборд.
|
||||
2. Создайте первое **пространство**: загрузите SVG/PNG/JPG/WebP либо выберите
|
||||
вариант без изображения, чтобы нарисовать план вручную.
|
||||
3. В редакторе «План» выберите **Контур комнаты**, поставьте вершины и замкните
|
||||
контур нажатием на первую точку.
|
||||
4. Назовите комнату и свяжите её с зоной Home Assistant. Для помещения без
|
||||
устройств выберите «Без зоны».
|
||||
5. Откройте «Устройства»: устройства связанной зоны уже размещены автоматически;
|
||||
перетащите маркеры в нужные места.
|
||||
6. При необходимости оформите подложку линиями, текстом и мебелью.
|
||||
7. Вернитесь в «Просмотр» — теперь план показывает живые состояния и принимает
|
||||
безопасные действия.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Пошаговые сценарии, все инструменты и особые случаи описаны в
|
||||
[полном руководстве](docs/USER-GUIDE.ru.md). Возможности подложки отдельно
|
||||
зафиксированы в [документе редактора](docs/DECOR-EDITOR.md), а роботов — в
|
||||
[руководстве по пылесосам](docs/VACUUM.md).
|
||||
|
||||
<!-- docs-section: installation -->
|
||||
|
||||
## Установка
|
||||
|
||||
### Через HACS
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
House Plan входит в основной каталог HACS — пользовательский репозиторий
|
||||
добавлять не нужно.
|
||||
|
||||
1. Найдите **House Plan** в поиске HACS и установите.
|
||||
2. Перезапустите Home Assistant.
|
||||
3. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
|
||||
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
|
||||
добавьте именно URL, который публикует интеграция:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
Не используйте путь к файлу внутри `custom_components`: Home Assistant не
|
||||
публикует его как JavaScript-модуль.
|
||||
|
||||
### Вручную
|
||||
|
||||
Скопируйте всю папку `custom_components/houseplan` из релиза в
|
||||
`config/custom_components`, перезапустите Home Assistant и добавьте интеграцию
|
||||
House Plan. Одного `houseplan-card.js` недостаточно: карточке также нужны
|
||||
внутренний манифест и хешированные модули из того же релиза.
|
||||
|
||||
### Добавление карточки
|
||||
|
||||
Создайте представление дашборда (лучше Panel) и добавьте карточку через UI либо:
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
title: План дома
|
||||
```
|
||||
|
||||
Для нескольких экранов можно задать разные стартовые пространства:
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
default_floor: ground
|
||||
```
|
||||
|
||||
Все карточки используют общие серверные комнаты и координаты. Текущий режим,
|
||||
масштаб и выбранное пространство локальны для экрана. Одновременное
|
||||
редактирование поддерживает синхронизацию и проверку ревизий, но один объект
|
||||
лучше не менять параллельно в двух браузерах.
|
||||
|
||||
## Где искать подробности
|
||||
|
||||
- [Полное руководство пользователя](docs/USER-GUIDE.ru.md)
|
||||
- [Матрица mouse/touch/keyboard](docs/USER-GUIDE.ru.md#6-навигация-масштаб-и-жесты)
|
||||
- [Инструменты плана](docs/USER-GUIDE.ru.md#инструменты-плана-в-короткой-таблице)
|
||||
- [Редактор подложки](docs/DECOR-EDITOR.md)
|
||||
- [Роботы-пылесосы](docs/VACUUM.md)
|
||||
- [Поддержка touch](docs/TOUCH-SUPPORT.md)
|
||||
|
||||
<!-- docs-section: support -->
|
||||
|
||||
## Помощь и обратная связь
|
||||
|
||||
- Вопросы и примеры планов: [Telegram @ha_houseplan](https://t.me/ha_houseplan).
|
||||
- Баги и предложения: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues).
|
||||
- Перед отчётом обновите House Plan, перезапустите HA и выполните жёсткое
|
||||
обновление страницы (`Ctrl+F5`). Приложите версию, браузер, логи и шаги
|
||||
воспроизведения; приватные entity ID можно заменить вымышленными.
|
||||
|
||||
Скриншоты в документации получены воспроизводимой командой
|
||||
`npm run build && node demo/docs/capture.mjs` только на синтетических данных. Версия сценариев,
|
||||
fingerprint исходников и хеш каждого изображения находятся в
|
||||
[индексе снимков](docs/images/screenshots.json).
|
||||
|
||||
Лицензия: [MIT](LICENSE).
|
||||
@@ -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.
|
||||
@@ -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 |