diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml
index 19991da3..126e34db 100644
--- a/.github/workflows/publish-prerelease.yml
+++ b/.github/workflows/publish-prerelease.yml
@@ -82,17 +82,13 @@ jobs:
set -euo pipefail
npm ci
npm run build
- cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
+ node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
+ npm run bundle:budget
VERSION=${TAG#v}
- grep -Fq "$VERSION" dist/houseplan-card.js
+ grep -RFq "$VERSION" dist
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
- unzip -l houseplan.zip | grep -q "manifest.json"
- ZIP_VERSION=$(unzip -p houseplan.zip manifest.json | node -e \
- "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).version))")
- test "$ZIP_VERSION" = "$VERSION" || {
- echo "::error::houseplan.zip manifest version $ZIP_VERSION != $VERSION"
- exit 1
- }
+ 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
diff --git a/.github/workflows/release-zip.yml b/.github/workflows/release-zip.yml
index a2545897..0ed0fed2 100644
--- a/.github/workflows/release-zip.yml
+++ b/.github/workflows/release-zip.yml
@@ -34,7 +34,7 @@ jobs:
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
run: cd custom_components/houseplan && zip -qr ../../houseplan.zip .
- name: Sanity check
- run: unzip -l houseplan.zip | grep -q "manifest.json"
+ run: node scripts/verify-houseplan-zip.mjs houseplan.zip custom_components/houseplan/frontend
- name: Upload asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 703683cc..ae69e9c1 100755
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -67,7 +67,8 @@ jobs:
with:
name: continuity-screencast
path: artifacts/continuity-screencast
- - run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
+ - 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:
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index 7afbe883..72adefc4 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -314,9 +314,11 @@ jobs:
run: npm test
- name: Build
run: npm run build
- # Копия стенда больше не коммитится (#255): сверяются две обязательные.
- - name: Card bundle snapshots in sync
- run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
+ # 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.
@@ -328,7 +330,7 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: card-bundle
- path: dist/houseplan-card.js
+ path: dist/
retention-days: 1
if-no-files-found: error
diff --git a/.gitignore b/.gitignore
index 614e6d97..9cbbc397 100755
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,5 @@ artifacts/
# она только росла — 364 версии по 1.16 МБ за семь недель (#255). Обязательных
# копий две: `dist/` (артефакт сборки) и `custom_components/` (её ставит HACS).
demo/srv/assets/houseplan-card.js
+demo/srv/assets/houseplan-assets.json
+demo/srv/assets/houseplan-assets/
diff --git a/AGENTS.md b/AGENTS.md
index 899a16b3..ad58e5f5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,7 +2,8 @@
House Plan is one HACS package with two parts plus a demo harness:
-- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to `dist/houseplan-card.js`.
+- **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.
@@ -93,7 +94,7 @@ Start with the spec?" is the correct answer, not a smaller patch.
| **A — product** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, i18n, `custom_components/**/translations/**` | yes |
| **B — gates and tooling** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, `.github/workflows/**`, `rollup.config.mjs`, `tsconfig*.json` | yes; may reuse the issue it covers |
| **C — documentation** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md` | not if it is part of its issue's DoD |
-| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` | never changes on its own. The stand copy `demo/srv/assets/houseplan-card.js` is no longer committed (#255): build it with `npm run bundle:sync` |
+| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` | never changes on its own. The stand copy `demo/srv/assets/**` 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`,
@@ -325,12 +326,12 @@ npm run inventory # the only correct way to get test counts
Never copy test counts into documents by hand; they go stale in days.
-After building, keep all three bundle snapshots in sync — CI compares them
-byte-for-byte:
+After building, keep the complete manifest-driven bundle trees in sync — CI
+verifies every listed file byte-for-byte:
```
-cp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
npm run bundle:sync # dist → custom_components + demo/srv/assets (#255)
+npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
```
During the implementation cycle the fast gates always run. Since 2026-08-14 the
diff --git a/README.md b/README.md
index ad0018a5..d488e27f 100755
--- a/README.md
+++ b/README.md
@@ -108,8 +108,10 @@ serve that path as a JavaScript module.
### Manual installation
-Copy `custom_components/houseplan` to `config/custom_components`, restart Home
-Assistant, and add the House Plan integration.
+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
diff --git a/README.ru.md b/README.ru.md
index bd6723aa..8d8fe7cf 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -112,8 +112,10 @@ resources:
### Вручную
-Скопируйте `custom_components/houseplan` в `config/custom_components`,
-перезапустите Home Assistant и добавьте интеграцию House Plan.
+Скопируйте всю папку `custom_components/houseplan` из релиза в
+`config/custom_components`, перезапустите Home Assistant и добавьте интеграцию
+House Plan. Одного `houseplan-card.js` недостаточно: карточке также нужны
+внутренний манифест и хешированные модули из того же релиза.
### Добавление карточки
diff --git a/custom_components/houseplan/__init__.py b/custom_components/houseplan/__init__.py
index b3e32251..874a1e92 100755
--- a/custom_components/houseplan/__init__.py
+++ b/custom_components/houseplan/__init__.py
@@ -39,10 +39,12 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
hass.data.setdefault(DOMAIN, {})
hp_ws.async_register(hass)
from .http_api import HouseplanContentView, HouseplanImportPreviewView, HouseplanUploadView
+ from .frontend_assets import HouseplanFrontendAssetView
hass.http.register_view(HouseplanUploadView())
hass.http.register_view(HouseplanContentView())
hass.http.register_view(HouseplanImportPreviewView())
+ hass.http.register_view(HouseplanFrontendAssetView())
return True
@@ -98,7 +100,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
# NOTE (audit B1): plans and marker files are NO LONGER static.
# They are served by HouseplanContentView, which requires auth.
- # Only the card bundle stays public — Lovelace resources must be.
+ # Only the entry and manifest-gated JS chunks stay public —
+ # Lovelace modules must be loadable without an auth header.
if static_paths:
await hass.http.async_register_static_paths(static_paths)
except ImportError: # very old HA versions
diff --git a/custom_components/houseplan/const.py b/custom_components/houseplan/const.py
index 4636b5c9..0e8ae3ad 100755
--- a/custom_components/houseplan/const.py
+++ b/custom_components/houseplan/const.py
@@ -7,6 +7,7 @@ STORAGE_VIRTUAL_LIGHTS_KEY = f"{DOMAIN}.virtual_lights"
STORAGE_VERSION = 1
STORAGE_MINOR_VERSION = 2
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
+FRONTEND_ASSETS_URL = "/houseplan_files/houseplan-assets"
PLANS_URL = "/houseplan_files/plans"
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files"
diff --git a/custom_components/houseplan/frontend/houseplan-assets.json b/custom_components/houseplan/frontend/houseplan-assets.json
new file mode 100644
index 00000000..787d910d
--- /dev/null
+++ b/custom_components/houseplan/frontend/houseplan-assets.json
@@ -0,0 +1,98 @@
+{
+ "schema": 1,
+ "fingerprint": "f111431804c4c42c6e35286f29720d089bc8b77ffc364a6517f7a3510fcb11c8",
+ "entry": "houseplan-card.js",
+ "initialViewFiles": [
+ "houseplan-assets/houseplan-card-Cs2QHIcW.js",
+ "houseplan-card.js"
+ ],
+ "initialViewGzipBytes": 255385,
+ "lazyFiles": [
+ "houseplan-assets/editor-BC5jpMNv.js",
+ "houseplan-assets/houseplan-editor-runtime-DUuA4F8c.js",
+ "houseplan-assets/houseplan-onboarding-runtime-Bj55plb1.js",
+ "houseplan-assets/space-deletion-CJ8Co_o8.js"
+ ],
+ "lazyGzipBytes": 138414,
+ "lazyEditorFiles": [
+ "houseplan-assets/editor-BC5jpMNv.js",
+ "houseplan-assets/houseplan-editor-runtime-DUuA4F8c.js",
+ "houseplan-assets/space-deletion-CJ8Co_o8.js"
+ ],
+ "lazyEditorGzipBytes": 131768,
+ "lazyOnboardingFiles": [
+ "houseplan-assets/houseplan-onboarding-runtime-Bj55plb1.js",
+ "houseplan-assets/space-deletion-CJ8Co_o8.js"
+ ],
+ "lazyOnboardingGzipBytes": 7200,
+ "files": [
+ {
+ "path": "houseplan-assets/editor-BC5jpMNv.js",
+ "sha256": "f10e45b5a98ab0efc6ceb3c4e08acc4f57a9cb5f99d178b723077a2cb011e05b",
+ "rawBytes": 3699,
+ "gzipBytes": 1534,
+ "isEntry": false,
+ "imports": [
+ "houseplan-assets/houseplan-card-Cs2QHIcW.js"
+ ],
+ "dynamicImports": []
+ },
+ {
+ "path": "houseplan-assets/houseplan-card-Cs2QHIcW.js",
+ "sha256": "caa02ba0654e191f72d9b7c9b139c078bb6edb84df4b3e2ec263be492009ecd2",
+ "rawBytes": 908483,
+ "gzipBytes": 255201,
+ "isEntry": false,
+ "imports": [],
+ "dynamicImports": [
+ "houseplan-assets/editor-BC5jpMNv.js",
+ "houseplan-assets/houseplan-editor-runtime-DUuA4F8c.js",
+ "houseplan-assets/houseplan-onboarding-runtime-Bj55plb1.js"
+ ]
+ },
+ {
+ "path": "houseplan-assets/houseplan-editor-runtime-DUuA4F8c.js",
+ "sha256": "0e053b445f87c6c7fdb2b681863e148976ab703221f4988d93f80ca825bd4051",
+ "rawBytes": 504648,
+ "gzipBytes": 129680,
+ "isEntry": false,
+ "imports": [
+ "houseplan-assets/houseplan-card-Cs2QHIcW.js",
+ "houseplan-assets/space-deletion-CJ8Co_o8.js"
+ ],
+ "dynamicImports": []
+ },
+ {
+ "path": "houseplan-assets/houseplan-onboarding-runtime-Bj55plb1.js",
+ "sha256": "32b8812031403d65f909ca236f431a3eeefc9bde9a07499ee123925331f75982",
+ "rawBytes": 26356,
+ "gzipBytes": 6646,
+ "isEntry": false,
+ "imports": [
+ "houseplan-assets/houseplan-card-Cs2QHIcW.js",
+ "houseplan-assets/space-deletion-CJ8Co_o8.js"
+ ],
+ "dynamicImports": []
+ },
+ {
+ "path": "houseplan-assets/space-deletion-CJ8Co_o8.js",
+ "sha256": "59ab0af663f25f859d7bbe3a8893cd00757a40117ca4b9e08d6846f0b2dfd3fc",
+ "rawBytes": 911,
+ "gzipBytes": 554,
+ "isEntry": false,
+ "imports": [],
+ "dynamicImports": []
+ },
+ {
+ "path": "houseplan-card.js",
+ "sha256": "53623e0fa5ef93003fc02a66b9932d847c8c68b5345233f9ed6c61f1e470d86a",
+ "rawBytes": 190,
+ "gzipBytes": 184,
+ "isEntry": true,
+ "imports": [
+ "houseplan-assets/houseplan-card-Cs2QHIcW.js"
+ ],
+ "dynamicImports": []
+ }
+ ]
+}
diff --git a/custom_components/houseplan/frontend/houseplan-assets/editor-BC5jpMNv.js b/custom_components/houseplan/frontend/houseplan-assets/editor-BC5jpMNv.js
new file mode 100644
index 00000000..8d27e2b9
--- /dev/null
+++ b/custom_components/houseplan/frontend/houseplan-assets/editor-BC5jpMNv.js
@@ -0,0 +1,14 @@
+globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f111431804c4c42c6e35286f29720d089bc8b77ffc364a6517f7a3510fcb11c8";import{c as e,l as o,t,d2 as s,A as a,b as l}from"./houseplan-card-Cs2QHIcW.js";class i extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],l=this._floorToken;l&&a.push({value:l,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const i="string"==typeof this._config?.floor?this._config.floor:"";i&&!e.some(e=>e.value===i)&&a.push({value:i,label:i}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;this._loadSpaces();const e=this._lang,o={title:t(e,"editor.title"),floor:t(e,"editor.floor"),default_floor:t(e,"editor.default_floor"),language:t(e,"editor.language"),icon_size:t(e,"editor.icon_size"),show_temperature:t(e,"editor.show_temperature"),live_states:t(e,"editor.live_states"),show_signal:t(e,"editor.show_signal"),kiosk:t(e,"editor.kiosk"),cycle:t(e,"editor.cycle")},s=this._schema,i=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),n=e=>l`
${this._t("fixed_floor.loading")}
+${this._t("fixed_floor.invalid_title")}
+${this._t("fixed_floor.invalid_body",{value:this._fixedFloorValue(r.value)})}
+${this._t("empty.no_spaces")}
+ ${this._serverStorage?B`${this._t("empty.add_first")}
+ `:B`${this._t("empty.install")}
`} +${this._t("vac.residual_message",{error:this._vacCalConfirm.error})}
+${this._tapConfirm.text}
`:this._tapConfirm.lines.map((e,t)=>B` +${e}
`)} +${this._t("opening.no_entities")}
`} +${this._reason(t.fallbackReason)}
`:a} + ${t.explanation.notices.map(t=>o`${this._reason(t)}
`)} + ${p<1?o`${this._t("marker.preview.scaled",{n:b})}
`:a} + ${e&&this._reducedMotion?o`${this._t("marker.preview.reduced_motion")}
`:a} +${t.eid}
+ ${t.stateText||t.state}
+ ${this._sourceProvider(t.eid)}
+