perf: lazy-load editor runtime

Issue: #337
User-Visible: yes
This commit is contained in:
Matysh
2026-08-28 08:29:44 +03:00
parent 4a61407282
commit 59be9c8565
83 changed files with 32499 additions and 26465 deletions
+5 -9
View File
@@ -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
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
run: cd custom_components/houseplan && zip -qr ../../houseplan.zip .
- name: Sanity check
run: 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 }}
+2 -1
View File
@@ -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:
+6 -4
View File
@@ -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
+2
View File
@@ -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/
+6 -5
View File
@@ -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
+4 -2
View File
@@ -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
+4 -2
View File
@@ -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` недостаточно: карточке также нужны
внутренний манифест и хешированные модули из того же релиза.
### Добавление карточки
+4 -1
View File
@@ -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
+1
View File
@@ -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"
@@ -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": []
}
]
}
@@ -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`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>o[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return l`
${n(s.slice(0,3))}
${i?l`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(e,"editor.default_floor_missing",{id:i})}
</div>`:a}
${n(s.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}i.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",i);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f111431804c4c42c6e35286f29720d089bc8b77ffc364a6517f7a3510fcb11c8";export{t as a,e as c,o as i,r as s,s as t};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,37 @@
"""Pure manifest/path resolver for generated frontend assets."""
from __future__ import annotations
import json
from pathlib import Path
_MANIFEST = "houseplan-assets.json"
_ASSET_DIR = "houseplan-assets"
def resolve_frontend_asset(frontend_root: Path, filename: object) -> Path | None:
"""Resolve one generated JS asset only when the current manifest lists it."""
if not isinstance(filename, str) or not filename or filename != Path(filename).name:
return None
if "/" in filename or "\\" in filename or filename in {".", ".."}:
return None
if not filename.endswith(".js"):
return None
try:
manifest = json.loads((frontend_root / _MANIFEST).read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return None
if not isinstance(manifest, dict) or manifest.get("schema") != 1:
return None
expected = f"{_ASSET_DIR}/{filename}"
listed = {
item.get("path")
for item in manifest.get("files", [])
if isinstance(item, dict)
}
if expected not in listed:
return None
asset_root = (frontend_root / _ASSET_DIR).resolve()
candidate = (asset_root / filename).resolve()
if candidate.parent != asset_root or not candidate.is_file():
return None
return candidate
@@ -0,0 +1,36 @@
"""Public, manifest-gated JavaScript chunks for the Lovelace card."""
from __future__ import annotations
from pathlib import Path
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
try:
from homeassistant.components.http import KEY_HASS
except ImportError: # pragma: no cover - old HA compatibility
KEY_HASS = "hass" # type: ignore[assignment]
from .const import FRONTEND_ASSETS_URL
from .frontend_asset_manifest import resolve_frontend_asset
_FRONTEND_ROOT = Path(__file__).parent / "frontend"
class HouseplanFrontendAssetView(HomeAssistantView):
"""Serve only generated chunks named by the current build manifest."""
url = f"{FRONTEND_ASSETS_URL}/{{filename}}"
name = "api:houseplan:frontend-asset"
requires_auth = False
async def get(self, request: web.Request, filename: str) -> web.StreamResponse:
hass = request.app[KEY_HASS]
path = await hass.async_add_executor_job(
resolve_frontend_asset, _FRONTEND_ROOT, filename
)
if path is None:
raise web.HTTPNotFound()
return web.FileResponse(
path,
headers={"Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff"},
)
+27 -4
View File
@@ -1,5 +1,6 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { relative, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
@@ -13,17 +14,39 @@ const fingerprintForTree = async (root) => {
return module.sourceFingerprint(root);
};
const verifyManifestTree = (root, expected) => {
const assetRoot = resolve(root, 'demo/srv/assets');
const manifestPath = resolve(assetRoot, 'houseplan-assets.json');
// Comparative benchmarks can target releases from before the multi-asset
// contract. Their own embedded fingerprint remains the legacy authority.
if (!existsSync(manifestPath)) return;
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (manifest?.schema !== 1 || manifest.fingerprint !== expected
|| !Array.isArray(manifest.files)) {
throw new Error('demo bundle manifest is stale or malformed; run npm run bundle:sync');
}
for (const file of manifest.files) {
const path = resolve(assetRoot, String(file?.path || ''));
const rel = relative(assetRoot, path);
if (!rel || rel.startsWith('..') || rel.includes(':') || !existsSync(path)) {
throw new Error(`demo bundle manifest asset is missing or unsafe: ${file?.path}`);
}
const hash = createHash('sha256').update(readFileSync(path)).digest('hex');
if (hash !== file.sha256) throw new Error(`demo bundle asset hash mismatch: ${file.path}`);
}
};
/** Refuse measurements/screenshots made by a committed bundle from old source. */
export async function assertFreshDemoBundle(page, root = process.cwd()) {
// A comparative performance run may load an older tree whose fingerprint
// contract is intentionally different from the candidate's. Validate that
// tree with the implementation that built it, not with today's algorithm.
const expected = await fingerprintForTree(root);
verifyManifestTree(root, expected);
const loaded = await page.evaluate(() => globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__ ?? null);
if (loaded !== expected) {
throw new Error(
'demo/srv/assets/houseplan-card.js is stale. Run npm run build and copy '
+ 'dist/houseplan-card.js to demo/srv/assets/houseplan-card.js first. '
'demo/srv/assets bundle tree is stale. Run npm run bundle:sync first. '
+ `Expected ${expected}, loaded ${loaded || 'no fingerprint'}.`,
);
}
+8 -1
View File
@@ -773,6 +773,12 @@ export async function prepareGoldenScenario(page, scenario) {
const expectedDevices = Object.keys(fixture.devices || {}).length;
if (expectedDevices) await until(() => card._devices?.length >= expectedDevices);
await until(() => card._booting === false);
// Golden scenarios intentionally call internal editor commands directly.
// Preload the lazy runtime for that legacy harness contract; cold-View
// loading and retry semantics are covered by smoke_lazy_editor_chunk.
if (!(await card._ensureEditorRuntime())) {
throw new Error(`golden editor runtime failed to load: ${scenario.id}`);
}
await frame();
return card;
};
@@ -1492,7 +1498,8 @@ export async function prepareGoldenScenario(page, scenario) {
});
}
const raw = scenario.cardEditorInvalidDefaultFloor;
const editor = document.createElement('houseplan-card-editor');
const cardClass = customElements.get('houseplan-card');
const editor = await cardClass.getConfigElement();
editor.setConfig({
type: 'custom:houseplan-card', title: 'Golden invalid floor',
language: scenario.language || 'en', default_floor: raw,
+13 -2
View File
@@ -44,7 +44,8 @@ export async function finish(browser, out) {
}
}
export async function launch(
async function launchInternal(
preloadEditorRuntime,
viewport = { width: 820, height: 760 },
scale = 1,
browserArgs = [],
@@ -57,7 +58,7 @@ export async function launch(
viewport, deviceScaleFactor: scale, ...contextOptions,
})).newPage();
// audit T1: an exception inside the card used to be logged and ignored
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.message); });
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.stack || e.message); });
await page.route('**/*', (r) => {
const u = new URL(r.request().url());
let p = decodeURIComponent(u.pathname);
@@ -84,5 +85,15 @@ export async function launch(
// hass flows continuously in production; the stub sets it once — nudge a rebuild
await page.evaluate(() => { const c = window.__card; c.hass = { ...c.hass }; });
await page.waitForFunction(() => window.__card._devices.length > 0, { timeout: 9000 });
// Existing product smokes exercise editor internals directly. They preload
// the new #337 runtime without changing mode; the dedicated network smoke
// uses launchColdView() and proves the real cold-View boundary separately.
if (preloadEditorRuntime) {
const ready = await page.evaluate(() => window.__card._ensureEditorRuntime());
if (!ready) throw new Error('editor runtime did not preload for browser smoke');
}
return { page, browser };
}
export const launch = (...args) => launchInternal(true, ...args);
export const launchColdView = (...args) => launchInternal(false, ...args);
+150
View File
@@ -0,0 +1,150 @@
// #337: the display-only card must stay independent from the editor runtime.
// Exercise the production bundle, including its content-hashed retry URL.
import { readFileSync } from 'node:fs';
import { launchColdView, checkAll, finish } from './serve.mjs';
const manifest = JSON.parse(readFileSync('dist/houseplan-assets.json', 'utf8'));
const runtimePath = manifest.files
.map((file) => file.path)
.find((path) => /houseplan-editor-runtime-[^/]+\.js$/.test(path));
if (!runtimePath) throw new Error('editor runtime is absent from the bundle manifest');
const runtimeName = runtimePath.split('/').at(-1);
const runtimeUrlPattern = `**/${runtimeName}*`;
const runtimeFile = `demo/srv/assets/${runtimePath}`;
const onboardingPath = manifest.files
.map((file) => file.path)
.find((path) => /houseplan-onboarding-runtime-[^/]+\.js$/.test(path));
if (!onboardingPath) throw new Error('onboarding runtime is absent from the bundle manifest');
const onboardingName = onboardingPath.split('/').at(-1);
const clickEditor = async (page, index, mode) => {
await page.locator('houseplan-card').evaluate((card, tabIndex) => {
const root = card.shadowRoot || card.renderRoot;
root.querySelectorAll('.modetab')[tabIndex]?.click();
}, index);
await page.waitForFunction((expected) => window.__card._mode === expected, mode);
await page.waitForFunction(() => window.__card._modeTransitionBusy === false);
};
const { page, browser } = await launchColdView();
const requested = [];
page.on('request', (request) => requested.push(new URL(request.url()).pathname));
const initialResources = await page.evaluate(() => performance.getEntriesByType('resource')
.map((entry) => new URL(entry.name).pathname));
const out = {
editorAbsentBeforeIntent: !initialResources.some((path) => path.endsWith(`/${runtimeName}`)),
};
await clickEditor(page, 0, 'plan');
await clickEditor(page, 1, 'devices');
await clickEditor(page, 2, 'decor');
out.oneRuntimeRequestForAllEditors = requested.filter((path) => path.endsWith(`/${runtimeName}`)).length === 1;
out.allEditorsUseInstalledRuntime = await page.evaluate(() =>
window.__card._editorRuntimeLoader.state === 'ready' && window.__card._mode === 'decor');
// Empty-install onboarding is a separate lazy surface. It may fetch its own
// dialog chunk, but must not fetch or install the editor until Save explicitly
// continues into Plan mode.
const onboarding = await launchColdView();
const onboardingRequests = [];
onboarding.page.on('request', (request) => {
onboardingRequests.push(new URL(request.url()).pathname);
});
await onboarding.page.evaluate(async () => {
const card = window.__card;
card._onboardingShown = false;
card._serverCfg = { ...card._serverCfg, spaces: [] };
card._model = [];
card.hass = { ...card.hass, floors: {}, areas: {} };
card.requestUpdate();
await card.updateComplete;
});
await onboarding.page.waitForFunction(() => {
const card = window.__card;
return card._onboardingRuntime && card.renderRoot.querySelector('hp-dialog');
});
out.onboardingUsesOwnChunk = onboardingRequests
.filter((path) => path.endsWith(`/${onboardingName}`)).length === 1;
out.onboardingDoesNotLoadEditor = onboardingRequests
.every((path) => !path.endsWith(`/${runtimeName}`))
&& await onboarding.page.evaluate(() => !window.__card._editorRuntime);
await onboarding.page.evaluate(async () => {
const card = window.__card;
const root = card.renderRoot;
const title = root.querySelector('hp-dialog input.namein');
title.value = 'Cold onboarding';
title.dispatchEvent(new InputEvent('input', { bubbles: true }));
await card.updateComplete;
const sources = root.querySelectorAll('hp-dialog input[name="plansrc"]');
sources[1].click();
await card.updateComplete;
const buttons = [...root.querySelectorAll('hp-dialog button')];
buttons.find((button) => button.textContent.includes(card._t('btn.save')))?.click();
});
await onboarding.page.waitForFunction(() => {
const card = window.__card;
return card._editorRuntime && card._mode === 'plan'
&& card._serverCfg.spaces.some((space) => space.title === 'Cold onboarding');
});
out.onboardingSaveContinuesToPlan = onboardingRequests
.filter((path) => path.endsWith(`/${runtimeName}`)).length === 1;
const gui = await launchColdView();
out.guiEditorLoadsAsynchronously = await gui.page.evaluate(async () => {
const ctor = customElements.get('houseplan-card');
const editor = await ctor.getConfigElement();
return editor?.localName === 'houseplan-card-editor';
});
// Two network failures are terminal for this page. View stays live and the
// retry is the same immutable chunk with a cache-busting query string.
const failed = await launchColdView();
let failedRequests = 0;
await failed.page.route(runtimeUrlPattern, async (route) => {
failedRequests += 1;
await route.abort('failed');
});
await failed.page.locator('houseplan-card').evaluate((card) => {
const root = card.shadowRoot || card.renderRoot;
root.querySelectorAll('.modetab')[0]?.click();
});
await failed.page.waitForFunction(() => window.__card._editorRuntimeLoader.state === 'failed');
out.networkFailureRetriesExactlyOnce = failedRequests === 2;
out.networkFailureKeepsView = await failed.page.evaluate(() => {
const card = window.__card;
return card._mode === 'view'
&& !card._editorRuntime
&& card._toast.includes(card._t('editor.load_failed'))
&& card._toast.includes(card._t('editor.refresh_advice'));
});
// A valid module from a different build is no safer than a 404. Both attempts
// are fulfilled deliberately so this checks the fingerprint handshake rather
// than the network branch above.
const mismatch = await launchColdView();
let mismatchRequests = 0;
const incompatibleRuntime = readFileSync(runtimeFile, 'utf8')
.replaceAll(manifest.fingerprint, `${manifest.fingerprint}-mismatch`);
await mismatch.page.route(runtimeUrlPattern, async (route) => {
mismatchRequests += 1;
await route.fulfill({
status: 200,
contentType: 'text/javascript',
body: incompatibleRuntime,
});
});
await mismatch.page.locator('houseplan-card').evaluate((card) => {
const root = card.shadowRoot || card.renderRoot;
root.querySelectorAll('.modetab')[1]?.click();
});
await mismatch.page.waitForFunction(() => window.__card._editorRuntimeLoader.state === 'failed');
out.fingerprintMismatchRetriesExactlyOnce = mismatchRequests === 2;
out.fingerprintMismatchKeepsView = await mismatch.page.evaluate(() =>
window.__card._mode === 'view' && !window.__card._editorRuntime);
await failed.browser.close();
await mismatch.browser.close();
await onboarding.browser.close();
await gui.browser.close();
checkAll(out);
await finish(browser, out);
+98
View File
@@ -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": []
}
]
}
+14
View File
@@ -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`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>o[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return l`
${n(s.slice(0,3))}
${i?l`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(e,"editor.default_floor_missing",{id:i})}
</div>`:a}
${n(s.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}i.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",i);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
function e(e,o){return{id:e,title:o,plan_url:null,view_box:[0,0,1,1],rooms:[],wall_segments:[]}}function o(e="file"){const o="draw"===e;return{source:e,showBorders:o,showNames:o,displayTouched:!1}}function r(e,o){if(e.displayTouched)return{...e,source:o};const r="draw"===o;return{...e,source:o,showBorders:r,showNames:r}}function s(e,o,r){return{...e,[o]:r,displayTouched:!0}}function t(e,o,r){const s=(e?.spaces||[]).find(e=>e?.id===r),t=new Set((s?.rooms||[]).map(e=>String(e?.id||"")).filter(Boolean)),n=[...new Set((e?.markers||[]).filter(e=>!0!==e?.removed&&"string"==typeof e?.id).filter(e=>e.space===r||"string"==typeof e.room_id&&t.has(e.room_id)||o?.[e.id]?.s===r).map(e=>e.id))].sort((e,o)=>e.localeCompare(o));return{markerIds:n,count:n.length}}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f111431804c4c42c6e35286f29720d089bc8b77ffc364a6517f7a3510fcb11c8";export{t as a,e as c,o as i,r as s,s as t};
+1 -7763
View File
File diff suppressed because one or more lines are too long
+15 -6
View File
@@ -22,7 +22,10 @@ media-wrapper survival) are pinned by `test/styles-split.test.mjs`, and
```
houseplan-card/
├─ src/ # card sources (TypeScript + Lit 3)
│ ├─ houseplan-card.ts # the card: rendering, states, drag, tooltip, sticky header
│ ├─ houseplan-card.ts # eager View shell, HA lifecycle and projection
│ ├─ editor-runtime-loader.ts # lazy loader: dedupe, retry and build handshake
│ ├─ houseplan-editor-runtime.ts # Plan/Devices/Background composition root
│ ├─ houseplan-onboarding-runtime.ts # first-space/import dialogs, independent of editor
│ ├─ hp-dialog.ts # shared HA/native modal shell, focus and transient-overlay lifecycle
│ ├─ hp-help.ts # presentation-only, localized contextual-help surface
│ ├─ floating-surface.ts # pure visual-viewport flip/shift geometry for dialog surfaces
@@ -37,7 +40,7 @@ houseplan-card/
│ └─ data/
│ ├─ house.ts # geometry: ROOMS (rooms→area), FLOOR_VB (viewBox), names
│ └─ backgrounds.ts # VECTOR plans (SVG base64) + FLOOR_BG_RECT (positioning)
├─ dist/houseplan-card.js # build (rollup+terser), ~290 KB, plans embedded
├─ dist/ # entry + manifest + content-hashed JS chunks
├─ demo/golden/ # deterministic HP-QA-01 matrix, capture/verify/accept
├─ demo/performance/ # large-house budgets and same-runner comparison
├─ scripts/release-*.mjs # exact-SHA publication contract and local orchestrator
@@ -54,10 +57,16 @@ houseplan-card/
└─ docs/ # this documentation
```
Rollup embeds a source fingerprint in the bundle. Performance and golden-image
tooling compares it with the current `src/` tree before recording results, so a
committed demo snapshot from an older source revision cannot produce a false
baseline.
Rollup embeds one source fingerprint in the eager entry and both lazy runtimes.
`dist/houseplan-assets.json` records the import graph, sizes and SHA-256 of every
generated asset. View loads only the initial graph; Plan, Devices and Background
share one editor runtime loaded on first intent. An empty installation loads a
separate onboarding dialog chunk, so creating the first space does not require
the editor asset; saving it then continues into Plan as before. The backend keeps the public
entry URL stable and serves only manifest-listed JS basenames below
`/houseplan_files/houseplan-assets/`. Performance, golden and smoke tooling
verify the manifest and every asset before recording results, so a stale or
partially copied tree cannot produce a false baseline.
Prerelease publication has one fail-closed contract shared by the local command
and the manual GitHub workflow. The tag version must match all six shipped
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- View now starts without downloading the three editors; their shared runtime
is loaded on first use and protected by one retry and a build-version check.
Release and test tooling now verify the complete multi-file frontend asset
tree rather than one JavaScript file
([#337](https://github.com/Matysh/houseplan-card/issues/337)).
- The wall-junction checks are honest at the boundaries: false refusals on
microscopic coordinate noise are gone, very long walls no longer crash the
check, and an internal check error refuses the write instead of waving it
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Режим просмотра теперь запускается без загрузки трёх редакторов: их общий
runtime подгружается при первом обращении и защищён повторной попыткой и
проверкой версии сборки. Релизные и тестовые инструменты проверяют всё дерево
frontend-ассетов, а не один JavaScript-файл
([#337](https://github.com/Matysh/houseplan-card/issues/337)).
- Проверки стыков стен стали честными на границах: ложные отказы на
микроскопических погрешностях координат устранены, очень длинные стены не
роняют проверку, а внутренняя ошибка проверки больше не пропускает запись
+16 -8
View File
@@ -102,10 +102,16 @@ git config core.untrackedCache true
- 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`: `npm run build`
writes `dist/houseplan-card.js` only, while the release contract also requires the
committed `custom_components/houseplan/frontend/houseplan-card.js` snapshot to be
byte-identical. The demo copy is refreshed by the same command but remains untracked.
- 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.
## Maintenance diagnostics
@@ -172,9 +178,9 @@ commands and the explicit review workflow are documented in
```bash
cd /tmp/hpc && npm ci # once
npx rollup -c # → dist/houseplan-card.js
node --check dist/houseplan-card.js
cp dist/houseplan-card.js custom_components/houseplan/frontend/
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)
@@ -184,7 +190,9 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
- **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".
- JS: `scp -P 22222 -i <key> dist/houseplan-card.js root@ha.jbstudio.pro:/mnt/data/supervisor/homeassistant/custom_components/houseplan/frontend/`
- 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.
+20
View File
@@ -3255,6 +3255,26 @@ require hands on real hardware — they remain for the human pass.
Popover and forced portal fallback have the same no-layout-shift contract
[auto: `smoke_help_affordance`].
## Lazy editor runtime and frontend asset tree (#337)
- [ ] A cold configured View reaches a complete interactive frame without any
request for `houseplan-editor-runtime-*.js`. The first Plan/Devices/
Background intent requests it once; later editor switches do not repeat
the request [auto: `smoke_lazy_editor_chunk`].
- [ ] Two failed requests, or a runtime with a different build fingerprint,
leave mode, camera and plan in View and show the localized refresh advice
[auto: `editor-runtime-loader.test`, `smoke_lazy_editor_chunk`].
- [ ] An empty installation requests the dedicated onboarding chunk, displays
the first-space dialog and still has no editor request. Saving a drawn
first space requests the editor once and continues into Plan; async
`getConfigElement()` still returns `houseplan-card-editor`
[auto: `smoke_lazy_editor_chunk`].
- [ ] `bundle:budget` follows transitive static imports and keeps initial View
at or below 256000 B gzip. Bundle sync, demo freshness, CI artifacts and
release zip validation fail when any manifest-listed asset is missing or
its SHA-256 differs [auto: `bundle-assets.test`, `bundle-freshness.test`,
release-contract tests].
## Hiding layers: decor, openings, zero-thickness walls (docs/UX-MODES.md)
- [ ] **Room names have one literal off state (#203)**: disable «Показывать
+8 -1
View File
@@ -101,7 +101,14 @@ on-disk path, not the JavaScript URL served by Home Assistant.
Copy the release folder to `config/custom_components/houseplan`, restart Home
Assistant, add the integration, then add the resource above only if Lovelace
resources are YAML-managed.
resources are YAML-managed. Always copy the complete integration folder: the
stable resource URL remains one file, but that entry loads internal
content-hashed modules. A lone `houseplan-card.js` is not a supported install.
The ordinary View does not download editor code. The first opening of Plan,
Device or Background may therefore take a brief moment. If that internal module
cannot be loaded after one retry, the plan stays in View and asks you to refresh
the page; no half-open editor is kept.
### Permissions
+9
View File
@@ -101,6 +101,15 @@ resources:
3. Добавьте интеграцию **House Plan**.
4. Если ресурсы Lovelace управляются YAML, добавьте ресурс из примера выше.
Копируйте папку интеграции целиком: стабильный URL ресурса по-прежнему один,
но entry-файл загружает внутренние модули с хешированными именами. Установка
одного `houseplan-card.js` не поддерживается.
В обычном просмотре код редакторов не загружается. Поэтому первое открытие
«Плана», «Устройств» или «Подложки» может занять короткое время. Если внутренний
модуль не загрузился и после одной повторной попытки, план остаётся в просмотре
и предлагает обновить страницу; наполовину открытого редактора не остаётся.
### Права пользователей
| Настройка интеграции | Просмотр | Управление устройствами | Редактирование планов и загрузка файлов |
+1
View File
@@ -11,6 +11,7 @@
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs",
"benchmark:wall-model": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node scripts/benchmark-wall-segment-model.mjs",
"bundle:sync": "npm run build && node scripts/bundle-sync.mjs",
"bundle:budget": "node scripts/bundle-budget.mjs",
"invariants": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node scripts/model-invariants.mjs",
"docs:accept": "node scripts/docs-accept.mjs",
"smokes:select": "node scripts/smoke-select.mjs",
+21 -2
View File
@@ -3,13 +3,22 @@ import json from '@rollup/plugin-json';
import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
import { sourceFingerprint } from './scripts/source-fingerprint.mjs';
import { cssTemplateMinifier } from './scripts/css-template-minifier.mjs';
import {
buildFingerprintPlugin,
bundleManifestPlugin,
cleanBundleOutputPlugin,
editorRuntimeRetryUrlPlugin,
} from './scripts/bundle-manifest.mjs';
const SOURCE_FINGERPRINT = sourceFingerprint();
export default {
input: 'src/houseplan-card.ts',
output: {
file: 'dist/houseplan-card.js',
dir: 'dist',
entryFileNames: 'houseplan-card.js',
chunkFileNames: 'houseplan-assets/[name]-[hash].js',
format: 'es',
sourcemap: false,
// Tooling reads this before recording screenshots/performance. A committed
@@ -17,5 +26,15 @@ export default {
// plausible-looking but invalid baseline.
intro: `globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__=${JSON.stringify(SOURCE_FINGERPRINT)};`,
},
plugins: [resolve(), json(), typescript(), terser({ format: { comments: false } })],
plugins: [
cleanBundleOutputPlugin(),
buildFingerprintPlugin(SOURCE_FINGERPRINT),
cssTemplateMinifier(),
resolve(),
json(),
typescript({ compilerOptions: { outDir: 'dist/.ts' } }),
terser({ format: { comments: false } }),
editorRuntimeRetryUrlPlugin(),
bundleManifestPlugin(SOURCE_FINGERPRINT),
],
};
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
export const INITIAL_VIEW_GZIP_BUDGET = 256_000;
export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET) {
if (manifest?.schema !== 1 || !Array.isArray(manifest.files)) {
throw new Error('invalid houseplan-assets.json');
}
if (!manifest.lazyEditorFiles?.length) {
throw new Error('bundle has no lazy editor graph');
}
if (manifest.initialViewFiles.some((path) => manifest.lazyEditorFiles.includes(path))) {
throw new Error('initial View graph overlaps lazy editor graph');
}
if (manifest.initialViewGzipBytes > budget) {
throw new Error(
`initial View graph ${manifest.initialViewGzipBytes} B gzip exceeds ${budget} B budget`,
);
}
return {
initialViewGzipBytes: manifest.initialViewGzipBytes,
lazyEditorGzipBytes: manifest.lazyEditorGzipBytes,
};
}
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
try {
const manifest = JSON.parse(readFileSync(resolve('dist/houseplan-assets.json'), 'utf8'));
const result = assertBundleBudget(manifest);
console.log(`initial View: ${result.initialViewGzipBytes} B gzip`);
console.log(`lazy editor: ${result.lazyEditorGzipBytes} B gzip`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
+169
View File
@@ -0,0 +1,169 @@
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, rmSync } from 'node:fs';
import { dirname, posix, relative, resolve } from 'node:path';
import { gzipSync } from 'node:zlib';
const BUILD_FINGERPRINT_TOKEN = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
const EDITOR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_EDITOR_RETRY_ASSET__';
const ONBOARDING_RETRY_ASSET_TOKEN = '__HOUSEPLAN_ONBOARDING_RETRY_ASSET__';
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
const reachable = (entry, byPath, edge) => {
const seen = new Set();
const visit = (path) => {
if (seen.has(path)) return;
const file = byPath.get(path);
if (!file) throw new Error(`bundle manifest references missing asset: ${path}`);
seen.add(path);
for (const child of file[edge] || []) visit(child);
};
visit(entry);
return seen;
};
export function buildBundleManifest(bundle, fingerprint) {
const files = Object.values(bundle)
.filter((item) => item.type === 'chunk')
.map((chunk) => {
const contents = Buffer.from(chunk.code, 'utf8');
return {
path: chunk.fileName.replaceAll('\\', '/'),
sha256: sha256(contents),
rawBytes: contents.length,
gzipBytes: gzipSync(contents, { level: 9 }).length,
isEntry: chunk.isEntry,
imports: [...chunk.imports].sort(),
dynamicImports: [...chunk.dynamicImports].sort(),
};
})
.sort((left, right) => left.path.localeCompare(right.path));
const entry = files.find((file) => file.isEntry)?.path;
if (!entry) throw new Error('bundle manifest has no entry chunk');
const byPath = new Map(files.map((file) => [file.path, file]));
const initial = reachable(entry, byPath, 'imports');
const dynamicRoots = [...initial]
.flatMap((path) => byPath.get(path)?.dynamicImports || []);
const lazy = new Set();
for (const root of dynamicRoots) {
for (const path of reachable(root, byPath, 'imports')) if (!initial.has(path)) lazy.add(path);
}
const onboardingRoots = dynamicRoots.filter((path) => path.includes('houseplan-onboarding-runtime-'));
const editorRoots = dynamicRoots.filter((path) => !onboardingRoots.includes(path));
const graphFrom = (roots) => {
const graph = new Set();
for (const root of roots) {
for (const path of reachable(root, byPath, 'imports')) if (!initial.has(path)) graph.add(path);
}
return graph;
};
const lazyEditor = graphFrom(editorRoots);
const lazyOnboarding = graphFrom(onboardingRoots);
const sum = (paths) => [...paths]
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
return {
schema: 1,
fingerprint,
entry,
initialViewFiles: [...initial].sort(),
initialViewGzipBytes: sum(initial),
lazyFiles: [...lazy].sort(),
lazyGzipBytes: sum(lazy),
lazyEditorFiles: [...lazyEditor].sort(),
lazyEditorGzipBytes: sum(lazyEditor),
lazyOnboardingFiles: [...lazyOnboarding].sort(),
lazyOnboardingGzipBytes: sum(lazyOnboarding),
files,
};
}
export function bundleManifestPlugin(fingerprint) {
return {
name: 'houseplan-bundle-manifest',
generateBundle(_options, bundle) {
const manifest = buildBundleManifest(bundle, fingerprint);
this.emitFile({
type: 'asset',
fileName: 'houseplan-assets.json',
source: `${JSON.stringify(manifest, null, 2)}\n`,
});
},
};
}
/** Embed one immutable build id in both sides of the lazy handshake. */
export function buildFingerprintPlugin(fingerprint) {
return {
name: 'houseplan-build-fingerprint',
transform(code, id) {
if (!id.replaceAll('\\', '/').includes('/src/')
|| !code.includes(BUILD_FINGERPRINT_TOKEN)) return null;
return {
code: code.replaceAll(BUILD_FINGERPRINT_TOKEN, fingerprint),
map: null,
};
},
};
}
/**
* Point the second import attempt at the exact content-hashed editor chunk.
* Rollup owns that filename, so the URL is injected only after chunk naming;
* the first import remains a normal Rollup edge for graph accounting.
*/
export function editorRuntimeRetryUrlPlugin() {
return {
name: 'houseplan-editor-retry-url',
generateBundle(_options, bundle) {
const chunks = Object.values(bundle).filter((item) => item.type === 'chunk');
const editor = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/houseplan-editor-runtime.ts')));
const onboarding = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/houseplan-onboarding-runtime.ts')));
if (!editor) throw new Error('editor runtime chunk was not emitted');
if (!onboarding) throw new Error('onboarding runtime chunk was not emitted');
let editorReplacements = 0;
let onboardingReplacements = 0;
for (const chunk of chunks) {
if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
editorReplacements += chunk.code.split(EDITOR_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(EDITOR_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(ONBOARDING_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), onboarding.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
onboardingReplacements += chunk.code.split(ONBOARDING_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(ONBOARDING_RETRY_ASSET_TOKEN, asset);
}
}
if (editorReplacements !== 1 || onboardingReplacements !== 1) {
throw new Error('lazy retry URL placeholder counts are '
+ `${editorReplacements}/${onboardingReplacements}, expected 1/1`);
}
},
};
}
/** Remove only files named by the previous generated manifest. */
export function cleanBundleOutputPlugin(outputRoot = 'dist') {
return {
name: 'houseplan-clean-bundle-output',
buildStart() {
const root = resolve(outputRoot);
const manifestPath = resolve(root, 'houseplan-assets.json');
if (!existsSync(manifestPath)) return;
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
for (const file of manifest?.files || []) {
const path = resolve(root, String(file.path || ''));
const rel = relative(root, path);
if (!rel || rel.startsWith('..') || rel.includes(':')) {
throw new Error(`previous bundle path escapes output root: ${file.path}`);
}
rmSync(path, { force: true });
}
rmSync(manifestPath, { force: true });
},
};
}
+69 -12
View File
@@ -9,24 +9,81 @@
* туда» жило шестью разными `cp` в воркфлоу и трижды в документации; когда
* копию забывали, смок врал согласованно (#236).
*/
import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { createHash } from 'node:crypto';
import {
copyFileSync, existsSync, mkdirSync, readFileSync, rmSync,
} from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const SOURCE = resolve(ROOT, 'dist/houseplan-card.js');
const SOURCE_ROOT = resolve(ROOT, 'dist');
const MANIFEST_NAME = 'houseplan-assets.json';
const TARGETS = [
'custom_components/houseplan/frontend/houseplan-card.js',
'demo/srv/assets/houseplan-card.js',
'custom_components/houseplan/frontend',
'demo/srv/assets',
];
if (!existsSync(SOURCE)) {
console.error('dist/houseplan-card.js не найден: сначала `npm run build`');
const manifestPath = resolve(SOURCE_ROOT, MANIFEST_NAME);
if (!existsSync(manifestPath)) {
console.error(`dist/${MANIFEST_NAME} не найден: сначала \`npm run build\``);
process.exit(1);
}
for (const target of TARGETS) {
const path = resolve(ROOT, target);
mkdirSync(dirname(path), { recursive: true });
copyFileSync(SOURCE, path);
console.log(`бандл → ${target}`);
const readManifest = (path) => {
const parsed = JSON.parse(readFileSync(path, 'utf8'));
if (parsed?.schema !== 1 || !Array.isArray(parsed.files) || !parsed.entry) {
throw new Error(`${path}: invalid House Plan bundle manifest`);
}
return parsed;
};
const sourceManifest = readManifest(manifestPath);
const managedFiles = [MANIFEST_NAME, ...sourceManifest.files.map((file) => file.path)];
const sha256 = (path) => createHash('sha256').update(readFileSync(path)).digest('hex');
const contained = (root, name) => {
const path = resolve(root, name);
const rel = relative(root, path);
if (!rel || rel.startsWith('..') || rel.includes(':')) {
throw new Error(`bundle path escapes target root: ${name}`);
}
return path;
};
for (const file of sourceManifest.files) {
const path = contained(SOURCE_ROOT, file.path);
if (!existsSync(path)) throw new Error(`manifest asset is missing: ${file.path}`);
if (sha256(path) !== file.sha256) throw new Error(`manifest hash mismatch: ${file.path}`);
}
for (const target of TARGETS) {
const targetRoot = resolve(ROOT, target);
const oldManifestPath = resolve(targetRoot, MANIFEST_NAME);
let old = null;
if (existsSync(oldManifestPath)) {
old = readManifest(oldManifestPath);
}
// Content-hashed dependencies first, stable entry second, manifest last.
// At every observable point the current manifest therefore names a complete
// tree; an interrupted copy cannot publish an allowlist for absent chunks.
const payload = sourceManifest.files
.map((file) => file.path)
.sort((left, right) => (left === sourceManifest.entry ? 1 : 0)
- (right === sourceManifest.entry ? 1 : 0) || left.localeCompare(right));
for (const name of payload) {
const source = contained(SOURCE_ROOT, name);
const destination = contained(targetRoot, name);
mkdirSync(dirname(destination), { recursive: true });
copyFileSync(source, destination);
}
copyFileSync(manifestPath, oldManifestPath);
for (const file of old?.files || []) {
if (!managedFiles.includes(file.path)) rmSync(contained(targetRoot, file.path), { force: true });
}
const copied = readManifest(resolve(targetRoot, MANIFEST_NAME));
for (const file of copied.files) {
if (sha256(contained(targetRoot, file.path)) !== file.sha256) {
throw new Error(`${target}/${file.path}: copied hash mismatch`);
}
}
console.log(`бандл-дерево → ${target}`);
}
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
export const BUNDLE_MANIFEST = 'houseplan-assets.json';
export const sha256Bytes = (contents) => createHash('sha256').update(contents).digest('hex');
export function containedBundlePath(root, name) {
if (typeof name !== 'string' || !name || name.includes('\\')) {
throw new Error(`invalid bundle path: ${String(name)}`);
}
const path = resolve(root, name);
const rel = relative(resolve(root), path);
if (!rel || rel.startsWith('..') || rel.includes(':')) {
throw new Error(`bundle path escapes root: ${name}`);
}
return path;
}
export function readBundleManifest(root) {
const path = resolve(root, BUNDLE_MANIFEST);
if (!existsSync(path)) throw new Error(`${path}: bundle manifest is missing`);
const manifest = JSON.parse(readFileSync(path, 'utf8'));
if (manifest?.schema !== 1 || typeof manifest.entry !== 'string'
|| typeof manifest.fingerprint !== 'string' || !Array.isArray(manifest.files)) {
throw new Error(`${path}: invalid House Plan bundle manifest`);
}
const names = manifest.files.map((file) => file?.path);
if (new Set(names).size !== names.length || !names.includes(manifest.entry)) {
throw new Error(`${path}: duplicate assets or missing entry`);
}
return manifest;
}
export function verifyBundleTree(root) {
const manifest = readBundleManifest(root);
for (const file of manifest.files) {
if (typeof file?.path !== 'string' || !file.path.endsWith('.js')
|| !/^[a-zA-Z0-9._/-]+$/.test(file.path)) {
throw new Error(`invalid manifest asset path: ${String(file?.path)}`);
}
const path = containedBundlePath(root, file.path);
if (!existsSync(path)) throw new Error(`manifest asset is missing: ${file.path}`);
const actual = sha256Bytes(readFileSync(path));
if (actual !== file.sha256) {
throw new Error(`manifest hash mismatch: ${file.path} (${actual} != ${file.sha256})`);
}
}
return manifest;
}
export function compareBundleTrees(sourceRoot, targetRoot) {
const source = verifyBundleTree(sourceRoot);
const target = verifyBundleTree(targetRoot);
if (JSON.stringify(target) !== JSON.stringify(source)) {
throw new Error('bundle manifests differ');
}
for (const file of source.files) {
const left = readFileSync(containedBundlePath(sourceRoot, file.path));
const right = readFileSync(containedBundlePath(targetRoot, file.path));
if (!left.equals(right)) throw new Error(`bundle asset differs: ${file.path}`);
}
return source;
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
try {
const [source, target] = process.argv.slice(2);
if (!source) throw new Error('usage: node scripts/bundle-tree.mjs <root> [matching-root]');
const manifest = target
? compareBundleTrees(resolve(source), resolve(target))
: verifyBundleTree(resolve(source));
console.log(`verified ${manifest.files.length} bundle assets`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
+12 -3
View File
@@ -24,15 +24,24 @@ const requireWindow = (errors, source, index, pattern, label, before = 650) => {
export function checkCoordinateWriteBarriers(root = defaultRoot) {
const errors = [];
const frontend = readFileSync(resolve(root, 'src/houseplan-card.ts'), 'utf8');
// The editor implementation is a lazy chunk, but the persisted-coordinate
// inventory remains one logical frontend. Normalize the typed host port so
// the existing boundary windows keep describing the production call sites.
const card = readFileSync(resolve(root, 'src/houseplan-card.ts'), 'utf8');
const editor = readFileSync(resolve(root, 'src/houseplan-editor-runtime.ts'), 'utf8')
.replaceAll('this.host.', 'this.');
const frontend = `${card}\n${editor}`;
const configWrites = occurrences(frontend, "type: 'houseplan/config/set'");
if (configWrites.length !== 1) errors.push(`frontend config writer inventory: ${configWrites.length}`);
for (const index of configWrites) requireWindow(
errors, frontend, index,
/const candidate = canonicalizeConfigGeometry\(this\._serverCfg\);[\s\S]*config: candidate/,
'frontend config/set', 3600,
/const canonicalCandidate = canonicalizeConfigGeometry\(candidate\);[\s\S]*config: canonicalCandidate/,
'frontend config/set', 600,
);
if (occurrences(frontend, '._sendConfigCandidate(candidate)').length !== 2) {
errors.push('frontend canonical config transport callers changed');
}
const positionWrites = occurrences(frontend, "type: 'houseplan/layout/update'");
if (positionWrites.length !== 2) errors.push(`frontend position writer inventory: ${positionWrites.length}`);
+107
View File
@@ -0,0 +1,107 @@
/**
* Conservative minifier for static Lit css`` templates.
*
* This deliberately is not a general CSS optimiser: it removes comments and
* redundant whitespace while preserving strings, escapes and descendant
* combinators. Interpolated templates fail closed because rewriting across a
* JavaScript expression would make the build depend on runtime values.
*/
const isWhitespace = (char) => /[\t\n\f\r ]/.test(char);
const TIGHT_BEFORE = new Set(['{', '}', ':', ';', ',', '>', ')']);
const TIGHT_AFTER = new Set(['{', '}', ':', ';', ',', '>', '(']);
export function minifyCssText(source, label = '<css>') {
let out = '';
let quote = '';
let pendingSpace = false;
for (let i = 0; i < source.length; i += 1) {
const char = source[i];
const next = source[i + 1];
if (quote) {
out += char;
if (char === '\\') {
if (i + 1 >= source.length) throw new Error(`${label}: dangling CSS escape`);
out += source[++i];
} else if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
if (pendingSpace && out && !TIGHT_AFTER.has(out.at(-1))) out += ' ';
pendingSpace = false;
quote = char;
out += char;
continue;
}
if (char === '/' && next === '*') {
const end = source.indexOf('*/', i + 2);
if (end < 0) throw new Error(`${label}: unclosed CSS comment`);
i = end + 1;
continue;
}
if (isWhitespace(char)) {
pendingSpace = true;
continue;
}
if (pendingSpace && out && !TIGHT_AFTER.has(out.at(-1)) && !TIGHT_BEFORE.has(char)) {
out += ' ';
}
pendingSpace = false;
if (TIGHT_BEFORE.has(char) && out.endsWith(' ')) out = out.slice(0, -1);
out += char;
}
if (quote) throw new Error(`${label}: unclosed CSS string`);
return out.trim();
}
export function minifyStaticCssTemplates(code, id = '<module>') {
let cursor = 0;
let output = '';
let changed = false;
while (true) {
const start = code.indexOf('css`', cursor);
if (start < 0) break;
let end = start + 4;
let escaped = false;
for (; end < code.length; end += 1) {
const char = code[end];
if (escaped) {
escaped = false;
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if (char === '$' && code[end + 1] === '{') {
throw new Error(`${id}:${end}: interpolated css template is not supported`);
}
if (char === '`') break;
}
if (end >= code.length) throw new Error(`${id}:${start}: unclosed css template`);
const css = code.slice(start + 4, end);
output += code.slice(cursor, start);
output += `css\`${minifyCssText(css, `${id}:${start}`)}\``;
cursor = end + 1;
changed = true;
}
if (!changed) return null;
return output + code.slice(cursor);
}
export function cssTemplateMinifier() {
return {
name: 'houseplan-css-template-minifier',
transform(code, id) {
if (!id.endsWith('.ts') || !code.includes('css`')) return null;
const transformed = minifyStaticCssTemplates(code, id);
return transformed == null ? null : { code: transformed, map: null };
},
};
}
+105 -7
View File
@@ -37,10 +37,96 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const occurrenceCount = (source, needle) => source.split(needle).length - 1;
/**
* Map a logical pre-split card anchor onto the lazy editor source.
*
* Keeping the mutation definitions in their original logical form makes the
* registry readable and preserves the history of each regression. At runtime
* we relocate only anchors that actually moved into the typed editor host.
* The mapping is deliberately strict: an ambiguous or missing anchor still
* fails the cheap mutation applicability gate.
*/
function relocateEditorPatch(patch, cardSource, editorSource) {
if (patch.file !== 'src/houseplan-card.ts'
|| occurrenceCount(cardSource, patch.find) === 1) return patch;
if (occurrenceCount(editorSource, patch.find) === 1) {
return { ...patch, file: 'src/houseplan-editor-runtime.ts' };
}
const normalized = editorSource.replaceAll('this.host.', 'this.');
if (occurrenceCount(normalized, patch.find) !== 1) return patch;
const normalizedStart = normalized.indexOf(patch.find);
let rawIndex = 0;
let normalizedIndex = 0;
while (normalizedIndex < normalizedStart) {
if (editorSource.startsWith('this.host.', rawIndex)) {
rawIndex += 'this.host.'.length;
normalizedIndex += 'this.'.length;
} else {
rawIndex += 1;
normalizedIndex += 1;
}
}
const rawStart = rawIndex;
const hostFlags = [];
let findIndex = 0;
while (findIndex < patch.find.length) {
if (patch.find.startsWith('this.', findIndex)) {
hostFlags.push(editorSource.startsWith('this.host.', rawIndex));
}
if (editorSource.startsWith('this.host.', rawIndex)) {
rawIndex += 'this.host.'.length;
findIndex += 'this.'.length;
} else {
rawIndex += 1;
findIndex += 1;
}
}
const rawFind = editorSource.slice(rawStart, rawIndex);
let replacementThis = 0;
const rawReplace = patch.replace.replaceAll('this.', () => {
const host = hostFlags[replacementThis++] === true;
return host ? 'this.host.' : 'this.';
});
return {
...patch,
file: 'src/houseplan-editor-runtime.ts',
find: rawFind,
replace: rawReplace,
};
}
// --- реестр ---------------------------------------------------------------
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
export const MUTANTS = [
const MUTANT_DEFINITIONS = [
{
id: 'editor-runtime-fingerprint-handshake',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/editor-runtime-loader.test.mjs',
because: 'a mixed entry/editor build must fail closed instead of installing incompatible code (#337)',
patches: [{
file: 'src/editor-runtime-loader.ts',
find: ' if (module.fingerprint !== this.options.expectedFingerprint) {',
replace: ' if (false && module.fingerprint !== this.options.expectedFingerprint) {',
}],
},
{
id: 'editor-runtime-one-retry',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/editor-runtime-loader.test.mjs',
because: 'a transient chunk failure gets exactly one bounded retry (#337)',
patches: [{
file: 'src/editor-runtime-loader.ts',
find: ' for (const attempt of [0, 1] as const) {',
replace: ' for (const attempt of [0] as const) {',
}],
},
{
id: 'wallthick-hover-floor-back',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
@@ -1527,9 +1613,9 @@ export const MUTANTS = [
guard: 'npm run bundle:sync && node demo/smoke_near_axis_optimize.mjs',
because: 'opening the Optimize preview or cancelling it must never persist a lossy repair (#290)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' private _openAlignDialog = (): void => this._previewAlignDialog(false);',
replace: ' private _openAlignDialog = (): void => {\n'
file: 'src/houseplan-editor-runtime.ts',
find: 'public _openAlignDialog = (): void => this._previewAlignDialog(false);',
replace: 'public _openAlignDialog = (): void => {\n'
+ ' this._previewAlignDialog(false);\n'
+ ' void this._runAlignToGrid();\n'
+ ' };',
@@ -3432,8 +3518,16 @@ export const MUTANTS = [
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
const mutationEditorSource = readFileSync(join(repoRoot, 'src/houseplan-editor-runtime.ts'), 'utf8');
export const MUTANTS = MUTANT_DEFINITIONS.map((mutant) => ({
...mutant,
patches: mutant.patches.map((patch) => relocateEditorPatch(
patch, mutationCardSource, mutationEditorSource,
)),
}));
// --- механика ---------------------------------------------------------------
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
export function applyPatches(root, patches) {
for (const patch of patches) {
@@ -3493,7 +3587,8 @@ export function guardNeedsTestBuild(guard) {
* Нужен ли гварду СОБРАННЫЙ бандл (#332).
*
* Бандл читают только браузерные проверки: смоки и golden-сцены грузят
* `demo/srv/assets/houseplan-card.js`, и мутант обязан попасть в него, иначе
* дерево `demo/srv/assets/`, и мутант обязан попасть в entry и lazy chunks,
* иначе
* guard проверяет чистый код. Юнит- и бэкенд-гварды бандл не открывают ни в
* каком виде (проверено по реестру и по исходникам тестов: dist/** читается
* только как git-чекаут, который в worktree и так есть). Rollup-сборка —
@@ -3543,7 +3638,10 @@ function buildBundle(dir) {
if (built.status !== 0) {
throw new Error(`сборка мутанта упала:\n${(built.stderr || built.stdout).slice(-2000)}`);
}
cpSync(join(dir, 'dist', 'houseplan-card.js'), join(dir, 'demo', 'srv', 'assets', 'houseplan-card.js'));
const synced = sh('node scripts/bundle-sync.mjs', dir);
if (synced.status !== 0) {
throw new Error(`дерево бандла мутанта не синхронизировалось:\n${(synced.stderr || synced.stdout).slice(-2000)}`);
}
}
function runMutant(mutant) {
+57 -27
View File
@@ -252,17 +252,33 @@ if (invokedDirectly) {
// Копия стенда (`demo/srv/assets`) больше не коммитится (#255): её собирает
// `npm run bundle:sync` перед браузерными прогонами. В релиз входят ровно
// две копии — артефакт сборки и та, что ставит HACS.
const names = [
'dist/houseplan-card.js',
'custom_components/houseplan/frontend/houseplan-card.js',
];
// Hash Git blobs, not checkout bytes. On Windows, Git can expose CRLF in
// the worktree while the exact tagged blobs and Linux release checkout use
// LF. The release must be bound to the immutable commit representation.
const hashes = names.map((name) => [name, sha256Bytes(committedFile(sha, name))]);
if (new Set(hashes.map(([, hash]) => hash)).size !== 1)
throw new Error(`Committed bundle snapshots differ: ${hashes.map(([name, hash]) => `${name}=${hash}`).join(', ')}`);
return hashes[0][1];
const distManifestBytes = committedFile(sha, 'dist/houseplan-assets.json');
const frontendManifestBytes = committedFile(
sha, 'custom_components/houseplan/frontend/houseplan-assets.json',
);
if (!distManifestBytes.equals(frontendManifestBytes)) {
throw new Error('Committed bundle manifests differ');
}
const manifest = JSON.parse(distManifestBytes.toString('utf8'));
if (manifest?.schema !== 1 || !Array.isArray(manifest.files)
|| manifest.entry !== 'houseplan-card.js') {
throw new Error('Committed bundle manifest is invalid');
}
for (const file of manifest.files) {
if (typeof file?.path !== 'string' || !file.path.endsWith('.js')
|| file.path.includes('..') || file.path.includes('\\')) {
throw new Error(`Committed bundle asset path is invalid: ${String(file?.path)}`);
}
const dist = committedFile(sha, `dist/${file.path}`);
const frontend = committedFile(sha, `custom_components/houseplan/frontend/${file.path}`);
if (!dist.equals(frontend)) throw new Error(`Committed bundle asset differs: ${file.path}`);
const actual = sha256Bytes(dist);
if (actual !== file.sha256) {
throw new Error(`Committed bundle asset hash mismatch: ${file.path}`);
}
}
const entry = manifest.files.find((file) => file.path === manifest.entry);
return { manifest, entrySha256: entry.sha256 };
};
const materializeCommittedBundle = (sha, expectedSha256, artifactsDir) => {
@@ -275,18 +291,31 @@ if (invokedDirectly) {
return bundlePath;
};
const verifyZipContents = (zipPath, version, bundleSha256) => {
const entries = readZipEntries(zipPath, ['manifest.json', 'frontend/houseplan-card.js']);
const verifyZipContents = (zipPath, version, bundleSnapshot) => {
const header = readZipEntries(zipPath, [
'manifest.json', 'frontend/houseplan-assets.json',
]);
const frontendManifest = JSON.parse(header.get('frontend/houseplan-assets.json').toString('utf8'));
if (JSON.stringify(frontendManifest) !== JSON.stringify(bundleSnapshot.manifest)) {
throw new Error('houseplan.zip frontend manifest differs from committed bundle');
}
const required = [
'manifest.json', 'frontend/houseplan-assets.json',
...bundleSnapshot.manifest.files.map((file) => `frontend/${file.path}`),
];
const entries = readZipEntries(zipPath, required);
const manifest = JSON.parse(entries.get('manifest.json').toString('utf8'));
if (manifest.version !== version)
throw new Error(`houseplan.zip manifest version ${manifest.version} != ${version}`);
const bundledHash = createHash('sha256')
.update(entries.get('frontend/houseplan-card.js')).digest('hex');
if (bundledHash !== bundleSha256)
throw new Error(`houseplan.zip frontend hash ${bundledHash} != committed bundle ${bundleSha256}`);
for (const file of bundleSnapshot.manifest.files) {
const bundledHash = sha256Bytes(entries.get(`frontend/${file.path}`));
if (bundledHash !== file.sha256) {
throw new Error(`houseplan.zip frontend hash mismatch: ${file.path}`);
}
}
};
const buildZip = (sha, version, bundleSha256, artifactsDir) => {
const buildZip = (sha, version, bundleSnapshot, artifactsDir) => {
const zipPath = resolve(artifactsDir, 'houseplan.zip');
run('git', [
// `git archive` on Windows otherwise applies local core.autocrlf and
@@ -294,11 +323,11 @@ if (invokedDirectly) {
'-c', 'core.autocrlf=false', 'archive', '--format=zip', `--output=${zipPath}`,
`${sha}:custom_components/houseplan`,
]);
verifyZipContents(zipPath, version, bundleSha256);
verifyZipContents(zipPath, version, bundleSnapshot);
return zipPath;
};
const verifyRemoteAssetContents = (version, bundleSha256) => {
const verifyRemoteAssetContents = (version, bundleSnapshot) => {
const download = mkdtempSync(resolve(tmpdir(), 'houseplan-release-check-'));
try {
run('gh', [
@@ -308,9 +337,9 @@ if (invokedDirectly) {
try {
const cardPath = resolve(download, 'houseplan-card.js');
const cardHash = sha256Path(cardPath);
if (cardHash !== bundleSha256)
throw new Error(`Published houseplan-card.js hash ${cardHash} != candidate ${bundleSha256}`);
verifyZipContents(resolve(download, 'houseplan.zip'), version, bundleSha256);
if (cardHash !== bundleSnapshot.entrySha256)
throw new Error(`Published houseplan-card.js hash ${cardHash} != candidate ${bundleSnapshot.entrySha256}`);
verifyZipContents(resolve(download, 'houseplan.zip'), version, bundleSnapshot);
} catch (error) {
throw new ReleaseAssetContentError(
error instanceof Error ? error.message : String(error),
@@ -466,7 +495,8 @@ if (invokedDirectly) {
const sha = run('git', ['rev-parse', 'HEAD']).stdout;
const remoteBranch = run('git', ['rev-parse', `origin/${branch}`]).stdout;
if (sha !== remoteBranch) throw new Error(`HEAD ${sha} is not synchronized with origin/${branch} ${remoteBranch}`);
const bundleSha256 = assertBundleSnapshots(sha);
const bundleSnapshot = assertBundleSnapshots(sha);
const bundleSha256 = bundleSnapshot.entrySha256;
const validateRuns = assertGreenValidate(sha);
validateIssues();
const existingTag = remoteTag();
@@ -519,7 +549,7 @@ if (invokedDirectly) {
// A matching name and non-zero size are insufficient: bind both
// downloadable assets to this exact candidate before closing issues.
try {
verifyRemoteAssetContents(contract.version, bundleSha256);
verifyRemoteAssetContents(contract.version, bundleSnapshot);
} catch (error) {
if (!(error instanceof ReleaseAssetContentError)) throw error;
console.log(`Published release needs stale-asset recovery: ${error.message}`);
@@ -534,7 +564,7 @@ if (invokedDirectly) {
}
}
const zipPath = buildZip(sha, contract.version, bundleSha256, artifactsDir);
const zipPath = buildZip(sha, contract.version, bundleSnapshot, artifactsDir);
if (!existingTag.exists) {
const local = localTag();
if (local.exists && (!local.annotated || local.commit !== sha)) {
@@ -577,7 +607,7 @@ if (invokedDirectly) {
const finalTag = remoteTag();
if (!finalTag.exists || finalTag.commit !== sha)
throw new Error(`Published tag ${tag} no longer resolves to exact SHA ${sha}`);
verifyRemoteAssetContents(contract.version, bundleSha256);
verifyRemoteAssetContents(contract.version, bundleSnapshot);
verifyHacsDiscovery();
finishIssues(published.url);
console.log(`Published and content-verified: ${published.url}`);
+10
View File
@@ -34,6 +34,16 @@ export const SMOKE_LINKS = [
+ 'fail-closed candidate) through the rendered card, never naming the key-quantisation '
+ 'internals that decide which nodes are one node (#331 §2.1)',
},
{
symbols: [
'EditorRuntimeLoader', 'EditorRuntimeLoaderState', 'EditorRuntimeModule',
'EDITOR_RUNTIME_FINGERPRINT', 'createHouseplanEditorRuntime',
],
smokes: ['smoke_lazy_editor_chunk.mjs'],
because: 'the production-bundle scenario proves that the content-hashed editor chunk stays '
+ 'off the View network path, is shared by all three editors, retries once, and refuses an '
+ 'incompatible build before editor state is installed (#337)',
},
{
symbols: [
'placeResizeAreaLabel', 'resizeInwardNormal', 'resizeMeasuredEdges',
+2
View File
@@ -18,6 +18,8 @@ const BUILD_INPUTS = [
'rollup.config.mjs',
'tsconfig.json',
'scripts/source-fingerprint.mjs',
'scripts/css-template-minifier.mjs',
'scripts/bundle-manifest.mjs',
];
const fingerprintFiles = (root) => {
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readZipEntries } from './release-prerelease.mjs';
import { readBundleManifest, sha256Bytes } from './bundle-tree.mjs';
export function verifyHouseplanZip(zipPath, bundleRoot, expectedVersion = '') {
const expected = readBundleManifest(bundleRoot);
const header = readZipEntries(zipPath, [
'manifest.json', 'frontend/houseplan-assets.json',
]);
const integration = JSON.parse(header.get('manifest.json').toString('utf8'));
if (expectedVersion && integration.version !== expectedVersion) {
throw new Error(`houseplan.zip manifest version ${integration.version} != ${expectedVersion}`);
}
const bundled = JSON.parse(header.get('frontend/houseplan-assets.json').toString('utf8'));
if (JSON.stringify(bundled) !== JSON.stringify(expected)) {
throw new Error('houseplan.zip frontend manifest differs from source tree');
}
const entries = readZipEntries(zipPath, expected.files.map((file) => `frontend/${file.path}`));
for (const file of expected.files) {
if (sha256Bytes(entries.get(`frontend/${file.path}`)) !== file.sha256) {
throw new Error(`houseplan.zip frontend hash mismatch: ${file.path}`);
}
}
return { version: integration.version, files: expected.files.length };
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
try {
const [zip, root, version = ''] = process.argv.slice(2);
if (!zip || !root) {
throw new Error('usage: node scripts/verify-houseplan-zip.mjs <zip> <bundle-root> [version]');
}
const result = verifyHouseplanZip(resolve(zip), resolve(root), version);
console.log(`verified houseplan.zip ${result.version}: ${result.files} frontend assets`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { polygonArea } from './logic';
/** View-safe area helpers kept separate from the lazy resize implementation. */
export function areaM2(poly: number[][], gridPitch: number, cellCm: number): number {
const cmPerUnit = cellCm / gridPitch;
return (polygonArea(poly) * cmPerUnit * cmPerUnit) / 1e4;
}
export function formatArea(m2: number, imperial: boolean): string {
if (imperial) return `${Math.round(m2 * 10.7639)} ft²`;
return `${(Math.round(m2 * 10) / 10).toFixed(1)} m²`;
}
+72
View File
@@ -0,0 +1,72 @@
export type EditorRuntimeLoaderState = 'idle' | 'loading' | 'ready' | 'failed';
export interface EditorRuntimeModule<Runtime> {
readonly fingerprint: string;
create(): Runtime;
}
export interface EditorRuntimeLoaderOptions<Runtime> {
readonly expectedFingerprint: string;
readonly load: (attempt: 0 | 1) => Promise<EditorRuntimeModule<Runtime>>;
readonly install: (runtime: Runtime) => void;
readonly stateChanged?: (state: EditorRuntimeLoaderState) => void;
readonly failed?: (error: unknown) => void;
}
/**
* One atomic lazy-runtime boundary shared by all editor entry points.
*
* A failed module is retried exactly once. Construction happens before
* `install`, so a parse, fingerprint or constructor failure cannot leave a
* half-installed editor attached to the View card.
*/
export class EditorRuntimeLoader<Runtime> {
private _state: EditorRuntimeLoaderState = 'idle';
private _inFlight: Promise<boolean> | null = null;
public constructor(private readonly options: EditorRuntimeLoaderOptions<Runtime>) {}
public get state(): EditorRuntimeLoaderState {
return this._state;
}
public ensure(): Promise<boolean> {
if (this._state === 'ready') return Promise.resolve(true);
if (this._state === 'failed') return Promise.resolve(false);
if (this._inFlight) return this._inFlight;
this._setState('loading');
this._inFlight = this._loadWithRetry().finally(() => {
this._inFlight = null;
});
return this._inFlight;
}
private async _loadWithRetry(): Promise<boolean> {
let lastError: unknown = new Error('Editor runtime did not load');
for (const attempt of [0, 1] as const) {
try {
const module = await this.options.load(attempt);
if (module.fingerprint !== this.options.expectedFingerprint) {
throw new Error(
`Editor runtime fingerprint mismatch: expected ${this.options.expectedFingerprint}, got ${module.fingerprint}`,
);
}
const runtime = module.create();
this.options.install(runtime);
this._setState('ready');
return true;
} catch (error: unknown) {
lastError = error;
}
}
this._setState('failed');
this.options.failed?.(lastError);
return false;
}
private _setState(state: EditorRuntimeLoaderState): void {
if (state === this._state) return;
this._state = state;
this.options.stateChanged?.(state);
}
}
+718 -10794
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+843
View File
@@ -0,0 +1,843 @@
import { html, nothing, type TemplateResult } from 'lit';
import {
DEFAULT_CUSTOM_FILL,
DEFAULT_ROOM_COLOR,
DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MAX,
DEFAULT_TEMP_MIN,
SPACE_FILL_UI_MODES,
customFillOf,
spaceDisplayOf,
stageBgOf,
} from './logic';
import { bgModeOf, northDegOf } from './sun';
import {
createEmptySpaceConfig,
initialSpaceDisplayDraft,
switchSpacePlanSource,
touchSpaceDisplay,
} from './space-dialog';
import { collectSpaceMarkerDependencies } from './space-deletion';
import {
gridCellFieldToCm,
gridCellFieldValue,
newSpaceCellCm,
} from './grid-scale';
import { zeroWallStyleOf } from './zero-walls';
import type { HouseplanEditorHostPort } from './houseplan-editor-runtime';
const BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
const CELL_CM_MIN = 0.1;
const CELL_CM_MAX = 1000;
const strictNumber = (value: string): number | null => {
if (!value.trim()) return null;
const parsed = Number(value.replace(',', '.'));
return Number.isFinite(parsed) ? parsed : null;
};
/**
* Space creation/import is part of empty-install onboarding, not an editor.
* It therefore has its own lazy boundary: normal View downloads neither this
* module nor the editor, while a new empty installation can create its first
* space without ever requesting the editor chunk.
*/
export const ONBOARDING_RUNTIME_FINGERPRINT = BUILD_FINGERPRINT;
export class HouseplanOnboardingRuntime {
public constructor(public readonly host: HouseplanEditorHostPort) {}
public _openSpaceDialog(mode: 'edit' | 'create', spaceId?: string): void {
if (!this.host._serverStorage || !this.host._serverCfg) {
this.host._showToast(this.host._t('toast.integration_missing'));
return;
}
if (mode === 'edit') {
const sp = this.host._serverCfg.spaces.find((space) => space.id === spaceId);
if (!sp) return;
const disp = spaceDisplayOf(sp);
const storedCustom = sp.settings?.custom_fill && typeof sp.settings.custom_fill === 'object'
? customFillOf(sp.settings.custom_fill) : null;
const dialogCustom = disp.fill === 'none'
? { ...(storedCustom || DEFAULT_CUSTOM_FILL), a: 0 }
: storedCustom;
this.host._spaceDialog = {
mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null,
source: sp.plan_url ? 'file' : 'draw',
showBorders: disp.showBorders, showNames: disp.showNames,
zeroWallStyle: zeroWallStyleOf(sp),
displayTouched: true,
hideDecor: disp.hideDecor, hideOpenings: disp.hideOpenings,
roomColor: disp.color, roomOpacity: disp.opacity,
fillMode: disp.fill === 'none' ? 'custom' : disp.fill,
customFill: dialogCustom,
glowEnabled: disp.glow,
bgColor: disp.bgColor,
bgMode: sp.settings?.bg_mode === 'static' || sp.settings?.bg_mode === 'daynight'
? sp.settings.bg_mode : null,
northDeg: northDegOf({}, sp.settings),
sunRays: typeof sp.settings?.sun_rays === 'boolean' ? sp.settings.sun_rays : null,
tempMin: disp.tempMin, tempMax: disp.tempMax,
showLqi: disp.showLqi ?? this.host._config?.show_signal ?? true,
cardFontScale: disp.cardFontScale,
labelTemp: disp.labelTemp, labelHum: disp.labelHum,
labelLqi: disp.labelLqi, labelLight: disp.labelLight,
cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5,
cellCmInput: gridCellFieldValue(
Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5, this.host._imperial,
),
cellCmTouched: false,
busy: false,
};
return;
}
const cellCm = newSpaceCellCm(this.host._imperial);
this.host._spaceDialog = {
mode, title: '', planUrl: null, planFile: null,
...initialSpaceDisplayDraft(),
hideDecor: false, hideOpenings: false, zeroWallStyle: 'dashed',
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom',
customFill: { ...DEFAULT_CUSTOM_FILL, a: 0 },
glowEnabled: true,
bgColor: null,
bgMode: 'daynight', northDeg: null, sunRays: null,
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this.host._config?.show_signal ?? true,
cardFontScale: 1,
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
cellCm,
cellCmInput: gridCellFieldValue(cellCm, this.host._imperial),
cellCmTouched: false,
busy: false,
};
}
public async _pickPlanFile(ev: Event): Promise<void> {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || !this.host._spaceDialog) return;
const extMap: Record<string, string> = {
'image/svg+xml': 'svg', 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp',
};
const ext = extMap[file.type] || (file.name.toLowerCase().endsWith('.svg') ? 'svg' : '');
if (!ext) {
this.host._showToast(this.host._t('toast.plan_formats'));
return;
}
const buf = new Uint8Array(await file.arrayBuffer());
let bin = '';
for (let i = 0; i < buf.length; i += 32768) bin += String.fromCharCode(...buf.subarray(i, i + 32768));
const b64 = btoa(bin);
const url = URL.createObjectURL(file);
const aspect = await new Promise<number>((resolve) => {
const image = new Image();
image.onload = () => resolve(image.naturalWidth && image.naturalHeight
? image.naturalWidth / image.naturalHeight : 1.414);
image.onerror = () => resolve(1.414);
image.src = url;
});
URL.revokeObjectURL(url);
this.host._spaceDialog = {
...this.host._spaceDialog, planFile: { ext, b64, aspect, name: file.name },
};
}
public _toggleServerPlans = async (): Promise<void> => {
const dialog = this.host._spaceDialog;
if (!dialog) return;
if (dialog.pickSaved) {
this.host._spaceDialog = { ...dialog, pickSaved: false };
return;
}
this.host._spaceDialog = { ...dialog, pickSaved: true, savedBusy: true };
try {
const response: { plans?: NonNullable<typeof dialog.saved> } = await this.host.hass.callWS({
type: 'houseplan/plans/list',
});
const current = this.host._spaceDialog;
if (current) this.host._spaceDialog = {
...current, saved: response?.plans || [], savedBusy: false,
};
} catch (error: unknown) {
const current = this.host._spaceDialog;
if (current) this.host._spaceDialog = { ...current, saved: [], savedBusy: false };
this.host._showToast(this.host._t('toast.plans_list_failed', {
err: this.host._errText(error),
}));
}
};
public _useServerPlan(url: string): void {
const dialog = this.host._spaceDialog;
if (!dialog) return;
this.host._spaceDialog = {
...dialog, planUrl: url, planFile: null, pickSaved: false, savedAspect: undefined,
};
this.host._aspectJob = this._readPlanAspect(url);
}
public async _readPlanAspect(url: string): Promise<number> {
for (let i = 0; i < 40; i++) {
const src = this.host._display(url);
if (src) {
const ratio = await new Promise<number>((resolve) => {
const image = new Image();
image.onload = () => resolve(image.naturalWidth && image.naturalHeight
? image.naturalWidth / image.naturalHeight : 0);
image.onerror = () => resolve(0);
image.src = src;
});
const current = this.host._spaceDialog;
if (current && current.planUrl === url && Number.isFinite(ratio) && ratio > 0) {
this.host._spaceDialog = { ...current, savedAspect: ratio };
return ratio;
}
return 0;
}
await new Promise((resolve) => setTimeout(resolve, 150));
if (this.host._spaceDialog?.planUrl !== url) return 0;
}
return 0;
}
public async _deleteServerPlan(name: string): Promise<void> {
if (!confirm(this.host._t('confirm.delete_plan', { name }))) return;
try {
await this.host.hass.callWS({ type: 'houseplan/plans/delete', name });
const dialog = this.host._spaceDialog;
if (dialog?.saved) this.host._spaceDialog = {
...dialog, saved: dialog.saved.filter((plan) => plan.name !== name),
};
} catch (error: unknown) {
this.host._showToast(this.host._t('toast.plan_delete_failed', {
err: this.host._errText(error),
}));
}
}
public _renderServerPlans(
dialog: NonNullable<HouseplanEditorHostPort['_spaceDialog']>,
): TemplateResult {
if (dialog.savedBusy) return html`<div class="savedplans muted">${this.host._t('space.loading')}</div>`;
const list = dialog.saved || [];
if (!list.length) return html`<div class="savedplans muted">${this.host._t('space.no_saved')}</div>`;
const kb = (size: number) => (size >= 1048576
? `${(size / 1048576).toFixed(1)} MB` : `${Math.round(size / 1024)} KB`);
return html`<div class="savedplans">
${list.map((plan) => html`
<div class="savedplan ${plan.url === dialog.planUrl ? 'cur' : ''}">
<img src=${this.host._display(plan.url)} alt="" loading="lazy" decoding="async" />
<div class="savedmeta">
<b>${plan.name}</b>
<span class="muted">${kb(plan.size)}${plan.used_by.length
? ` · ${this.host._t('space.used_by', { list: plan.used_by.join(', ') })}`
: ''}</span>
</div>
<button class="btn ghost" @click=${() => this._useServerPlan(plan.url)}
?disabled=${plan.url === dialog.planUrl}>${this.host._t('btn.use')}</button>
<button class="btn ghost danger"
title=${plan.used_by.length || plan.url === dialog.planUrl
? this.host._t('space.in_use') : this.host._t('btn.delete')}
?disabled=${plan.used_by.length > 0 || plan.url === dialog.planUrl}
@click=${() => this._deleteServerPlan(plan.name)}>
<ha-icon icon="mdi:trash-can-outline"></ha-icon>
</button>
</div>`)}
</div>`;
}
private async _saveConfigNow(): Promise<void> {
this.host._cfgEpoch++;
try {
await this.host._writeConfig();
} catch (error: unknown) {
const failure = error as { physicalGeometryRolledBack?: boolean; code?: string };
if (failure?.physicalGeometryRolledBack) await this.host._reloadRejectedPhysicalWrite();
else if (failure?.code === 'conflict') await this.host._reloadConfigOnly();
throw error;
}
}
public async _saveSpaceDialog(): Promise<void> {
const dialog = this.host._spaceDialog;
if (!dialog || dialog.busy || !dialog.title.trim()) return;
if (dialog.source === 'file' && !dialog.planFile && !dialog.planUrl) {
this.host._showToast(this.host._t('toast.plan_required'));
return;
}
const wasFirst = dialog.mode === 'create' && (this.host._serverCfg?.spaces.length || 0) === 0;
this.host._spaceDialog = { ...dialog, busy: true };
try {
const spaceId = dialog.mode === 'create'
? `s${Date.now().toString(36)}` : dialog.spaceId!;
let uploaded: { url: string; aspect: number } | null = null;
if (dialog.source === 'file' && dialog.planFile) {
const response: { url: string } = await this.host.hass.callWS({
type: 'houseplan/plan/set',
space_id: spaceId,
ext: dialog.planFile.ext,
data: dialog.planFile.b64,
});
uploaded = { url: response.url, aspect: dialog.planFile.aspect };
}
let pickedAspect: number | null = dialog.savedAspect || null;
if (!uploaded && dialog.source === 'file' && dialog.planUrl
&& !pickedAspect && this.host._aspectJob) {
pickedAspect = (await this.host._aspectJob) || null;
}
const config = this.host._serverCfg!;
let space = config.spaces.find((candidate) => candidate.id === spaceId);
if (dialog.mode === 'create') {
space = createEmptySpaceConfig(spaceId, dialog.title.trim());
config.spaces.push(space);
} else {
if (!space) throw new Error(`space ${spaceId} is gone from the config`);
space.title = dialog.title.trim();
}
if (!space) throw new Error(`space ${spaceId} is unavailable`);
if (uploaded) {
space.plan_url = uploaded.url;
space.plan_aspect = uploaded.aspect;
} else if (dialog.source === 'file' && dialog.planUrl && dialog.planUrl !== space.plan_url) {
space.plan_url = dialog.planUrl;
space.plan_aspect = pickedAspect;
}
if (dialog.source === 'draw') {
space.plan_url = null;
space.plan_aspect = null;
delete space.plan_x;
delete space.plan_y;
delete space.plan_scale;
delete space.plan_scale_x;
delete space.plan_scale_y;
delete space.plan_angle;
}
space.settings = {
...(space.settings || {}),
show_borders: dialog.showBorders,
show_names: dialog.showNames,
hide_decor: dialog.hideDecor || undefined,
hide_openings: dialog.hideOpenings || undefined,
room_color: dialog.roomColor,
room_opacity: dialog.roomOpacity,
bg_color: dialog.bgColor || undefined,
bg_mode: dialog.bgMode || undefined,
north_deg: dialog.northDeg ?? undefined,
sun_rays: dialog.sunRays ?? undefined,
fill_mode: dialog.fillMode,
custom_fill: dialog.customFill || undefined,
glow_enabled: dialog.glowEnabled,
temp_min: Number.isFinite(dialog.tempMin)
? Math.min(dialog.tempMin, dialog.tempMax) : DEFAULT_TEMP_MIN,
temp_max: Number.isFinite(dialog.tempMax)
? Math.max(dialog.tempMin, dialog.tempMax) : DEFAULT_TEMP_MAX,
show_lqi: dialog.showLqi,
card_font_scale: dialog.cardFontScale !== 1 ? dialog.cardFontScale : undefined,
label_temp: dialog.labelTemp,
label_hum: dialog.labelHum,
label_lqi: dialog.labelLqi,
label_light: dialog.labelLight,
};
space.zero_wall_style = dialog.zeroWallStyle;
space.cell_cm = Number.isFinite(dialog.cellCm) && dialog.cellCm > 0
? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, dialog.cellCm)) : 5;
await this._saveConfigNow();
this.host._spaceDialog = null;
if (dialog.mode === 'create') this.host._commitSpace(space.id);
this.host._regSignature = '';
this.host._maybeRebuildDevices();
if (this.host._importQueue.length) {
this._openNextImport();
} else if (wasFirst || this.host._importTotal > 0) {
const wasImport = this.host._importTotal > 0;
this.host._importTotal = 0;
this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || this.host._space);
await this.host._requestMode('plan');
this.host._tool = 'draw';
this.host._path = [];
this.host._cursorPt = null;
this.host._primeDrawWallField();
this.host._showToast(this.host._t(wasFirst && !wasImport
? 'toast.space_added_onboard' : 'import.done'));
} else {
this.host._showToast(this.host._t(dialog.mode === 'create'
? 'toast.space_added' : 'toast.space_saved'));
if (dialog.mode === 'create') {
if (this.host._mode !== 'plan') await this.host._requestMode('plan');
else {
this.host._tool = 'draw';
this.host._path = [];
this.host._cursorPt = null;
this.host._primeDrawWallField();
this.host._saveNav();
}
}
}
} catch (error: unknown) {
const failure = error as { code?: string };
if (failure?.code !== 'conflict') await this.host._reloadConfigOnly(true);
if (this.host._spaceDialog) this.host._spaceDialog = {
...this.host._spaceDialog, busy: false,
};
this.host._showToast(this.host._t('toast.error', { err: this.host._errText(error) }));
}
}
public async _deleteSpace(): Promise<void> {
const dialog = this.host._spaceDialog;
if (!dialog || dialog.mode !== 'edit') return;
const serverConfig = this.host._serverCfg;
if (!serverConfig) return;
const space = serverConfig.spaces.find((candidate) => candidate.id === dialog.spaceId);
if (!space) return;
const dependencies = collectSpaceMarkerDependencies(
serverConfig, this.host._layout || {}, dialog.spaceId || '',
);
const deletingLastSpace = serverConfig.spaces.length === 1
&& serverConfig.spaces[0]?.id === dialog.spaceId;
if (dependencies.count && !deletingLastSpace) {
this.host._spaceDialog = { ...dialog, deleteBlockers: dependencies.count };
return;
}
if (!confirm(this.host._t('confirm.delete_space', { title: space.title }))) return;
this.host._spaceDialog = { ...dialog, deleteBlockers: 0, busy: true };
try {
if (this.host._saveConfigDebounced.pending()) this.host._saveConfigDebounced.flush();
if (this.host._persistLayout.pending()) this.host._persistLayout.flush();
await this.host._writeChain;
const response: { config_rev?: number; layout_rev?: number } = await this.host.hass.callWS({
type: 'houseplan/space/delete',
space_id: dialog.spaceId,
expected_config_rev: this.host._cfgRev,
expected_layout_rev: this.host._layoutRev,
});
const [configResponse, layoutResponse] = await Promise.all([
this.host.hass.callWS({ type: 'houseplan/config/get' }),
this.host.hass.callWS({ type: 'houseplan/layout/get' }),
]);
this.host._adoptStructuralResponses(configResponse, layoutResponse);
this.host._cfgRev = response?.config_rev ?? this.host._cfgRev;
this.host._layoutRev = response?.layout_rev ?? this.host._layoutRev;
this.host._spaceDialog = null;
if (this.host._space === dialog.spaceId) {
this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || '');
}
this.host._regSignature = '';
this.host._maybeRebuildDevices();
this.host._showToast(this.host._t('toast.space_deleted'));
} catch (error: unknown) {
const failure = error as { code?: string };
if (failure?.code === 'conflict' || failure?.code === 'space_in_use') {
await Promise.all([
this.host._reloadConfigOnly(true), this.host._reloadLayoutOnly(),
]);
}
const refreshedConfig = this.host._serverCfg;
if (this.host._spaceDialog && refreshedConfig) {
const refreshed = collectSpaceMarkerDependencies(
refreshedConfig, this.host._layout || {}, dialog.spaceId || '',
);
const stillLastSpace = refreshedConfig.spaces.length === 1
&& refreshedConfig.spaces[0]?.id === dialog.spaceId;
this.host._spaceDialog = {
...this.host._spaceDialog,
busy: false,
deleteBlockers: stillLastSpace ? 0 : refreshed.count,
};
}
this.host._showToast(this.host._t('toast.delete_failed', {
err: this.host._errText(error),
}));
}
}
public _startImport(): void {
const dialog = this.host._importDialog;
if (!dialog) return;
const titles = dialog.floors.filter((floor) => floor.checked).map((floor) => floor.name);
this.host._importDialog = null;
if (!titles.length) {
this._openSpaceDialog('create');
return;
}
this.host._importQueue = titles;
this.host._importTotal = titles.length;
this._openNextImport();
}
public _openNextImport(): void {
const title = this.host._importQueue.shift();
if (title === undefined) return;
const cellCm = newSpaceCellCm(this.host._imperial);
this.host._spaceDialog = {
mode: 'create', title, planUrl: null, planFile: null,
...initialSpaceDisplayDraft(),
hideDecor: false, hideOpenings: false, zeroWallStyle: 'dashed',
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom',
customFill: null,
glowEnabled: true,
bgColor: null,
bgMode: 'daynight', northDeg: null, sunRays: null,
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this.host._config?.show_signal ?? true,
cardFontScale: 1,
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
cellCm,
cellCmInput: gridCellFieldValue(cellCm, this.host._imperial),
cellCmTouched: false,
busy: false,
};
}
public _skipImport(): void {
this.host._spaceDialog = null;
if (this.host._importQueue.length) this._openNextImport();
else if (this.host._importTotal > 0 && this.host._model.length) {
this.host._importTotal = 0;
this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || this.host._space);
void this.host._requestMode('plan').then(() => {
this.host._showToast(this.host._t('import.done'));
});
}
}
public _renderImportDialog(): TemplateResult {
const dialog = this.host._importDialog!;
const selected = dialog.floors.filter((floor) => floor.checked).length;
return html`<hp-dialog .hass=${this.host.hass} .title=${this.host._t('import.title')}
icon="mdi:home-floor-1" @hp-close=${() => (this.host._importDialog = null)}>
<div class="body">
<div class="rhint">${this.host._t('import.hint')}</div>
${dialog.floors.map((floor, index) => html`<label class="floorrow">
<input type="checkbox" .checked=${floor.checked}
@change=${(event: Event) => {
const floors = [...dialog.floors];
floors[index] = {
...floor, checked: (event.target as HTMLInputElement).checked,
};
this.host._importDialog = { floors };
}} />
<span>${floor.name}</span>
${floor.level != null ? html`<span class="floorlvl">L${floor.level}</span>` : nothing}
</label>`)}
</div>
<div class="row" slot="footer">
<button class="btn ghost" @click=${() => {
this.host._importDialog = null;
this._openSpaceDialog('create');
}}>${this.host._t('import.manual')}</button>
<span class="spacer"></span>
<button class="btn on" @click=${() => this._startImport()} ?disabled=${!selected}>
<ha-icon icon="mdi:import"></ha-icon>${this.host._t('import.start', { n: selected })}
</button>
</div>
</hp-dialog>`;
}
private _boolInput(value: boolean, change: (checked: boolean) => void): TemplateResult {
return html`<input type="checkbox" .checked=${value}
@change=${(event: Event) => change((event.target as HTMLInputElement).checked)} />`;
}
private _rangeInput(
min: number, max: number, step: number, value: number, change: (next: number) => void,
): TemplateResult {
return html`<input type="range" min=${min} max=${max} step=${step} .value=${String(value)}
@input=${(event: Event) => change(Number((event.target as HTMLInputElement).value))} />`;
}
public _renderSpaceDialog(): TemplateResult {
return this._renderSpaceDialogBody(this.host._spaceDialog!);
}
private _renderSpaceDialogBody(
dialog: NonNullable<HouseplanEditorHostPort['_spaceDialog']>,
): TemplateResult {
const progress = this.host._importTotal > 0 && dialog.mode === 'create'
? this.host._t('import.progress', {
i: this.host._importTotal - this.host._importQueue.length,
n: this.host._importTotal,
}) : '';
const close = () => {
this.host._spaceDialog = null;
this.host._importQueue = [];
this.host._importTotal = 0;
};
return html`<hp-dialog .hass=${this.host.hass}
.title=${`${dialog.mode === 'create'
? this.host._t('space.new') : this.host._t('space.header')}${progress ? ` · ${progress}` : ''}`}
icon="mdi:floor-plan" wide @hp-close=${close}>
<div class="body">
<label>${this.host._t('space.title_label')}</label>
<input class="namein" type="text" placeholder=${this.host._t('space.title_ph')}
.value=${dialog.title}
@input=${(event: Event) => (this.host._spaceDialog = {
...dialog, title: (event.target as HTMLInputElement).value,
})} />
<label>${this.host._t('space.plan_label')}</label>
<label class="srcrow">
<input type="radio" name="plansrc" .checked=${dialog.source === 'file'}
@change=${() => (this.host._spaceDialog = switchSpacePlanSource(dialog, 'file'))} />
<span>${this.host._t('space.source_file')}</span>
</label>
${dialog.source === 'file' ? html`<div class="planrow">
${dialog.planFile
? html`<span class="planname">${dialog.planFile.name}</span>`
: dialog.planUrl
? html`<img class="planprev" src=${this.host._display(dialog.planUrl)}
alt=${this.host._t('space.plan_alt')} />`
: html`<span class="planname muted">${this.host._t('space.no_plan')}</span>`}
<span class="fileupload">
<button class="btn filebtn" type="button" @click=${(event: Event) =>
((event.currentTarget as HTMLElement).nextElementSibling as HTMLInputElement | null)?.click()}>
<ha-icon icon="mdi:upload"></ha-icon>${dialog.planUrl || dialog.planFile
? this.host._t('btn.replace') : this.host._t('btn.upload')}
</button>
<input type="file" hidden
accept=".svg,.png,.jpg,.jpeg,.webp,image/svg+xml,image/png,image/jpeg,image/webp"
@change=${(event: Event) => this._pickPlanFile(event)} />
</span>
<button class="btn ghost" @click=${this._toggleServerPlans}
title=${this.host._t('space.pick_saved_hint')}>
<ha-icon icon="mdi:folder-image"></ha-icon>${this.host._t('space.pick_saved')}
</button>
</div>
${dialog.pickSaved ? this._renderServerPlans(dialog) : nothing}` : nothing}
<label class="srcrow">
<input type="radio" name="plansrc" .checked=${dialog.source === 'draw'}
@change=${() => (this.host._spaceDialog = switchSpacePlanSource(dialog, 'draw'))} />
<span>${this.host._t('space.source_draw')}</span>
</label>
<label>${this.host._t('space.scale_label')}</label>
<div class="colorrow">
<input class="namein tempin" type="number"
min=${gridCellFieldValue(CELL_CM_MIN, this.host._imperial)}
max=${gridCellFieldValue(CELL_CM_MAX, this.host._imperial)}
step="0.1"
.value=${dialog.cellCmInput ?? gridCellFieldValue(dialog.cellCm, this.host._imperial)}
@input=${(event: Event) => {
const raw = (event.target as HTMLInputElement).value;
const parsed = strictNumber(raw);
const canonical = parsed == null ? null
: gridCellFieldToCm(parsed, this.host._imperial);
this.host._spaceDialog = {
...dialog,
cellCmInput: raw,
cellCmTouched: true,
cellCm: canonical != null && canonical > 0
? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, canonical)) : dialog.cellCm,
};
}} />
<span class="opl">${this.host._t(this.host._imperial
? 'space.scale_unit_imperial' : 'space.scale_unit')}</span>
</div>
<label class="dispsection">${this.host._t('space.display_section')}</label>
<label class="srcrow">
${this._boolInput(dialog.showBorders, (value) => {
this.host._spaceDialog = touchSpaceDisplay(dialog, 'showBorders', value);
})}
<span>${this.host._t('space.show_borders')}</span>
</label>
<label>${this.host._t('space.zero_wall_style')}</label>
<select class="areasel" @change=${(event: Event) => {
const value = (event.target as HTMLSelectElement).value;
this.host._spaceDialog = {
...dialog, zeroWallStyle: value === 'solid' ? 'solid' : 'dashed',
};
}}>
<option value="dashed" ?selected=${dialog.zeroWallStyle === 'dashed'}>
${this.host._t('space.zero_wall_dashed')}
</option>
<option value="solid" ?selected=${dialog.zeroWallStyle === 'solid'}>
${this.host._t('space.zero_wall_solid')}
</option>
</select>
<div class="rhint">${this.host._t('space.zero_wall_help')}</div>
<label class="srcrow">
${this._boolInput(dialog.showNames, (value) => {
this.host._spaceDialog = touchSpaceDisplay(dialog, 'showNames', value);
})}
<span>${this.host._t('space.show_names')}</span>
</label>
<label class="srcrow">
${this._boolInput(dialog.showLqi, (value) => {
this.host._spaceDialog = { ...dialog, showLqi: value };
})}
<span>${this.host._t('space.show_lqi')}</span>
</label>
<label class="srcrow">
${this._boolInput(dialog.hideDecor, (value) => {
this.host._spaceDialog = { ...dialog, hideDecor: value };
})}
<span>${this.host._t('space.hide_decor')}</span>
</label>
<div class="rhint">${this.host._t('space.hide_decor_tip')}</div>
<label class="srcrow">
${this._boolInput(dialog.hideOpenings, (value) => {
this.host._spaceDialog = { ...dialog, hideOpenings: value };
})}
<span>${this.host._t('space.hide_openings')}</span>
</label>
<div class="rhint">${this.host._t('space.hide_openings_tip')}</div>
<label class="dispsection">${this.host._t('space.roomcard_section')}</label>
${([
['labelTemp', 'space.label_temp'], ['labelHum', 'space.label_hum'],
['labelLqi', 'space.label_lqi'], ['labelLight', 'space.label_light'],
] as const).map(([field, key]) => html`<label class="srcrow">
${this._boolInput(dialog[field], (value) => {
this.host._spaceDialog = { ...dialog, [field]: value };
})}
<span>${this.host._t(key)}</span>
</label>`)}
<label>${this.host._t('space.card_font')}</label>
<div class="colorrow gsrow">
${this._rangeInput(50, 300, 5, Math.round(dialog.cardFontScale * 100), (value) => {
this.host._spaceDialog = { ...dialog, cardFontScale: value / 100 };
})}
<span class="opv">${Math.round(dialog.cardFontScale * 100)}%</span>
</div>
${this.host._renderCardPreview(dialog.cardFontScale, 1, 1)}
<div class="colorrow">
<hp-color-opacity .label=${this.host._t('space.room_color')}
.opacityLabel=${this.host._t('space.opacity')}
.pickerLabels=${this.host._colorPickerLabels}
.color=${dialog.roomColor} .opacity=${dialog.roomOpacity} .showOpacity=${true}
@hp-color-opacity-change=${(event: CustomEvent<{ color: string; opacity: number }>) => {
this.host._spaceDialog = {
...dialog, roomColor: event.detail.color, roomOpacity: event.detail.opacity,
};
}}></hp-color-opacity>
</div>
<label>${this.host._t('space.bg_mode')}</label>
<select class="areasel" @change=${(event: Event) => {
const value = (event.target as HTMLSelectElement).value;
this.host._spaceDialog = {
...dialog,
bgMode: value === 'static' || value === 'daynight' ? value : null,
};
}}>
<option value="" ?selected=${dialog.bgMode === null}>${this.host._t('space.sun_inherit')}</option>
<option value="static" ?selected=${dialog.bgMode === 'static'}>${this.host._t('gs.bg_static')}</option>
<option value="daynight" ?selected=${dialog.bgMode === 'daynight'}>${this.host._t('gs.bg_daynight')}</option>
</select>
${(dialog.bgMode ?? bgModeOf(this.host._settings, {})) === 'static' ? html`
<div class="colorrow">
<hp-color-opacity .label=${this.host._t('space.bg_color')}
.pickerLabels=${this.host._colorPickerLabels}
.color=${dialog.bgColor || stageBgOf(this.host._settings, { bgColor: null })
|| this.host._stageBgHex()}
.opacity=${1} .showOpacity=${false}
@hp-color-opacity-change=${(event: CustomEvent<{ color: string }>) => {
this.host._spaceDialog = { ...dialog, bgColor: event.detail.color };
}}></hp-color-opacity>
${dialog.bgColor ? html`<button class="btn ghost" @click=${() => {
this.host._spaceDialog = { ...dialog, bgColor: null };
}}>${this.host._t('space.bg_inherit')}</button>`
: html`<span class="opl">${this.host._t('space.bg_inherited')}</span>`}
</div>` : nothing}
<label>${this.host._t('space.north')}</label>
<div class="colorrow">
<input class="namein tempin" type="number" min="0" max="359" step="1"
placeholder=${this.host._t('space.sun_inherit')}
.value=${dialog.northDeg === null ? '' : String(dialog.northDeg)}
@input=${(event: Event) => {
const raw = (event.target as HTMLInputElement).value.trim();
const value = raw === '' ? null : Math.round(Number(raw));
this.host._spaceDialog = {
...dialog,
northDeg: value !== null && Number.isFinite(value)
? Math.min(359, Math.max(0, value)) : null,
};
}} />
<span class="opl">${dialog.northDeg === null
? this.host._t('space.north_inherited', {
v: northDegOf(this.host._settings, {}) === null
? '—' : `${northDegOf(this.host._settings, {})}°`,
}) : '°'}</span>
</div>
<label>${this.host._t('space.sun_rays')}</label>
<select class="areasel" @change=${(event: Event) => {
const value = (event.target as HTMLSelectElement).value;
this.host._spaceDialog = {
...dialog, sunRays: value === '' ? null : value === '1',
};
}}>
<option value="" ?selected=${dialog.sunRays === null}>${this.host._t('space.sun_inherit')}</option>
<option value="1" ?selected=${dialog.sunRays === true}>${this.host._t('space.sun_on')}</option>
<option value="0" ?selected=${dialog.sunRays === false}>${this.host._t('space.sun_off')}</option>
</select>
<label>${this.host._t('space.fill_label')}</label>
${SPACE_FILL_UI_MODES.map((value) => [value, `fill.${value}`] as const).map(
([value, key]) => html`<label class="srcrow">
<input type="radio" name="fillmode" .checked=${dialog.fillMode === value}
@change=${() => (this.host._spaceDialog = { ...dialog, fillMode: value })} />
<span>${this.host._t(key)}</span>
${value === 'temp' && dialog.fillMode === 'temp' ? html`<span class="temprange">
<input class="namein tempin" type="number" step="0.5" .value=${String(dialog.tempMin)}
@input=${(event: Event) => {
const parsed = strictNumber((event.target as HTMLInputElement).value);
if (parsed != null) this.host._spaceDialog = { ...dialog, tempMin: parsed };
}} />
–
<input class="namein tempin" type="number" step="0.5" .value=${String(dialog.tempMax)}
@input=${(event: Event) => {
const parsed = strictNumber((event.target as HTMLInputElement).value);
if (parsed != null) this.host._spaceDialog = { ...dialog, tempMax: parsed };
}} /> °C
</span>` : nothing}
</label>
${value === 'custom' && dialog.fillMode === 'custom' ? html`
<div class="colorrow gsrow">
<span class="gsl">${this.host._t('space.custom_fill')}</span>
<hp-color-opacity .label=${this.host._t('space.custom_fill')}
.opacityLabel=${this.host._t('space.opacity')}
.pickerLabels=${this.host._colorPickerLabels}
.color=${(dialog.customFill || DEFAULT_CUSTOM_FILL).c}
.opacity=${(dialog.customFill || DEFAULT_CUSTOM_FILL).a}
@hp-color-opacity-change=${(event: CustomEvent<{ color: string; opacity: number }>) => {
this.host._spaceDialog = {
...dialog, customFill: { c: event.detail.color, a: event.detail.opacity },
};
}}></hp-color-opacity>
${dialog.customFill ? html`<button class="btn ghost" type="button"
@click=${() => (this.host._spaceDialog = { ...dialog, customFill: null })}>
${this.host._t('btn.reset')}</button>` : nothing}
</div>` : nothing}`,
)}
<label class="srcrow">
${this._boolInput(dialog.glowEnabled, (checked) => {
this.host._spaceDialog = { ...dialog, glowEnabled: checked };
})}
<span>${this.host._t('space.glow_enabled')}</span>
</label>
${dialog.deleteBlockers ? html`<div class="backuperror" role="alert">
${this.host._t('space.delete_blocked', { n: String(dialog.deleteBlockers) })}
</div>` : nothing}
</div>
<div class="row dialog-action-footer" slot="footer">
${dialog.mode === 'edit' ? html`<div class="dialog-action-group dialog-action-danger">
<button class="btn danger" @click=${() => this._deleteSpace()} ?disabled=${dialog.busy}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this.host._t('btn.delete')}
</button>
</div>` : nothing}
<div class="dialog-action-group dialog-action-commit">
${this.host._importTotal > 0 && dialog.mode === 'create'
? html`<button class="btn ghost" @click=${() => this._skipImport()}>
${this.host._t('btn.skip')}</button>` : nothing}
<button class="btn ghost" @click=${close}>${this.host._t('btn.cancel')}</button>
<button class="btn on" @click=${() => this._saveSpaceDialog()}
?disabled=${!dialog.title.trim()
|| (dialog.source === 'file' && !(dialog.planFile || dialog.planUrl)) || dialog.busy}
title=${dialog.source === 'file' && !(dialog.planFile || dialog.planUrl)
? this.host._t('title.need_plan') : ''}>
<ha-icon icon="mdi:check"></ha-icon>${dialog.busy ? '…' : this.host._t('btn.save')}
</button>
</div>
</div>
</hp-dialog>`;
}
}
+4
View File
@@ -5,6 +5,10 @@
"editor.open_group": "Tool group: {group}",
"editor.group_active": "{group} — active: {item}",
"editor.disabled_action": "{action} is unavailable: {reason}",
"editor.loading": "Loading editor…",
"editor.loading_aria": "The plan editor is loading",
"editor.load_failed": "Could not load the editor.",
"editor.refresh_advice": "Refresh the page and try again.",
"color_picker.title": "Color picker",
"color_picker.hue": "Hue",
"color_picker.saturation": "Saturation",
+4
View File
@@ -5,6 +5,10 @@
"editor.open_group": "Группа инструментов: {group}",
"editor.group_active": "{group} — активно: {item}",
"editor.disabled_action": "{action} недоступно: {reason}",
"editor.loading": "Загружаем редактор…",
"editor.loading_aria": "Редактор плана загружается",
"editor.load_failed": "Не удалось загрузить редактор.",
"editor.refresh_advice": "Обновите страницу и повторите попытку.",
"color_picker.title": "Выбор цвета",
"color_picker.hue": "Оттенок",
"color_picker.saturation": "Насыщенность",
+1 -14
View File
@@ -15,6 +15,7 @@ import {
polygonArea, segmentsProperlyCross, polyContainsPoly, roomsOverlap,
} from './logic';
import { classifyNearAxisSegment } from './near-axis';
export { areaM2, formatArea } from './area-format';
/** Minimal room dimension in centimetres (owner: «мин. габарит ~30 см»). */
export const MIN_ROOM_CM = 30;
@@ -1347,17 +1348,3 @@ export function clampRoomScale(
}
return good;
}
// ---------------- live numbers ----------------
/** Room area in m² from render units via the grid scale. */
export function areaM2(poly: number[][], gridPitch: number, cellCm: number): number {
const cmPerUnit = cellCm / gridPitch;
return (polygonArea(poly) * cmPerUnit * cmPerUnit) / 1e4;
}
/** "12.4 m²" or "133 ft²" per the HA unit system. */
export function formatArea(m2: number, imperial: boolean): string {
if (imperial) return `${Math.round(m2 * 10.7639)} ft²`;
return `${(Math.round(m2 * 10) / 10).toFixed(1)} m²`;
}
+33 -1
View File
@@ -114,9 +114,41 @@ export const dialogsStyles = css`
--mdc-icon-size: 44px;
color: var(--hp-accent);
}
.editorloading {
position: absolute;
z-index: 74;
left: 50%;
top: 50%;
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-5);
border: 1px solid color-mix(in srgb, var(--hp-accent) 45%, transparent);
border-radius: 999px;
background: color-mix(in srgb,
var(--ha-card-background, var(--card-background-color, #111)) 92%, transparent);
color: var(--primary-text-color);
box-shadow: 0 6px 22px rgb(0 0 0 / 18%);
transform: translate(-50%, -50%);
pointer-events: none;
animation: editor-loading-in 0.15s ease both;
}
.editorloading ha-icon {
--mdc-icon-size: 22px;
color: var(--hp-accent);
animation: editor-loading-spin 0.9s linear infinite;
}
@keyframes editor-loading-in {
from { opacity: 0; transform: translate(-50%, calc(-50% + 4px)); }
to { opacity: 1; transform: translate(-50%, -50%); }
}
@keyframes editor-loading-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.recoveryoverlay {
.recoveryoverlay,
.editorloading,
.editorloading ha-icon {
transition: none;
animation: none;
}
}
.oplock {
+4
View File
@@ -0,0 +1,4 @@
/** Compatibility projection for a session token written by the old toolbar. */
export function normalizeUnifiedWallTool(value: unknown): unknown {
return value === 'partition' ? 'draw' : value;
}
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
buildBundleManifest, buildFingerprintPlugin, editorRuntimeRetryUrlPlugin,
} from '../scripts/bundle-manifest.mjs';
import { assertBundleBudget } from '../scripts/bundle-budget.mjs';
import { compareBundleTrees, sha256Bytes, verifyBundleTree } from '../scripts/bundle-tree.mjs';
import {
minifyCssText, minifyStaticCssTemplates,
} from '../scripts/css-template-minifier.mjs';
test('CSS template minifier preserves semantic whitespace, strings and functions', () => {
const css = `
/* owner note */
.a .b, .c > .d { --pair: 1 2; width: calc(100% - 2px); }
.quoted { content: "a b /* text */"; background: url("a b.png"); }
.joined/**/.state { color: red; }
`;
assert.equal(
minifyCssText(css),
'.a .b,.c>.d{--pair:1 2;width:calc(100% - 2px);}.quoted{content:"a b /* text */";background:url("a b.png");}.joined.state{color:red;}',
);
assert.equal(
minifyStaticCssTemplates('const s = css` .a { content: "\\`"; } `;', 'fixture.ts'),
'const s = css`.a{content:"\\`";}`;',
);
});
test('CSS template minifier fails closed on interpolation and malformed input', () => {
assert.throws(
() => minifyStaticCssTemplates('const s = css`color:${value}`;', 'fixture.ts'),
/interpolated css template/,
);
assert.throws(() => minifyCssText('.a{/* nope'), /unclosed CSS comment/);
assert.throws(() => minifyCssText('.a{content:"nope}'), /unclosed CSS string/);
});
test('bundle manifest separates static initial graph from dynamic editor graph', () => {
const manifest = buildBundleManifest({
'houseplan-card.js': {
type: 'chunk', fileName: 'houseplan-card.js', code: 'entry', isEntry: true,
imports: ['shared.js'], dynamicImports: [
'houseplan-assets/editor.js', 'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
],
},
'shared.js': {
type: 'chunk', fileName: 'shared.js', code: 'shared', isEntry: false,
imports: [], dynamicImports: [],
},
'houseplan-assets/editor.js': {
type: 'chunk', fileName: 'houseplan-assets/editor.js', code: 'editor', isEntry: false,
imports: ['shared.js'], dynamicImports: [],
},
'houseplan-assets/houseplan-onboarding-runtime-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
code: 'onboarding', isEntry: false,
imports: ['shared.js'], dynamicImports: [],
},
}, 'fingerprint');
assert.deepEqual(manifest.initialViewFiles, ['houseplan-card.js', 'shared.js']);
assert.deepEqual(manifest.lazyEditorFiles, ['houseplan-assets/editor.js']);
assert.deepEqual(manifest.lazyOnboardingFiles, [
'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
]);
assert.deepEqual(manifest.lazyFiles, [
'houseplan-assets/editor.js', 'houseplan-assets/houseplan-onboarding-runtime-HASH.js',
]);
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000));
assert.throws(() => assertBundleBudget(manifest, 1), /exceeds/);
});
test('build fingerprint is embedded for Windows and POSIX source ids', () => {
const plugin = buildFingerprintPlugin('exact-build');
for (const id of ['C:\\repo\\src\\houseplan-card.ts', '/repo/src/houseplan-editor-runtime.ts']) {
const transformed = plugin.transform(
'export const fingerprint = "__HOUSEPLAN_SOURCE_FINGERPRINT__";', id,
);
assert.match(transformed.code, /"exact-build"/);
}
});
test('retry URL points at the content-hashed runtime chunk after naming', () => {
const plugin = editorRuntimeRetryUrlPlugin();
const bundle = {
'houseplan-assets/houseplan-card.js': {
type: 'chunk', fileName: 'houseplan-assets/houseplan-card.js',
code: 'new URL("__HOUSEPLAN_EDITOR_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_ONBOARDING_RETRY_ASSET__", import.meta.url)', modules: {},
},
'houseplan-assets/houseplan-editor-runtime-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/houseplan-editor-runtime-HASH.js', code: '',
modules: { '/repo/src/houseplan-editor-runtime.ts': {} },
},
'houseplan-assets/houseplan-onboarding-runtime-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/houseplan-onboarding-runtime-HASH.js', code: '',
modules: { '/repo/src/houseplan-onboarding-runtime.ts': {} },
},
};
plugin.generateBundle({}, bundle);
assert.equal(
bundle['houseplan-assets/houseplan-card.js'].code,
'new URL("./houseplan-editor-runtime-HASH.js", import.meta.url);'
+ 'new URL("./houseplan-onboarding-runtime-HASH.js", import.meta.url)',
);
});
test('bundle tree verification fails for a missing or tampered manifest asset', () => {
const temp = mkdtempSync(join(tmpdir(), 'houseplan-bundle-tree-'));
const source = join(temp, 'source');
const target = join(temp, 'target');
try {
for (const root of [source, target]) {
mkdirSync(join(root, 'houseplan-assets'), { recursive: true });
writeFileSync(join(root, 'houseplan-card.js'), 'entry');
writeFileSync(join(root, 'houseplan-assets', 'editor-hash.js'), 'editor');
const files = ['houseplan-card.js', 'houseplan-assets/editor-hash.js'].map((path) => ({
path, sha256: sha256Bytes(readFileSync(join(root, path))),
}));
writeFileSync(join(root, 'houseplan-assets.json'), `${JSON.stringify({
schema: 1, fingerprint: 'fixture', entry: 'houseplan-card.js', files,
})}\n`);
}
assert.doesNotThrow(() => compareBundleTrees(source, target));
writeFileSync(join(target, 'houseplan-assets', 'editor-hash.js'), 'tampered');
assert.throws(() => verifyBundleTree(target), /manifest hash mismatch/);
rmSync(join(target, 'houseplan-assets', 'editor-hash.js'));
assert.throws(() => verifyBundleTree(target), /manifest asset is missing/);
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
+30
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
@@ -44,6 +45,35 @@ test('bundle freshness rejects a missing or stale fingerprint', async () => {
}
});
test('bundle freshness verifies every manifest-listed demo asset', async () => {
const root = fixtureRoot();
try {
const expected = sourceFingerprint(root);
const assets = resolve(root, 'demo/srv/assets');
mkdirSync(resolve(assets, 'houseplan-assets'), { recursive: true });
const files = [
['houseplan-card.js', 'entry'],
['houseplan-assets/editor-HASH.js', 'editor'],
];
for (const [name, contents] of files) writeFileSync(resolve(assets, name), contents, 'utf8');
writeFileSync(resolve(assets, 'houseplan-assets.json'), JSON.stringify({
schema: 1,
fingerprint: expected,
files: files.map(([path, contents]) => ({
path,
sha256: createHash('sha256').update(contents).digest('hex'),
})),
}), 'utf8');
const page = { evaluate: async () => expected };
assert.equal(await assertFreshDemoBundle(page, root), expected);
writeFileSync(resolve(assets, 'houseplan-assets/editor-HASH.js'), 'tampered', 'utf8');
await assert.rejects(assertFreshDemoBundle(page, root), /hash mismatch/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('bundle freshness uses the target tree fingerprint contract', async () => {
const root = fixtureRoot();
try {
+3 -2
View File
@@ -5,6 +5,7 @@ import {
hexToRgb, hsvToHex, hsvToRgb, normalizeHexColor, normalizeHue, rgbToHex, rgbToHsv,
} from '../test-build/color-picker.js';
import { readAllStylesSource } from './styles-source.mjs';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
test('hex drafts accept only three or six digits and normalize on commit', () => {
assert.equal(normalizeHexColor('#AbC'), '#aabbcc');
@@ -40,7 +41,7 @@ test('HSV helpers wrap hue, clamp finite dimensions and keep grayscale achromati
test('the shared component keeps its API and contains no nested native color picker', () => {
const component = readFileSync(new URL('../src/hp-color-opacity.ts', import.meta.url), 'utf8');
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const card = readHouseplanProductionSource();
const sourceFiles = (directory) => readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const target = new URL(entry.name + (entry.isDirectory() ? '/' : ''), directory);
return entry.isDirectory() ? sourceFiles(target) : entry.name.endsWith('.ts') ? [target] : [];
@@ -80,7 +81,7 @@ test('the hue range exposes one cyclic spectrum without restyling other ranges',
});
test('activity color and ripple size keep independent readable rows', () => {
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const card = readHouseplanProductionSource();
const styles = readAllStylesSource();
const start = card.indexOf("d.display === 'icon_ripple'");
const end = card.indexOf("marker.activity_alarm_note", start);
+7 -2
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
import {
COORDINATE_DECIMALS,
@@ -110,11 +111,15 @@ test('one position changes only x/y and preserves future metadata (#224)', () =>
});
test('frontend write paths adopt canonical candidates before persistence (#224)', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const source = readHouseplanProductionSource();
assert.match(source, /enqueueSerializedWrite\(this\._writeChain, async \(\) =>/);
assert.match(
source,
/const candidate = canonicalizeConfigGeometry\(this\._serverCfg\);[\s\S]*config: candidate/,
/const candidate = canonicalizeConfigGeometry\(this\._serverCfg\);/,
);
assert.match(
source,
/const canonicalCandidate = canonicalizeConfigGeometry\(candidate\);[\s\S]*config: canonicalCandidate/,
);
assert.match(
source,
@@ -3,11 +3,14 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readAllStylesSource } from './styles-source.mjs';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
// #266: styles.ts is being split into surface files — style greps must
// see the whole sheet, other sources keep the direct read.
const source = (name) => name === 'styles.ts'
? readAllStylesSource()
: name === 'houseplan-card.ts'
? readHouseplanProductionSource()
: readFileSync(new URL(`../src/${name}`, import.meta.url), 'utf8');
test('issue 213 resolves the effective base before the face without a late visual factor', () => {
+105
View File
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import test from 'node:test';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { EditorRuntimeLoader } from '../test-build/editor-runtime-loader.js';
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const deferred = () => {
let resolve;
let reject;
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
return { promise, resolve, reject };
};
test('editor runtime loader deduplicates concurrent intent and installs atomically', async () => {
const pending = deferred();
const states = [];
const installed = [];
let loads = 0;
const loader = new EditorRuntimeLoader({
expectedFingerprint: 'same',
load: async () => { loads++; return pending.promise; },
install: (runtime) => installed.push(runtime),
stateChanged: (state) => states.push(state),
});
const first = loader.ensure();
const second = loader.ensure();
assert.equal(loads, 1);
assert.equal(loader.state, 'loading');
assert.equal(installed.length, 0);
pending.resolve({ fingerprint: 'same', create: () => ({ ready: true }) });
assert.deepEqual(await Promise.all([first, second]), [true, true]);
assert.equal(loads, 1);
assert.deepEqual(installed, [{ ready: true }]);
assert.deepEqual(states, ['loading', 'ready']);
});
test('editor runtime loader retries the first failure exactly once', async () => {
const attempts = [];
const loader = new EditorRuntimeLoader({
expectedFingerprint: 'same',
load: async (attempt) => {
attempts.push(attempt);
if (attempt === 0) throw new Error('404');
return { fingerprint: 'same', create: () => 'runtime' };
},
install: () => {},
});
assert.equal(await loader.ensure(), true);
assert.deepEqual(attempts, [0, 1]);
assert.equal(await loader.ensure(), true);
assert.deepEqual(attempts, [0, 1], 'a ready loader never imports again');
});
test('fingerprint mismatch fails closed after one retry and remains terminal', async () => {
const attempts = [];
const installed = [];
const failures = [];
const loader = new EditorRuntimeLoader({
expectedFingerprint: 'entry',
load: async (attempt) => {
attempts.push(attempt);
return { fingerprint: 'other', create: () => 'mixed-runtime' };
},
install: (runtime) => installed.push(runtime),
failed: (error) => failures.push(String(error)),
});
assert.equal(await loader.ensure(), false);
assert.deepEqual(attempts, [0, 1]);
assert.deepEqual(installed, []);
assert.equal(loader.state, 'failed');
assert.match(failures[0], /fingerprint mismatch/);
assert.equal(await loader.ensure(), false);
assert.deepEqual(attempts, [0, 1], 'terminal failure requires a page refresh');
});
test('lazy runtime boundary keeps the eager entry independent and does not grow any usage', () => {
const card = readFileSync(join(repoRoot, 'src', 'houseplan-card.ts'), 'utf8');
assert.doesNotMatch(
card,
/^import(?!\s+type\b)[^;]*houseplan-editor-runtime[^;]*;/m,
'the eager card must not statically import the editor runtime',
);
const sources = [];
const visit = (directory) => {
for (const name of readdirSync(directory)) {
const path = join(directory, name);
if (statSync(path).isDirectory()) visit(path);
else if (/\.ts$/.test(name)) sources.push(readFileSync(path, 'utf8'));
}
};
visit(join(repoRoot, 'src'));
const anyCount = sources.reduce(
(total, source) => total + (source.match(/\bany\b/g)?.length || 0), 0,
);
assert.ok(anyCount <= 1123, `src any count grew from 1123 to ${anyCount}`);
});
+2 -1
View File
@@ -1,8 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const card = readHouseplanProductionSource();
const editor = readFileSync(new URL('../src/editor.ts', import.meta.url), 'utf8');
test('active-space writes go through the fixed-floor mutation boundary', () => {
+3 -2
View File
@@ -6,6 +6,7 @@ import {
GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS, OPENING_SYMBOL_EXISTING_GOLDEN_IMPACT,
} from '../demo/golden/matrix.mjs';
import { fixtureWallKey } from '../demo/fixtures/visual-matrix.mjs';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
test('golden matrix has stable unique ids and bounded comparison thresholds', () => {
assert.equal(Number.isInteger(GOLDEN_MATRIX_VERSION) && GOLDEN_MATRIX_VERSION > 0, true);
@@ -739,7 +740,7 @@ test('golden overrides fail closed on misspelled fixture references', () => {
});
test('a light source paints exactly one region: the floor it can see', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const source = readHouseplanProductionSource();
// One region per source, and it is the visibility polygon clipped to floor.
assert.match(source, /visibilityPolygon\(\[pos\.x, pos\.y\], R, occluders/);
assert.match(source,
@@ -772,7 +773,7 @@ test('a light source paints exactly one region: the floor it can see', () => {
});
test('all destructive editor dialogs use the medium shell and shared responsive footer groups', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const source = readHouseplanProductionSource();
for (const method of ['_renderOpeningDialog', '_renderPhysicalDialog', '_renderSpaceDialog']) {
const start = source.indexOf(`private ${method}`);
assert.notEqual(start, -1, method);
+55
View File
@@ -0,0 +1,55 @@
import { readFileSync } from 'node:fs';
import ts from 'typescript';
/**
* Source-contract tests historically inspected one monolithic card file.
* Editor implementations are now lazy, so expose the same logical production
* surface with the implementation before its typed host stubs.
*/
export function readHouseplanProductionSource() {
const runtime = readFileSync(
new URL('../src/houseplan-editor-runtime.ts', import.meta.url),
'utf8',
);
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const parse = (name, source) => ts.createSourceFile(
name, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS,
);
const runtimeFile = parse('houseplan-editor-runtime.ts', runtime);
const cardFile = parse('houseplan-card.ts', card);
const classOf = (file, name) => file.statements.find(
(statement) => ts.isClassDeclaration(statement) && statement.name?.text === name,
);
const runtimeClass = classOf(runtimeFile, 'HouseplanEditorRuntime');
const cardClass = classOf(cardFile, 'HouseplanCard');
if (!runtimeClass || !cardClass) throw new Error('Houseplan source classes are missing');
const nameOf = (member, file) => member.name?.getText(file);
const implementations = new Map(runtimeClass.members
.map((member) => [nameOf(member, runtimeFile), member])
.filter(([name]) => name));
const replacements = [];
for (const member of cardClass.members) {
const name = nameOf(member, cardFile);
const implementation = implementations.get(name);
if (!implementation) continue;
const baseText = card.slice(member.getStart(cardFile), member.end);
if (!baseText.includes('_editorRuntime')) continue;
const visibility = /^(private|protected|public)\b/.exec(baseText)?.[1] || 'private';
const implementationText = runtime
.slice(implementation.getStart(runtimeFile), implementation.end)
.replace(/^public\b/, visibility)
.replaceAll('this.host.', 'this.');
replacements.push({
from: member.getStart(cardFile),
to: member.end,
text: implementationText,
});
}
let reconstructed = card;
for (const replacement of replacements.sort((a, b) => b.from - a.from)) {
reconstructed = reconstructed.slice(0, replacement.from)
+ replacement.text
+ reconstructed.slice(replacement.to);
}
return reconstructed;
}
+2 -1
View File
@@ -12,13 +12,14 @@ import {
resolveLanguageCode,
} from '../test-build/i18n/registry.js';
import { langOf } from '../test-build/i18n.js';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
const dictionaries = new Map(
LANGUAGE_REGISTRY.map(({ code, dictionary }) => [code, dictionary]),
);
const en = dictionaries.get('en');
const ru = dictionaries.get('ru');
const cardSource = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const cardSource = readHouseplanProductionSource();
test('i18n: registry codes and English fallback are valid', () => {
const codes = LANGUAGE_REGISTRY.map(({ code }) => code);
+2 -3
View File
@@ -9,6 +9,7 @@ import {
cmToUnits, newViolations,
} from '../test-build/junction-limits.js';
import { GRID_STEP_N } from '../test-build/space-geometry.js';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
// #329, решения владельца 2026-08-27. Пороги абсолютные: 15°, 6 стен,
// max(20 см, толщина), 5 см, 25 см². cell_cm на них не влияет.
@@ -383,9 +384,7 @@ test('#330 AC4: кэш baseline инвалидируется по конфиг-
// приёмом, каким #293 пинит обвязку смока. Поведенческая половина AC4
// (N move → N+1 вычислений в реальном жесте) живёт в
// demo/smoke_junction_limits.mjs (resizeBaselineCachedPerGesture).
const source = readFileSync(
new URL('../src/houseplan-card.ts', import.meta.url), 'utf8',
);
const source = readHouseplanProductionSource();
const method = source.slice(
source.indexOf('private _junctionLimitsIntroduced('),
source.indexOf('private _commitPhysicalGeometry('),
+3 -2
View File
@@ -3,9 +3,10 @@ import { readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
const srcRoot = fileURLToPath(new URL('../src/', import.meta.url));
const source = readFileSync(join(srcRoot, 'houseplan-card.ts'), 'utf8');
const source = readHouseplanProductionSource();
const walkTypescript = (directory) => readdirSync(directory, { withFileTypes: true })
.flatMap((entry) => {
const path = join(directory, entry.name);
@@ -27,7 +28,7 @@ test('native selects explicitly project selection unless they are command menus'
// This is a command menu, not persisted state: it intentionally resets to
// its empty first row after inserting an HA variable into decorative text.
assert.deepEqual(unsafe, ['houseplan-card.ts:decor-live-attribute']);
assert.deepEqual(unsafe, ['houseplan-editor-runtime.ts:decor-live-attribute']);
});
test('persisted dynamic marker selects project their selected option explicitly', () => {
+2 -1
View File
@@ -12,6 +12,7 @@ import {
import { optimizePlans } from '../test-build/plan-optimizer.js';
import { MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES } from '../test-build/wall-thickness.js';
import { nearAxisProfile } from '../scripts/model-invariants.mjs';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/279-near-orthogonal-junction.json', import.meta.url), 'utf8',
@@ -21,7 +22,7 @@ const sourceOf = (file) => readFileSync(new URL(`../src/${file}`, import.meta.ur
test('#290 authoring, Optimize and renderer use one near-axis threshold source', () => {
const renderer = sourceOf('wall-thickness.ts');
const authoring = sourceOf('houseplan-card.ts');
const authoring = readHouseplanProductionSource();
const optimizer = sourceOf('plan-optimizer.ts');
assert.match(renderer, /import \{ NEAR_AXIS_MAX_DEGREES \} from '\.\/near-axis';/);
+2 -1
View File
@@ -2,8 +2,9 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readAllStylesSource } from './styles-source.mjs';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const card = readHouseplanProductionSource();
const styles = readAllStylesSource();
const staticRender = readFileSync(new URL('../src/space-render.ts', import.meta.url), 'utf8');
const backend = readFileSync(new URL('../custom_components/houseplan/websocket_api.py', import.meta.url), 'utf8');
+2 -1
View File
@@ -1,8 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const source = readHouseplanProductionSource();
const methodBody = (name) => {
const start = source.search(new RegExp(`(?:private|protected)\\s+(?:get\\s+|async\\s+)?${name}(?:\\s*=)?(?:\\(|:)`));
+6 -5
View File
@@ -6,6 +6,7 @@ import {
GLOW_CARD_CONTRACT,
LARGE_HOUSE_CARD_CONTRACT,
} from '../demo/performance/card-contract.mjs';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
const directCardMembers = (relativePath) => {
const source = readFileSync(new URL(`../${relativePath}`, import.meta.url), 'utf8');
@@ -38,10 +39,10 @@ test('Glow benchmark declares every private card member it consumes', () => {
});
test('performance contracts reference real production members', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const source = readHouseplanProductionSource();
for (const contract of [LARGE_HOUSE_CARD_CONTRACT, GLOW_CARD_CONTRACT]) {
for (const name of declaredMembers(contract)) {
assert.match(source, new RegExp(`\\b(?:private\\s+(?:get\\s+)?|get\\s+)${name}\\b`),
assert.match(source, new RegExp(`\\b(?:private\\s+(?:declare\\s+|get\\s+)?|get\\s+)${name}\\b`),
`${contract.label} declares missing production member ${name}`);
}
}
@@ -66,7 +67,7 @@ test('contract accepts recent optional fields only when their runtime type is va
});
test('view render resolves structural wall cuts once per frame, not once per room', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const source = readHouseplanProductionSource();
const start = source.indexOf('const allZeroCuts = this._openCuts();');
const end = source.indexOf('${this._renderOpeningTunnelFills(space, roomFills)}', start);
assert.ok(start >= 0 && end > start, 'view room-render block is present');
@@ -87,7 +88,7 @@ test('view render resolves structural wall cuts once per frame, not once per roo
});
test('room inner faces are structurally cached and shared by both fill layers', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const source = readHouseplanProductionSource();
const helperStart = source.indexOf('private _innerRoomContour(');
const helperEnd = source.indexOf('\n /**', helperStart);
assert.ok(helperStart >= 0 && helperEnd > helperStart, 'inner-contour cache helper is present');
@@ -113,7 +114,7 @@ test('room inner faces are structurally cached and shared by both fill layers',
});
test('wall and light geometry reuse bounded caches before structural work', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const source = readHouseplanProductionSource();
const unionStart = source.indexOf('private _wallUnionGeometry()');
const unionEnd = source.indexOf('\n /** Thick-wall spans', unionStart);
+6 -5
View File
@@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs';
import {
createRenderDeviceSnapshot, presentationSnapshotKey, renderDeviceSnapshotPositions,
} from '../test-build/render-device-snapshot.js';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
test('snapshot positions skip resolution until a renderable plan exists', () => {
const devices = [{ id: 'one' }, { id: 'two' }];
@@ -71,7 +72,7 @@ const methodBody = (source, name) => {
};
test('atomic plan render paths do not bypass RenderDeviceSnapshot with this.hass', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const source = readHouseplanProductionSource();
for (const name of [
'_roomLqi', '_resolvedRoomFills', '_sunNow', '_renderSunRays', '_renderGlowLayer',
'_renderVacuums', '_renderVacFit', '_renderDevice', '_roomTemp', '_roomHum', '_openingAmt',
@@ -82,7 +83,7 @@ test('atomic plan render paths do not bypass RenderDeviceSnapshot with this.hass
});
test('the card gates snapshot positions on the render model', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const source = readHouseplanProductionSource();
const capture = methodBody(source, '_captureRenderDeviceSnapshot');
assert.match(
capture,
@@ -97,7 +98,7 @@ test('the card gates snapshot positions on the render model', () => {
});
test('opening references use their own availability policy without weakening plan tombstones', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const source = readHouseplanProductionSource();
for (const name of [
'_contactCandidates', '_lockCandidates', '_openingAmt', '_renderOpenings',
'_renderOpeningLocks', '_renderOpeningInfoCard', '_lockAction',
@@ -125,7 +126,7 @@ test('opening references use their own availability policy without weakening pla
});
test('lock actuation remains guarded inside the one sanctioned opening-card method', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const source = readHouseplanProductionSource();
const action = methodBody(source, '_lockAction');
const guardAt = action.indexOf('_openingEntityAvailable(entityId)');
const confirmAt = action.indexOf('confirm(');
@@ -136,7 +137,7 @@ test('lock actuation remains guarded inside the one sanctioned opening-card meth
});
test('marker delete/re-add and opening save remain separate config transactions', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const source = readHouseplanProductionSource();
const saveOpening = methodBody(source, '_saveOpening');
const saveMarker = methodBody(source, '_saveMarker');
assert.match(saveOpening, /sp\.openings/);
+4 -2
View File
@@ -2,10 +2,12 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { readAllStylesSource } from './styles-source.mjs';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
const card = fs.readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const card = readHouseplanProductionSource();
const resize = fs.readFileSync(new URL('../src/resize.ts', import.meta.url), 'utf8');
const controller = fs.readFileSync(new URL('../src/resize-controller.ts', import.meta.url), 'utf8');
const editorRuntime = fs.readFileSync(new URL('../src/houseplan-editor-runtime.ts', import.meta.url), 'utf8');
const wallInvariant = fs.readFileSync(new URL('../src/wall-record-preservation.ts', import.meta.url), 'utf8');
const invariantCli = fs.readFileSync(new URL('../scripts/model-invariants.mjs', import.meta.url), 'utf8');
const styles = readAllStylesSource();
@@ -99,7 +101,7 @@ test('#264 Resize controller is the sole mutable gesture owner', () => {
for (const mirror of ['_rszSel', '_rszDrag', '_rszPreview', '_rszLive', '_rszEligibilityCache']) {
assert.equal(card.includes(`private ${mirror}`), false, `${mirror} remains a root state mirror`);
}
assert.match(card, /new ResizeController</);
assert.match(editorRuntime, /new ResizeController</);
assert.match(controller, /class ResizeController/);
assert.doesNotMatch(controller, /LitElement|PointerEvent|requestUpdate|_serverCfg|_writeConfig/);
});
+1 -1
View File
@@ -43,7 +43,7 @@ const UNIT_FORMATTING = new RegExp(
*/
const FORMATTERS = [
{ file: 'src/logic.ts', anchor: 'export function formatLength(', lines: 12 },
{ file: 'src/resize.ts', anchor: 'export function formatArea(', lines: 8 },
{ file: 'src/area-format.ts', anchor: 'export function formatArea(', lines: 8 },
];
const sourceFiles = (root) => {
+2 -1
View File
@@ -16,6 +16,7 @@ import {
} from '../test-build/wall-segment-model.js';
import { wallKey } from '../test-build/wall-thickness.js';
import { GRID_STEP_N } from '../test-build/space-geometry.js';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
const rectangle = (id, x1 = 0, y1 = 0, x2 = 1, y2 = 1) => ({
id,
@@ -377,7 +378,7 @@ test('initial migration resolves a reserved deterministic id with the documented
});
test('every frontend structural transaction crosses the wall identity barrier', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const source = readHouseplanProductionSource();
const optimizer = readFileSync(new URL('../src/plan-optimizer.ts', import.meta.url), 'utf8');
const methodSource = (name) => {
const start = source.indexOf(`private ${name}(`);
+2 -1
View File
@@ -10,6 +10,7 @@ import {
import { spaceModels } from '../test-build/space-geometry.js';
import { wallBodiesGeometry, wallBodiesUnionPath } from '../test-build/wall-thickness.js';
import { checkPhysicalGeometry } from '../scripts/model-invariants.mjs';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
@@ -98,7 +99,7 @@ test('#278 physical fingerprint ignores decor but covers every strict writer fie
});
test('#278 production source routes physical writers through one barrier and decor around it', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const source = readHouseplanProductionSource();
for (const historyKey of [
'draft_segment', 'wall_chain_finish', 'column_add', 'physical_edit', 'physical_delete',
'physical_move', 'resize_room', 'wall_thickness',
+68
View File
@@ -0,0 +1,68 @@
"""Pure path/manifest guards for public frontend chunks (#337)."""
from __future__ import annotations
import json
import importlib.util
from pathlib import Path
_PATH = Path(__file__).parents[1] / "custom_components" / "houseplan" / "frontend_asset_manifest.py"
_SPEC = importlib.util.spec_from_file_location("hp_frontend_asset_manifest", _PATH)
assert _SPEC and _SPEC.loader
_MODULE = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(_MODULE)
resolve_frontend_asset = _MODULE.resolve_frontend_asset
def _root(tmp_path: Path) -> Path:
root = tmp_path / "frontend"
assets = root / "houseplan-assets"
assets.mkdir(parents=True)
(assets / "editor-abc.js").write_text("export {};", encoding="utf-8")
(assets / "stale.js").write_text("export {};", encoding="utf-8")
(root / "houseplan-assets.json").write_text(json.dumps({
"schema": 1,
"files": [{"path": "houseplan-assets/editor-abc.js"}],
}), encoding="utf-8")
return root
def test_frontend_asset_requires_current_manifest_entry(tmp_path: Path) -> None:
root = _root(tmp_path)
assert resolve_frontend_asset(root, "editor-abc.js") == (
root / "houseplan-assets" / "editor-abc.js"
).resolve()
assert resolve_frontend_asset(root, "stale.js") is None
assert resolve_frontend_asset(root, "missing.js") is None
assert resolve_frontend_asset(root, "houseplan-assets.json") is None
def test_frontend_asset_refuses_traversal_and_malformed_manifest(tmp_path: Path) -> None:
root = _root(tmp_path)
for value in (
"../editor-abc.js", "..\\editor-abc.js", "nested/editor-abc.js",
"..%2feditor-abc.js", "editor-abc.css", "..", "",
):
assert resolve_frontend_asset(root, value) is None
(root / "houseplan-assets.json").write_text("not json", encoding="utf-8")
assert resolve_frontend_asset(root, "editor-abc.js") is None
def test_frontend_asset_refuses_wrong_schema_and_symlink_escape(tmp_path: Path) -> None:
root = _root(tmp_path)
manifest = root / "houseplan-assets.json"
manifest.write_text(json.dumps({
"schema": 2, "files": [{"path": "houseplan-assets/editor-abc.js"}],
}), encoding="utf-8")
assert resolve_frontend_asset(root, "editor-abc.js") is None
outside = tmp_path / "outside.js"
outside.write_text("outside", encoding="utf-8")
link = root / "houseplan-assets" / "linked.js"
try:
link.symlink_to(outside)
except OSError:
return # Windows without Developer Mode; containment is covered elsewhere.
manifest.write_text(json.dumps({
"schema": 1, "files": [{"path": "houseplan-assets/linked.js"}],
}), encoding="utf-8")
assert resolve_frontend_asset(root, "linked.js") is None
+1 -1
View File
@@ -24,7 +24,7 @@
"src/space-geometry.ts", "src/junction-limits.ts",
"src/space-order.ts", "src/card-editor-validation.ts",
"src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts",
"src/visual-continuity.ts", "src/mode-transition.ts", "src/pointer-modality.ts",
"src/visual-continuity.ts", "src/mode-transition.ts", "src/editor-runtime-loader.ts", "src/pointer-modality.ts",
"src/render-device-snapshot.ts",
"src/command-stack.ts",
"src/coordinate-canonicalization.ts",