mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Keep the critical file-sync subsection under Local Windows workstation and place the repository-maintenance section after it, as requested by review r1. Issue: #628 User-Visible: no
758 lines
43 KiB
Markdown
758 lines
43 KiB
Markdown
# Development and deployment
|
||
|
||
## Input support contract
|
||
|
||
Read `docs/TOUCH-SUPPORT.md` before changing interaction code.
|
||
|
||
- View and kiosk must work well on touch and remain release-blocking surfaces.
|
||
- Editors are implemented and accepted against a desktop browser with
|
||
mouse/keyboard first.
|
||
- Full editor parity on phones/tablets is not required. If correct touch support
|
||
is expensive, an intentionally reduced or absent touch path is allowed.
|
||
- Every editor feature/spec/code review must classify touch as supported,
|
||
best-effort/degraded, or not exposed.
|
||
- A degradation is valid only when documented in the same change. It may not
|
||
compromise data integrity, permissions, confirmations or ordinary View.
|
||
- Do not add complex gesture state solely to claim touch parity. Prefer a clear
|
||
desktop recommendation or safe unavailable action over unreliable editing.
|
||
|
||
Existing touch editor behaviour is not silently disposable: when changing a
|
||
covered workflow, update its test and documentation explicitly and record why
|
||
the degradation is accepted.
|
||
|
||
## Environment (cowork sessions)
|
||
|
||
- The source of truth is **GitHub `main`** (https://github.com/Matysh/houseplan-card).
|
||
In a sandbox session restore from it or from `houseplan-card.git.bundle`
|
||
(`git clone houseplan-card.git.bundle hpcN` into a **fresh** /tmp directory).
|
||
- The user's folder `houseplan/houseplan-card/` is a file mirror (synced after every commit)
|
||
+ an up-to-date `houseplan-card.git.bundle`. The mount cannot delete files — stale
|
||
artifacts linger there; git is authoritative.
|
||
- `/tmp` persists between sessions, **but files created in previous sessions belong to
|
||
`nobody` and are unreadable** (this hit `/tmp/hpc`, `/tmp/ha_jb`, `/tmp/shots/srv`).
|
||
Always clone into a new directory and re-run `npm ci`; ask the user to re-upload `ha_jb`.
|
||
- Headless Chromium for smoke tests: `PLAYWRIGHT_BROWSERS_PATH=/tmp/pw npx playwright
|
||
install chromium-headless-shell`, then run with `LD_LIBRARY_PATH` pointing to the
|
||
extracted lib dirs (`libs/lib/x86_64-linux-gnu:libs/usr/lib/x86_64-linux-gnu:.../nss`).
|
||
- Restart HA over SSH with `nohup ha core restart >/dev/null 2>&1 </dev/null &` —
|
||
a plain `ha core restart` holds the SSH session until the sandbox call times out.
|
||
- GitHub pushes: classic PAT (repo+workflow scopes), created via the user's Chrome;
|
||
stored in `~/.git-credentials` for the session.
|
||
|
||
## Local contour in 5 minutes (локальный контур за 5 минут, #633)
|
||
|
||
Three commands take a fresh Linux sandbox (agent session, WSL, a clean VM) from
|
||
nothing to a green smoke, a full unit run in parts and a pre-push gate. Each
|
||
step fits the ≈3-minute limit of one sandbox command; everything is idempotent,
|
||
so after a timeout or a sandbox restart the same command is simply repeated.
|
||
|
||
```bash
|
||
# 1. Worktree + dependencies + Chromium + bundle + one Playwright page.
|
||
# HP_BRANCH picks the branch (taken from origin if it exists there);
|
||
# without it the worktree is a detached origin/dev.
|
||
HP_BRANCH=issue/NNN-slug HP_WORKTREE=/tmp/w-NNN bash scripts/sandbox-bootstrap.sh
|
||
# or step by step: worktree | deps | chromium | bundle | check
|
||
cd /tmp/w-NNN && node demo/smoke_edge_cases.mjs # AC2 of #633: green
|
||
|
||
# 2. The full unit suite in parts that each fit one command.
|
||
npm run test:chunk -- 1/6 # builds test-build/, then the first sixth
|
||
npm run test:chunk -- 2/6 --no-build
|
||
npm run test:chunk -- 3/6 --list # only print the files of the part
|
||
|
||
# 3. Push: the pre-push hook runs npm run gate:small for issue/* branches.
|
||
git push origin issue/NNN-slug
|
||
HP_PREPUSH_GATE=0 git push origin issue/NNN-slug # explicit opt-out
|
||
```
|
||
|
||
What each command guarantees:
|
||
|
||
- **`scripts/sandbox-bootstrap.sh`** — the worktree comes from the clone the
|
||
script lives in (`HP_CLONE` overrides), `npm ci --ignore-scripts` runs only
|
||
when `package-lock.json` changed (`HP_SHARED_NODE_MODULES` links a ready
|
||
`node_modules` instead), Chromium comes from the npm package
|
||
`@sparticuz/chromium@152.0.0` (the Playwright CDN is closed in the sandbox,
|
||
the npm registry is not) and gets a shim at every path Playwright expects —
|
||
a real browser already there is left alone. `bundle` is `npm run
|
||
bundle:sync`; `check` opens one page in Playwright. The script carries no
|
||
owner paths and no credentials: pushing is configured separately. Golden
|
||
frames are still captured only in Linux CI (#455).
|
||
- **`npm run test:chunk -- N/M`** — `test/*.test.mjs` sorted by code point,
|
||
file *i* goes to part *i* mod M + 1 (round-robin). Chosen over size-balanced
|
||
parts so that a file stays in the same part while tests are edited; the M
|
||
parts together cover every file exactly once (`test/test-chunk.test.mjs`).
|
||
The test build runs in every part unless `--no-build`, so a part is
|
||
self-contained after a sandbox restart.
|
||
- **pre-push** — see [TESTING.md «Локальный набор перед пушем»](TESTING.md#локальный-набор-перед-пушем-343):
|
||
on by default for `issue/*` branches with an executable diff, skipped when the
|
||
branch diff against `origin/dev` is class C/D only (review documents,
|
||
changelogs, bundle), off with `HP_PREPUSH_GATE=0`, forced for any branch with
|
||
`HP_PREPUSH_GATE=1`. `gate:small` takes minutes; when a push must fit a
|
||
3-minute command, run `npm run gate:small` separately and push with
|
||
`HP_PREPUSH_GATE=0`.
|
||
|
||
## Local Windows workstation
|
||
|
||
The CI contract is **Node.js 22 + Python 3.14**. Do not use Codex's bundled
|
||
Node 24 or an unpinned machine Python environment as proof that a release will
|
||
pass. The pins are not declared twice: `node scripts/toolchain-pins.mjs` reads
|
||
them from `validate.yml`, `tests_backend/requirements.txt` and the lockfile, and
|
||
`npm run toolchain:check` compares the machine with them (#496). `.nvmrc` and
|
||
`.python-version` carry the same values for nvm/uv/pyenv; a test keeps them equal.
|
||
|
||
The supported native setup is repository-scoped and does not change the
|
||
machine's default Node, Python or persistent `PATH` (#557):
|
||
|
||
```powershell
|
||
# One-time/idempotent setup. Requires uv; installs a verified portable Node 22
|
||
# under %LOCALAPPDATA% and Python 3.14 in the dedicated .venv-ci.
|
||
.\scripts\windows-toolchain.ps1 setup
|
||
|
||
# Read-only proof: actual versions and executable/package/browser paths.
|
||
.\scripts\windows-toolchain.ps1 check
|
||
|
||
# Explicit pinned entrypoints for ordinary commands; no accidental PATH tools.
|
||
.\scripts\windows-toolchain.ps1 npm run gate:small
|
||
.\scripts\windows-toolchain.ps1 python -Arguments @(
|
||
'-m', 'pytest', '-p', 'pytest_asyncio.plugin',
|
||
'tests_backend/test_validation.py', 'tests_backend/test_trails.py',
|
||
'tests_backend/test_trail_recorder.py', '-q'
|
||
)
|
||
.\scripts\windows-toolchain.ps1 playwright install chromium
|
||
```
|
||
|
||
The Node archive is selected from the official release index for the major in
|
||
`.nvmrc` and checked against Node's `SHASUMS256.txt`. The script prepends that
|
||
directory to `PATH` only for its child process. It never removes an existing
|
||
venv: if the requested `-VenvPath` contains another Python minor, setup stops
|
||
and asks for another path. Playwright remains in its normal shared Windows
|
||
cache. Install `uv` once with `winget install --id astral-sh.uv --source winget`
|
||
if it is absent; GitHub access still uses the separately installed `gh`.
|
||
|
||
WSL2 is optional for the ordinary frontend and pure-backend loop. Keep its clone
|
||
inside Linux ext4, not under `/mnt/c`; on a fresh checkout run:
|
||
|
||
```bash
|
||
cd ~/houseplan-card
|
||
bash scripts/wsl-setup.sh # idempotent setup in dedicated .venv-ci
|
||
bash scripts/wsl-setup.sh --check # no installation; paths + versions only
|
||
bash scripts/wsl-setup.sh --verify # setup, real HA subset and one golden capture
|
||
```
|
||
|
||
The script provisions the CI pins (nvm → Node, uv → Python and the HA test stack
|
||
from `tests_backend/requirements.txt`, Playwright Chromium from the lockfile).
|
||
`--verify` imports Unix-only `fcntl` and the pinned Home Assistant, runs
|
||
`tests_backend/test_ha_setup.py`, builds the card and captures
|
||
`panel-wide-view-light-en` under `artifacts/golden/`; it records elapsed time and
|
||
the resulting PNG path. `HOUSEPLAN_VENV` selects another dedicated venv without
|
||
deleting or rewriting an existing one. WSL is normally early feedback. For an
|
||
intentional baseline change it may also produce the reviewed candidate with the
|
||
fail-closed command below; the canonical merge/release proof still lives in
|
||
GitHub Linux CI at the final exact SHA.
|
||
It is required only when running the full HA harness locally: current Home
|
||
Assistant imports the Unix-only `fcntl` module and cannot start its pytest plugin
|
||
on native Windows. Keep a WSL clone inside the Linux ext4 filesystem rather than
|
||
under `/mnt/c`, otherwise dependency installs become slower. The release CI
|
||
always runs this harness on Ubuntu and gates the exact tagged commit. Docker
|
||
Desktop is not currently required. Do not install the full Home Assistant pytest
|
||
stack natively just for this repository: its pinned `lru-dict==1.3.0` first
|
||
requires Visual Studio Build Tools to compile, but the resulting plugin still
|
||
cannot run without `fcntl`.
|
||
|
||
Useful repo-local Git settings on NTFS (optional for this small repository):
|
||
|
||
```powershell
|
||
git config core.fsmonitor true
|
||
git config core.untrackedCache true
|
||
```
|
||
|
||
### ⚠️ File-sync pitfalls (critical)
|
||
1. The network mount sometimes serves files **truncated/scrambled** — edits via the Edit tool
|
||
from the Windows side are unreliable. Rule: **apply python patches against a clean copy in /tmp,
|
||
write via bash**, with an assert that count(old)==1.
|
||
2. **Run the rollup build ONLY in /tmp/hpc** (`npm ci` is already done). A build on the mount once
|
||
produced a syntactically valid but broken bundle ("wi is not defined") that crashed the rendering
|
||
of ALL HA dashboards (the card is loaded as an extra_module on every page!).
|
||
3. `.git` cannot be created on the mount ("Operation not permitted" on dot-directories) — hence the bundle.
|
||
|
||
## Local repository maintenance (#628)
|
||
|
||
Owner decision on 2026-09-25: use only a one-time local garbage collection for
|
||
the Windows clone. This issue does **not** introduce Git LFS, stop tracking
|
||
`dist/**`, remove the committed integration bundle, or rewrite published Git
|
||
history.
|
||
|
||
Run the maintenance command only when no Git process is active in the shared
|
||
clone (all worktrees use the same object database):
|
||
|
||
```powershell
|
||
git gc --prune=now
|
||
git count-objects -vH
|
||
git fsck --no-dangling --no-progress
|
||
```
|
||
|
||
The 2026-09-25 measurement before GC was 26,243 loose objects / 749.84 MiB,
|
||
41 packs / 334.55 MiB and 98 garbage entries / 959.60 KiB. After GC it is zero
|
||
loose objects, two packs / 467.63 MiB, zero `tmp_obj_*` files, and a clean
|
||
`git fsck`. `size-pack` grew because reachable loose objects moved into packs;
|
||
the meaningful total (`size` + `size-pack`) fell from 1,084.39 MiB to
|
||
467.63 MiB, a reduction of 616.76 MiB.
|
||
|
||
This post-GC value is the #628 monthly-growth baseline. To evaluate AC2 on or
|
||
after 2026-10-25, run the same GC and `count-objects` sequence and compare the
|
||
new `size-pack` with 467.63 MiB; the accepted upper bound is 487.63 MiB.
|
||
|
||
## Tests
|
||
|
||
- Frontend: `npm test` — compiles src/logic.ts+rules.ts (tsconfig.test.json) and runs node:test
|
||
(test/*.test.mjs). Strict typing: `npm run typecheck` (tsc --noEmit, part of `npm run build`).
|
||
- Pure backend on native Windows (with no HA plugin autoload): use the explicit
|
||
`python -Arguments @(...)` invocation above after setting
|
||
`$env:PYTEST_DISABLE_PLUGIN_AUTOLOAD='1'`.
|
||
- Full backend (including `test_ha_*.py`): `python -m pytest tests_backend/ -q`
|
||
in CI or WSL/Linux only.
|
||
- Junction-limit TS/Python parity (clean, no Home Assistant):
|
||
`npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs &&
|
||
python tests_backend/junction_parity.py --build-dir=test-build/junction-parity`.
|
||
Its dedicated reusable Validate job owns this proof; missing compiled modules
|
||
fail setup instead of turning into a pytest skip.
|
||
- IMPORTANT (audit lesson): the rollup typescript plugin reports a syntax error as a WARNING and still
|
||
builds the bundle — a truncated file can "pass". That is why the build starts with `tsc --noEmit`,
|
||
which fails on such errors. Always build with `npm run build`, never bare `rollup -c`.
|
||
- Before committing a frontend source change, run `npm run bundle:sync`. Rollup writes
|
||
`dist/houseplan-card.js`, `dist/houseplan-assets.json` and content-hashed chunks under
|
||
`dist/houseplan-assets/`; the command synchronizes that complete tree to the committed
|
||
integration snapshot and the untracked demo copy, then verifies every manifest hash.
|
||
`node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend` is the
|
||
read-only parity check used by CI and release automation.
|
||
- The first-space/import dialog is a separate `houseplan-onboarding-runtime-*`
|
||
chunk. Do not fold it into `houseplan-editor-runtime-*`: empty-install
|
||
onboarding is a View prerequisite, while a configured View must request
|
||
neither lazy runtime until the corresponding user intent.
|
||
- Hidden Stage 3 rendering is a separate `iso-scene-render-*` chunk. An
|
||
alpha-off View must not request it. Its normal import and content-hashed retry
|
||
must pass the same source-fingerprint handshake before Iso installs atomically;
|
||
`houseplan-assets.json` records the graph as `lazyIsometricFiles`.
|
||
|
||
## Maintenance diagnostics
|
||
|
||
### Labs presentation flags
|
||
|
||
Labs is an internal, presentation-only runtime in `src/labs.ts`; it must never
|
||
gate config/schema migrations, persistence writes, HA services or network
|
||
requests. `?hp_alpha=1` or `#hp_alpha=1&space=<id>` enables every experimental
|
||
capability in the current build and persists `1` in
|
||
`houseplan_card_alpha_v1`; `hp_alpha=0` disables them and persists `0`. Query is
|
||
applied before hash and the last exact `1`/`0` wins. The URL is not rewritten,
|
||
unknown values fail closed for the current resolution, and the legacy
|
||
`hp-labs`/`houseplan_card_labs_v1` inputs are not read or migrated. Diagnostics
|
||
expose the boolean `window.__hpAlpha` together with the frozen sorted
|
||
`window.__hpLabs` capability array.
|
||
|
||
To add a capability, add one unique lowercase id plus issue and a non-empty
|
||
summary to `LABS_FLAGS`, then cover registry validation and the alpha-on active
|
||
set. Capabilities have no individual public key or version lifetime: the one
|
||
persisted alpha switch is deliberately indefinite until the owner changes the
|
||
contract. See `docs/ISOMETRIC.md` for the current use.
|
||
|
||
These commands are read-only diagnostics, not release gates:
|
||
|
||
```bash
|
||
# Show registered legacy/internal fields or inspect an exported config locally.
|
||
npm run audit:config
|
||
npm run audit:config -- path/to/houseplan-config.json
|
||
|
||
# Reproducible synthetic large-house report (seven measured samples + warm-up).
|
||
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
|
||
|
||
# Hidden isometric profile; diagnostic only outside exact-SHA Linux CI.
|
||
npm run benchmark:large-house-isometric -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-local.json
|
||
|
||
# Dense Stage 3 overlay/opening profile; also diagnostic outside exact-SHA Linux CI.
|
||
npm run benchmark:isometric-stage3-dense -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-stage3-local.json
|
||
|
||
# Golden candidates never overwrite reviewed references.
|
||
npm run golden:capture
|
||
npm run golden:verify
|
||
npm run golden:accept -- --reviewed
|
||
|
||
# Full attested candidate for baseline acceptance. Run only in the ext4 WSL
|
||
# clone, on a clean named branch whose HEAD already equals origin/<branch>.
|
||
npm run golden:wsl:capture -- --expect-change=<scene-id,scene-id>
|
||
npm run golden:accept -- --reviewed --from=artifacts/golden \
|
||
--expect-change=<scene-id,scene-id>
|
||
|
||
# Docs screenshots whose pixels did not change (a version bump, a refactor):
|
||
# re-capture locally, compare decoded RGBA against the committed frames and,
|
||
# if every frame is identical, refresh only the manifest fingerprints (#512).
|
||
npm run docs:accept -- --identical
|
||
```
|
||
|
||
Golden frames never show the real card version: the harness sets the test-only
|
||
seam `window.__HP_VERSION_OVERRIDE__ = '0.0.0-golden'` before the card is
|
||
created, so a version bump alone changes no baseline (#512, see
|
||
`demo/golden/README.md`).
|
||
|
||
`golden:wsl:capture` refuses native Windows, `/mnt/c`, dirty or detached trees,
|
||
unpublished/mismatched branch SHAs, stale source fingerprints, toolchain drift,
|
||
partial matrices, undeclared differences and an insufficient witness floor. It
|
||
writes `artifacts/golden/wsl-attestation.json`, which self-hashes the source
|
||
identity, environment, pinned toolchain, report, every PNG and the acceptance
|
||
intent. Acceptance verifies the passport again and records it under
|
||
`localAttestation` in the baseline index. Copy the printed
|
||
`Baseline-Reviewed-Local: sha256:…` line to the baseline commit together with
|
||
`Release:`; never add the GitHub `Baseline-Reviewed:` trailer to the same commit.
|
||
Push that commit and wait for the full GitHub Validate on its exact SHA before
|
||
S7/merge. The local path removes the earlier expected-red capture run, not this
|
||
independent final check. A downloaded `golden-images` artifact from GitHub stays
|
||
supported and keeps the existing `Baseline-Reviewed: <run URL>` provenance.
|
||
|
||
The config audit performs no network requests and does not rewrite the input.
|
||
Its registry and lifecycle rules are documented in `CONFIG-COMPATIBILITY.md`.
|
||
The blocking performance workflow runs its independent profile pairs in
|
||
parallel. Inside each pair it captures the base SHA and candidate sequentially
|
||
on one pinned Chromium/CI runner, applies the same relative and absolute budget,
|
||
and uploads both reports plus the comparison. This preserves same-machine
|
||
comparability without serialising the whole matrix beyond the job timeout. A
|
||
developer-laptop report remains a diagnostic and must not be used to loosen CI
|
||
limits. See
|
||
`demo/performance/README.md`.
|
||
When the comparison base predates `scripts/bundle-sync.mjs`, the workflow still
|
||
builds that exact tree and materializes its fresh bundle through the equivalent
|
||
legacy copy path. This keeps old stable releases usable as performance baselines
|
||
without borrowing build output from the candidate. Comparative benchmark
|
||
launches also pass that target tree as the freshness authority; the ordinary
|
||
smoke launcher continues to default to the current repository root.
|
||
Both browser diagnostics require a freshly built/copied demo bundle. Rollup
|
||
embeds a SHA-256 fingerprint of `src/` plus the locked package and
|
||
Rollup/TypeScript build inputs; benchmark/golden runners fail before
|
||
capturing anything when `demo/srv/assets/houseplan-card.js` is stale. Golden
|
||
commands and the explicit review workflow are documented in
|
||
`demo/golden/README.md`.
|
||
|
||
## Build
|
||
|
||
```bash
|
||
cd /tmp/hpc && npm ci # once
|
||
npm run bundle:sync # build + entry/manifest/chunks → integration + demo
|
||
npm run bundle:budget # initial View graph must stay <= 256000 B gzip
|
||
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
|
||
```
|
||
|
||
## Deployment to the dacha (ha.jbstudio.pro)
|
||
|
||
- SSH: port **22222**, root, key `ha_jb` (lives in the user folder `houseplan/.secrets/ha_jb`,
|
||
outside git; copy into the sandbox with chmod 600 — only ask the user if it is gone).
|
||
- **The HA config root is `/mnt/data/supervisor/homeassistant`** — in this SSH
|
||
environment `/config` does not exist; a deploy aimed at `/config/...` fails
|
||
with "No such file or directory".
|
||
- Frontend: copy the complete `custom_components/houseplan/frontend/` tree.
|
||
Copying only `houseplan-card.js` is unsupported: the entry imports hashed chunks and
|
||
validates its editor runtime against the build fingerprint.
|
||
- Cache busting: `sed` the `?v=` version in `.storage/lovelace_resources`, then restart HA.
|
||
- **The `frontend/` subfolder is not optional.** `__init__.py` registers
|
||
`Path(__file__).parent / "frontend" / "houseplan-card.js"` as the static path.
|
||
A copy dropped next to `__init__.py` (…/houseplan/houseplan-card.js) is served
|
||
by nobody: md5 on the server matches, the browser still gets the old bundle,
|
||
and hours go into debugging a bug that was already fixed. Cost this mistake
|
||
once: 2026-07-27, two releases deployed into the void.
|
||
- The whole integration: tar c custom_components/houseplan (--exclude __pycache__) → tar x on the server.
|
||
- **Verification is mandatory, and it must go over HTTP** — comparing md5 against
|
||
the file you just copied proves nothing about what the browser receives. The
|
||
one check that counts:
|
||
`curl -s https://ha.jbstudio.pro/houseplan_files/houseplan-card.js | grep -o '1\.[0-9]*\.[0-9]*' | sort -u`
|
||
must print the version just built. (Inside the SSH add-on `localhost` is NOT
|
||
HA — use the host `homeassistant`.)
|
||
- Python changes require an HA restart (`ha core restart`, holds the connection until it finishes, HTTP
|
||
comes back up in 1–3 min). JS changes — just a page refresh (the static path is served
|
||
with no-cache).
|
||
- After deploying JS — check in the browser (Ctrl+F5) and the console (there must be no errors from
|
||
houseplan-card.js; a broken bundle takes down all dashboards).
|
||
|
||
## Frontend cache and the "empty view"
|
||
|
||
- The card module URL contains `?v=<VERSION from const.py>`. Browsers keep the ES module in
|
||
memory cache: after deploying new JS **bump VERSION in const.py and restart HA**,
|
||
otherwise a plain F5 will keep the old version.
|
||
- After a page reload the HA frontend (with kiosk-mode) sometimes leaves the view empty
|
||
("InvalidStateError: Transition was aborted", hui-view is not created for 1–2 min).
|
||
Cured by repeating the SPA navigation: pushState + a location-changed event, or just waiting.
|
||
|
||
## Resource registration and version recovery (#462)
|
||
|
||
- A writable Lovelace resource registry is the loader authority. Registration
|
||
returns a typed outcome; a pending or transient first attempt installs the
|
||
`extra_module_url` fallback immediately and schedules exactly one retry after
|
||
Home Assistant reaches running state plus a fixed one-second delay. Listener,
|
||
timer and retry task must all be cancelled through config-entry unload.
|
||
- Runtime registration facts belong in `hass.data[DOMAIN]` and System Health,
|
||
not in plan/layout storage. The persistent hard-reload notification is
|
||
localized from the backend `issues` translation category and its config-entry
|
||
flag is written only after `persistent_notification.async_create` returns
|
||
without an exception.
|
||
- Every successful `houseplan/config/get` is authoritative for
|
||
`integration_version`. A missing, non-string or whitespace-only value clears
|
||
a previously known version. The full-card version controller stays in the
|
||
initial View graph; do not move it behind the lazy editor runtime or add it to
|
||
`houseplan-space-card`.
|
||
- Targeted checks while changing this contract are:
|
||
|
||
```text
|
||
python -m pytest tests_backend/test_ha_frontend_registration.py tests_backend/test_ha_setup.py -q
|
||
npx tsc -p tsconfig.test.json
|
||
node scripts/fix-test-build.mjs
|
||
node --test test/version-recovery.test.mjs
|
||
node demo/smoke_version_recovery.mjs
|
||
node scripts/check-docs.mjs
|
||
```
|
||
|
||
The full HA pytest harness requires Linux/WSL; native Windows lacks `fcntl`.
|
||
|
||
## The stylesheet minifier sees TypeScript output, not the source (#526)
|
||
|
||
`scripts/css-template-minifier.mjs` runs as a Rollup transform, and by then the
|
||
module has already been through TypeScript. The TS printer puts a space between
|
||
a tag and its template, so the source `css`…`` arrives as `css `…``.
|
||
|
||
The plugin used to look for the exact string `css` + backtick and therefore
|
||
returned `null` for every stylesheet in the project: minification never ran
|
||
once, and roughly 23 KB of explanatory comments were shipped to every user —
|
||
12.8 KB gzipped in the initial chunk.
|
||
|
||
Two consequences for anyone touching this area:
|
||
|
||
- match the tag as a word followed by optional whitespace, never as a literal
|
||
two-character string;
|
||
- the guard that keeps this honest is not inside the plugin but in
|
||
`test/bundle-assets.test.mjs`: it takes real comment text out of
|
||
`src/styles/*.ts` and asserts none of it appears in `dist/**`. A plugin that
|
||
silently stops working cannot pass it.
|
||
|
||
## Do not animate container-relative properties on the plan (#524)
|
||
|
||
A CSS property whose value is expressed in container query units — `cqw`,
|
||
`cqh`, or any custom property derived from them, such as `--dev-size` — must
|
||
not appear in a `transition` on elements the plan draws in quantity.
|
||
|
||
Container query styles are re-evaluated whenever the container's inline size
|
||
changes: a tooltip, a scrollbar, a rotation, a panel resize. Every such
|
||
re-evaluation produces a new computed value and therefore **restarts the
|
||
transition on every one of those elements at once**.
|
||
|
||
That is how `box-shadow` on device markers cost a real user 9.4 frames per
|
||
second in Firefox 155: sixty-one markers started a 150 ms non-composited
|
||
shadow transition four times in two seconds, and the refresh driver spent the
|
||
window waiting for paint. Chromium starts exactly the same transitions — it
|
||
merely pays less for them, which is why the defect hid there.
|
||
|
||
The witness is browser-independent and lives in
|
||
`demo/smoke_marker_shadow_transitions.mjs`: change the stage container width by
|
||
one pixel and assert that no `transitionrun` for `box-shadow` arrives.
|
||
|
||
## Updating a pinned Action (#556)
|
||
|
||
Every `uses:` in `.github/workflows/**` is a full commit SHA with the human
|
||
version in a trailing comment; `node scripts/action-pins.mjs` enforces it and the
|
||
Validate preflight runs it. The comment is not decoration — it is the only thing
|
||
that tells a reader which release they audited.
|
||
|
||
To move a pin: read what the tag points at today,
|
||
|
||
```bash
|
||
gh api repos/<owner>/<repo>/commits/<tag> -q .sha
|
||
```
|
||
|
||
read the delta from the currently pinned SHA, then change **both** the SHA and
|
||
the comment in one commit. `node scripts/action-pins.mjs --list` prints every
|
||
third-party action with its pin, which is the fastest way to see what is behind.
|
||
|
||
Two of these are branches upstream, not releases — `home-assistant/actions`
|
||
(`master`) and `hacs/action` (`main`) — so their comment carries the date the
|
||
branch head was read. They have no tags to follow; the only honest record is
|
||
"this commit, read on this day".
|
||
|
||
## What the review model is allowed to do (#556)
|
||
|
||
The `model_review` job is the only untrusted stage of the review pipeline: it
|
||
runs a model with `Read/Write/Bash` over the material. Its `permissions:` block
|
||
is the real ceiling **only because** the `Review` step is handed
|
||
`github_token: ${{ secrets.GITHUB_TOKEN }}`.
|
||
|
||
Without that input `claude-code-action` exchanges the job's OIDC token for its
|
||
own GitHub App installation token (`src/github/token.ts`), and that exchange
|
||
defaults to `contents: write`, `pull_requests: write`, `issues: write` no matter
|
||
what the calling job declared — the `ghs_…` token then sits in the environment of
|
||
the model's own Bash tool. Removing the one line therefore widens the model's
|
||
rights silently, with every test still green, which is why there is a witness
|
||
(`test/review-doc-guard.test.mjs`) and a mutant
|
||
(`review-job-trusts-the-app-token`) standing on it.
|
||
|
||
`issues: write` is the single write scope the model keeps, because the process
|
||
asks the reviewer for the verdict comment (§7.2) and a separate issue for
|
||
out-of-scope Medium findings (§12). It buys comments, labels and issue-body
|
||
edits — not a commit, not a merge (that is decided in `integrate` from the sealed
|
||
`verdict.json`), not a release. Dropping it means moving both duties into
|
||
`integrate`, which is a pipeline change and not part of this one.
|
||
|
||
## Dependency and cache gotchas
|
||
|
||
- **polygon-clipping is a trap**: its `.d.ts` declares named exports but the ESM build has only
|
||
a default export — tsc or the runtime breaks, whichever you appease. Use **polyclip-ts**
|
||
(proper ESM + native types; same results, +~50 KB bundle via bignumber.js).
|
||
- **Redeploying the same version keeps the resource URL** (`/houseplan_files/houseplan-card.js?v=X`),
|
||
so browsers may serve the previous bundle from cache. Bump the version for anything users must
|
||
pick up, or hard-refresh (Ctrl+Shift+R) when testing a hotfix redeploy.
|
||
- **CSS `filter: blur()` on an SVG group is applied in name only** in Chromium:
|
||
`getComputedStyle` returns `blur(1px)`, and the rendered result changes by a
|
||
couple of hundred pixels on a whole plan — i.e. not at all. Use an SVG
|
||
`<filter>` with `feGaussianBlur` and `filterUnits="userSpaceOnUse"`. One
|
||
filter over the light layer costs about a fifth of one blurred mask per
|
||
source (60 sources: 66 ms vs 206 ms).
|
||
- **A geometry cache must be keyed by the geometry, not by `_cfgEpoch`.** The
|
||
epoch lags behind edits made in place (boundary/opening tools mutate the
|
||
space object), and a stale barrier set is invisible: the plan keeps lighting
|
||
through a wall that already exists. `_lightBarriers` hashes its own inputs
|
||
instead, and the same fingerprint keys the per-source region cache.
|
||
- **Segments that cross must be split before a visibility sweep.** The sweep
|
||
casts a ray at every barrier ENDPOINT; two faces crossing in their middles —
|
||
normal where wall bodies meet at a junction — leave that corner unsampled and
|
||
the fan closes it with a chord, so a sliver of floor next to a corner the
|
||
lamp plainly sees goes dark. `splitAtIntersections` removes the whole class.
|
||
|
||
## Release
|
||
|
||
### Primary prerelease path
|
||
|
||
Prepare the candidate as usual: synchronize every version field, add dated RU
|
||
and EN changelog sections, update the production bundle snapshots and write the
|
||
short bilingual body in `docs/RELEASE-NOTES.md`. That file is the one current
|
||
instance of the canonical `## Основное` / `## Highlights` template; its two
|
||
changelog links must be pinned to the new tag.
|
||
|
||
After the candidate commit is pushed to `dev` and its exact-SHA Validate is
|
||
green, publication is one command:
|
||
|
||
```powershell
|
||
npm run release:prerelease -- v1.61.0-beta.4 --issues=63,64 --yes
|
||
```
|
||
|
||
Omit `--yes` for an interactive tag confirmation. Use the same fail-closed
|
||
preflight without creating a tag or release with:
|
||
|
||
```powershell
|
||
npm run release:check -- v1.61.0-beta.4 --issues=63,64
|
||
```
|
||
|
||
The orchestrator requires a clean, synchronized `dev`, byte-identical bundle
|
||
snapshots and a completed green Validate for `HEAD`. Snapshot hashes and the
|
||
uploaded standalone JS are read from the exact Git blobs rather than checkout
|
||
bytes, so Windows CRLF conversion cannot disagree with the LF-tagged archive.
|
||
The archive command additionally forces `core.autocrlf=false` for that one
|
||
operation; it does not modify the developer's Git configuration.
|
||
It creates or verifies an
|
||
annotated exact-SHA tag, builds `houseplan.zip` directly from that committed
|
||
tree, verifies its manifest and embedded frontend against the candidate hash,
|
||
and creates `RELEASE-MEMBERSHIP.json`. Every explicitly supplied issue must have
|
||
an `Issue: #NN` trailer in the candidate history since the previous release;
|
||
issue authorship is irrelevant. The prerelease is staged as a draft and receives
|
||
both installable assets, the membership manifest and their `SHA256SUMS` passport.
|
||
Only then does it become public. The downloaded public bytes and membership are
|
||
verified against the candidate, followed by the paginated HACS order and
|
||
manifest-driven issue bookkeeping. Nothing else re-uploads assets after
|
||
publication (#540): the bytes it verified are the bytes that stay. Re-running
|
||
the same command after a partial failure is safe when the checkout still points
|
||
to the tagged candidate: stale assets are repaired, while a hidden per-release
|
||
comment marker, manifest membership and postcondition check make comment,
|
||
status-label removal and close individually repeatable (#547). ZIP inspection
|
||
is implemented in Node and does not depend on the host's `tar`/`unzip` variant.
|
||
A per-tag local lock and GitHub workflow
|
||
concurrency reject parallel runs; the local lock is removed on normal exit and
|
||
on handled `SIGHUP`/`SIGINT`/`SIGTERM` interruption (`SIGKILL` cannot be handled
|
||
by any process).
|
||
|
||
`Publish prerelease` in GitHub Actions is the browser/button equivalent. Select
|
||
the `dev` branch and enter the exact tag. It performs the same contract and
|
||
draft-first publication entirely on GitHub, including both assets. Prereleases
|
||
are intentionally silent in Telegram; only stable releases are announced.
|
||
GitHub exposes a `workflow_dispatch` button only after
|
||
the workflow file exists on the default branch; until the next promotion to
|
||
`main`, use the local command. The workflow snapshots the current S8 candidates
|
||
before publication, then retains only issues proven by Git history in the pinned
|
||
SHA. Its `close-merged` job consumes that immutable manifest after public asset
|
||
verification, so work merged later remains open and an accepted external issue
|
||
is treated like an owner-authored one. A retry reuses the published manifest and
|
||
resumes bookkeeping even when the release is already public (#120, #547).
|
||
|
||
**Independent line review (#638, `PROCESS.md` §11.5).** Right after the
|
||
candidate SHA is pinned, the `independent-review` job of `release.yml` queues
|
||
`.github/workflows/release-review.yml` on `dev` for the same tag and SHA. It
|
||
reviews every product surface changed since the previous stable tag against
|
||
`docs/SCOPE.md` and `docs/USER-GUIDE.ru.md` — without specs or review rounds —
|
||
and publishes `docs/reviews/RELEASE-REVIEW-vX.Y.Z.md` to `dev`. It runs in
|
||
parallel and **never blocks the release**: no release job needs it, a failure
|
||
is a warning, and the findings are the owner's call. Manual run:
|
||
`gh workflow run release-review.yml --ref dev -f tag=vX.Y.Z`; a repeat for a
|
||
tag whose document already exists is skipped unless `-f force=true`.
|
||
|
||
**Stable releases** go through `.github/workflows/release.yml`, the only
|
||
publisher of installable assets (#540). Run it with `workflow_dispatch` on
|
||
`main` with the exact tag: when the tag does not exist yet it is created on the
|
||
`main` tip; when it exists, its commit is the candidate. The workflow resolves
|
||
the tag to its exact commit, requires the `Release: <tag>` trailer on it,
|
||
checks the release contract (`release-contract.mjs --stable`) and
|
||
requires a complete Validate proof for its SHA and
|
||
Git tree (#541). The proof is tied to the workflow run ID and attempt and lists
|
||
both the requested checks and the jobs that actually executed. A skipped heavy job counts only
|
||
when its content-addressed reuse marker names an independently verified
|
||
successful source job. Since #573 the proof also carries composite evidence:
|
||
the identity of the product tree (every tracked path except the accepted
|
||
golden overlay `demo/golden/baselines/**`), the overlay itself (its Git tree,
|
||
the SHA-256 of `baselines-index.json` and either the run named by the commit's
|
||
`Baseline-Reviewed:` trailer or the attestation hash stored by a
|
||
`Baseline-Reviewed-Local:` commit) and the content key of every reusable job,
|
||
executed or reused. Release consumers standing on the candidate checkout
|
||
(`release-gate.mjs`, `release-prerelease.mjs`) recompute all of it locally and
|
||
fail closed on any mismatch, on a reused marker whose key is not the
|
||
candidate's, and on a declared review run that does not exist, was cancelled
|
||
or is not a Validate run; a proof without the block is stale for them. Review
|
||
and merge consumers pass no expectations, do not query the declared review run
|
||
and keep the #541 semantics unchanged. The
|
||
practical consequence is the beta.3 path: a candidate red only in golden,
|
||
then a baseline-only commit that reuses smoke, performance smoke, parity and
|
||
backend from the candidate's green jobs, skips every caught witness in the
|
||
mutation ledger and re-runs golden, preflight and frontend only. Review, merge and release use the
|
||
same `missing` / `pending` / `cancelled` / `stale` / `failed` state machine. A
|
||
cancelled or light run is not a release verdict. Any complete green full proof on the exact SHA
|
||
is sufficient: its content-addressed evidence remains valid
|
||
even when a later duplicate run fails (#511, #619). The release also requires Full
|
||
Performance and a green E2E run on a
|
||
real Home Assistant — `e2e-gate.mjs --ref=<sha>` dispatches `e2e.yml` in
|
||
`Matysh/houseplan-e2e` on the **candidate commit**, whose
|
||
`custom_components/houseplan` tree the suite installs from the codeload tarball
|
||
(#514, #540) — then builds once, archives `houseplan.zip` from that same tree
|
||
(`git archive <sha>:custom_components/houseplan`, deterministic), writes
|
||
`SHA256SUMS`, uploads everything into a draft, publishes, downloads the public
|
||
assets back and checks them against the passport, and only then announces. The
|
||
tree hash printed in the run summary is the identity between what E2E installed
|
||
and what HACS downloads.
|
||
|
||
Publishing a stable release by hand in the GitHub form still works, but
|
||
fail-closed: `release: published` starts the same workflow, which immediately
|
||
turns the release back into a draft and walks the same path; nothing installable
|
||
is public while the gates run. A red gate leaves the draft in place — open the
|
||
linked run, the Playwright traces and screenshots are in its artifacts; fix,
|
||
then cut a new tag. Re-dispatching the workflow on an already public tag is a
|
||
**repair**: the gates run again on the SHA, missing assets are added, and an
|
||
existing asset whose hash differs from the rebuilt one fails the run instead of
|
||
being replaced. Hand-published betas are ignored by this workflow — prereleases
|
||
have their own staged path above. Bump the version
|
||
everywhere in sync: `src/houseplan-card.ts` (CARD_VERSION), `package.json`,
|
||
`custom_components/houseplan/manifest.json`, `custom_components/houseplan/const.py`.
|
||
|
||
Validate intentionally runs on branch pushes, not tag pushes, so an annotated
|
||
release tag does not duplicate the expensive browser/performance matrix. Every
|
||
tagged SHA must therefore already be pushed to a branch and have a completed
|
||
green full exact-SHA Validate proof. For an owner-approved emergency hotfix, push a
|
||
temporary `hotfix/*` branch and wait for Validate before creating the tag;
|
||
never tag a detached or otherwise unpushed commit, because the release gate
|
||
will wait for a run that cannot exist and then fail closed after one hour.
|
||
|
||
The GitHub Release body is a concise **bilingual user summary**, not a copy of
|
||
the exhaustive changelog. Put Russian first and English second, with equivalent
|
||
meaning in both sections. Give separate bullets only to significant features
|
||
and user-visible behaviour changes. Collapse minor fixes, visual polish,
|
||
refactors, tests and purely internal improvements into one final bullet:
|
||
`Мелкие исправления и улучшения.` / `Small fixes and improvements.` Keep the
|
||
body short because HACS displays it inside Home Assistant and concatenates the
|
||
bodies of skipped releases. The full detail remains in both
|
||
`docs/CHANGELOG.ru.md` and `docs/CHANGELOG.md`; finish every release body with
|
||
two explicit links, one to each language version of the changelog.
|
||
|
||
Changelog entries may link directly to a **closed** GitHub Issue when that
|
||
issue is the canonical task for the shipped change. Append a normal Markdown
|
||
link such as `([#55](https://github.com/Matysh/houseplan-card/issues/55))` to
|
||
the relevant bullet in both language changelogs. Keep this optional: do not
|
||
invent issues for minor work, do not link open or partially delivered issues,
|
||
and do not expand the grouped small-fixes bullet into an issue inventory.
|
||
|
||
```md
|
||
<!-- release: vX.Y.Z -->
|
||
|
||
## Основное
|
||
- Значимое изменение.
|
||
- Исправлена конкретная проблема ([#123](https://github.com/Matysh/houseplan-card/issues/123)).
|
||
- Мелкие исправления и улучшения.
|
||
|
||
## Highlights
|
||
- Significant change.
|
||
- Fixed a specific problem ([#123](https://github.com/Matysh/houseplan-card/issues/123)).
|
||
- Small fixes and improvements.
|
||
|
||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/vX.Y.Z/docs/CHANGELOG.ru.md)
|
||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/vX.Y.Z/docs/CHANGELOG.md)
|
||
```
|
||
|
||
The literal `## Основное` and `## Highlights` headings above are the canonical
|
||
release-body format. Do not maintain a second `## Русский` / `## English`
|
||
template in scripts or release notes; change this single template if the
|
||
product format changes again.
|
||
|
||
Replace `vX.Y.Z` with the release tag so the links remain pinned to the
|
||
published version instead of drifting with `dev` or `main`.
|
||
|
||
Local release gates are deliberately different. A pre-release runs
|
||
`npm run build` plus only the unit tests and browser smokes selected for the
|
||
changed surfaces; record the exact selection in the release handoff. A stable
|
||
release runs the complete local frontend, backend and smoke gates before its
|
||
tag is created. The exact-SHA Validate required by `release.yml` remains in
|
||
force for both and can run a broader matrix automatically; the local policy
|
||
does not weaken the publication guard.
|
||
|
||
Feature promotion has an additional hard gate: every new feature or material
|
||
behaviour change must spend at least one published beta/RC before stable. A
|
||
stable release commit may change only version fields, generated bundle
|
||
snapshots and changelog/release metadata; feature source changes belong in the
|
||
preceding pre-release commit. Skip this step only for an explicit owner-approved
|
||
emergency hotfix, and document the exception in the handoff.
|
||
|
||
## Reproducible scripts (data)
|
||
|
||
- Extracting the geometry/backgrounds from the prototype and generating `src/data/*` — see the commit
|
||
history and docs/ARCHITECTURE.md (SVG→base-space transforms: f1 0.647/(490,27), f2 0.896/(351,21)).
|
||
- Room fitting: render the plan with rectangles overlaid (cv2) → snap to walls → manual fine-tuning.
|
||
|
||
## Production objects in HA (the dacha)
|
||
|
||
- Dashboard `plan-doma`, panel view, card `custom:houseplan-card` (icon_size 2.5).
|
||
- The houseplan integration: entry loaded, `.storage/houseplan.layout` — the layout (server-side).
|
||
- The old prototype `/config/www/houseplan/` (iframe) is kept as a fallback, do not touch.
|
||
- configuration.yaml backups: `.bak-avgtemp` (before the average-temperature sensor edit).
|
||
|
||
|
||
## Smoke tests (since 2026-07-27)
|
||
|
||
Every `demo/smoke_*.mjs` ends with:
|
||
|
||
```js
|
||
checkAll(out); // every key must be true...
|
||
checkAll(out, { n: 4 }); // ...unless an expected value is given
|
||
await finish(browser, out);
|
||
```
|
||
|
||
`finish` prints the JSON dump (useful on failure), reports named mismatches and
|
||
sets a non-zero exit code — including when the card threw during the run. The
|
||
suite runs in CI (`smoke` job) against a freshly built bundle; never test the
|
||
`demo/srv/assets/houseplan-card.js` copy, which `npm run bundle:sync` writes and which is not committed (#255).
|
||
|
||
`updateComplete` proves only that Lit finished its own update. Pointer-owned
|
||
editor state can be painted later by `live-editor`, outside that cycle. Browser
|
||
checks which read live editor DOM must await the lazy runtime's
|
||
`_whenLiveEditorSettled()` contract; a timeout, sleep or fixed number of RAFs is
|
||
not evidence that the latest projection was applied. If a smoke changes editor
|
||
mode before dispatching synthetic gestures, it must also wait for the observable
|
||
mode-transition and viewport-refit state to settle, because the stage can finish
|
||
its physical resize after the Lit update (#460).
|
||
|
||
When adding a checklist line marked `[auto: ...]` in docs/TESTING.md or its
|
||
appendices in docs/testing-notes/, add the failing check in the same commit —
|
||
that is what the marker now promises.
|