Compare commits

...
Author SHA1 Message Date
claude[bot] 5e78c00672 docs: review document for #337
Проверка (CI) / Классификация изменённых файлов (push) Successful in 23s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 45s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 58s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 22s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 20s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 12m3s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 11m39s
Issue: #337
User-Visible: no
2026-08-28 05:40:26 +00:00
Matysh 0bd5570ae5 docs: accept lazy editor screenshot fingerprint
Reviewed artifact: https://github.com/Matysh/houseplan-card/actions/runs/33144737317

Issue: #337
User-Visible: no
2026-08-28 08:29:45 +03:00
claude[bot]andMatysh d30cc799f9 docs: review document for #337
Issue: #337
User-Visible: no
2026-08-28 08:29:45 +03:00
Matysh 33a0110ec2 fix: preserve warm editor camera during lazy adoption
Issue: #337
User-Visible: no
2026-08-28 08:29:45 +03:00
claude[bot]andMatysh 47c9e3f3a9 docs: review document for #337
Issue: #337
User-Visible: no
2026-08-28 08:29:45 +03:00
Matysh 52e7033496 test: restore lazy split mutation coverage
Issue: #337
User-Visible: no
2026-08-28 08:29:45 +03:00
Matysh 13181bd473 test: guard eager config canonicalization
Issue: #337
User-Visible: no
2026-08-28 08:29:45 +03:00
Matysh 8096179ece fix: preserve editor behavior across lazy runtime
Issue: #337
User-Visible: no
2026-08-28 08:29:45 +03:00
claude[bot]andMatysh c80c002bab docs: review document for #337
Issue: #337
User-Visible: no
2026-08-28 08:29:45 +03:00
Matysh 4aadf6f077 docs: refresh screenshots after dev rebase
Issue: #337
User-Visible: no
2026-08-28 08:29:44 +03:00
Matysh ec879b3b2d test: enter lazy editor in resize smoke
Issue: #337
User-Visible: no
2026-08-28 08:29:44 +03:00
Matysh e4eb8ed06f docs: accept canonical screenshots for lazy runtime
Issue: #337
User-Visible: no
2026-08-28 08:29:44 +03:00
Matysh 0964f488a2 fix: sync docs capture asset tree
Issue: #337
User-Visible: no
2026-08-28 08:29:44 +03:00
Matysh df450c9c5e fix: keep lazy editor build portable
Issue: #337
User-Visible: no
2026-08-28 08:29:44 +03:00
Matysh 59be9c8565 perf: lazy-load editor runtime
Issue: #337
User-Visible: yes
2026-08-28 08:29:44 +03:00
claude[bot]andMatysh 4a61407282 docs: review document for #337
Issue: #337
User-Visible: no
2026-08-28 08:29:44 +03:00
Matysh a211861141 docs: specify lazy editor bundle
Issue: #337
User-Visible: no
2026-08-28 08:29:44 +03:00
114 changed files with 34385 additions and 26496 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": "edcb61bd3c2c9a6056c747c366bbd7c73f1678dce2c656f631b56a4241a4b8dc",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-CdKyxHZ7.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 255910,
"lazyFiles": [
"houseplan-assets/editor-MqqOS4sP.js",
"houseplan-assets/houseplan-editor-runtime-Bbmj7A6f.js",
"houseplan-assets/houseplan-onboarding-runtime-C28J-NJP.js",
"houseplan-assets/space-deletion-BxF_6aDU.js"
],
"lazyGzipBytes": 138425,
"lazyEditorFiles": [
"houseplan-assets/editor-MqqOS4sP.js",
"houseplan-assets/houseplan-editor-runtime-Bbmj7A6f.js",
"houseplan-assets/space-deletion-BxF_6aDU.js"
],
"lazyEditorGzipBytes": 131779,
"lazyOnboardingFiles": [
"houseplan-assets/houseplan-onboarding-runtime-C28J-NJP.js",
"houseplan-assets/space-deletion-BxF_6aDU.js"
],
"lazyOnboardingGzipBytes": 7199,
"files": [
{
"path": "houseplan-assets/editor-MqqOS4sP.js",
"sha256": "140369d29021c71a88b788224fca477b97fc4fc1d5254bbad9e68f01fee403c9",
"rawBytes": 3699,
"gzipBytes": 1529,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CdKyxHZ7.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-CdKyxHZ7.js",
"sha256": "66c490a5ab13cff53463d315b0d18c8dfd26f1caf150836de5559ac3df2aa82e",
"rawBytes": 910654,
"gzipBytes": 255727,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/editor-MqqOS4sP.js",
"houseplan-assets/houseplan-editor-runtime-Bbmj7A6f.js",
"houseplan-assets/houseplan-onboarding-runtime-C28J-NJP.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-Bbmj7A6f.js",
"sha256": "0d2dd1b6cfc0c05a010e7b2345623f1063ed5e0fe0f9a95be9afb81e90c26e6b",
"rawBytes": 504952,
"gzipBytes": 129697,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CdKyxHZ7.js",
"houseplan-assets/space-deletion-BxF_6aDU.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-C28J-NJP.js",
"sha256": "38044f0836917a8f91705b61b5d48fcf4c0ca72d41b919831d2b66a0a78e4ff9",
"rawBytes": 26356,
"gzipBytes": 6646,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CdKyxHZ7.js",
"houseplan-assets/space-deletion-BxF_6aDU.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/space-deletion-BxF_6aDU.js",
"sha256": "137b0f78bc5e11c7b1e95c138c1fcf0f982f89013053a1cc648e6df52092401e",
"rawBytes": 911,
"gzipBytes": 553,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "3a1818c954f8d4a8a8cb143d7b9c39196a8a340a43c578fd6ca5b6fc01ac8065",
"rawBytes": 190,
"gzipBytes": 183,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-CdKyxHZ7.js"
],
"dynamicImports": []
}
]
}
@@ -0,0 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edcb61bd3c2c9a6056c747c366bbd7c73f1678dce2c656f631b56a4241a4b8dc";import{c as e,l as o,t,d2 as s,A as a,b as l}from"./houseplan-card-CdKyxHZ7.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__="edcb61bd3c2c9a6056c747c366bbd7c73f1678dce2c656f631b56a4241a4b8dc";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'}.`,
);
}
+5 -6
View File
@@ -1,8 +1,12 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// #337: a clean CI checkout has no ignored demo bundle. Materialize the whole
// manifest-owned tree before launching Chromium; copying only the stable entry
// leaves every content-hashed import at 404.
import '../../scripts/bundle-sync.mjs';
import { visualFingerprint } from '../../scripts/source-fingerprint.mjs';
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
@@ -11,9 +15,6 @@ import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from './screenshots.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const OUTPUT = resolve(ROOT, 'docs/images');
const BUNDLE = resolve(ROOT, 'dist/houseplan-card.js');
const DEMO_BUNDLE = resolve(ROOT, 'demo/srv/assets/houseplan-card.js');
const INTEGRATION_BUNDLE = resolve(ROOT, 'custom_components/houseplan/frontend/houseplan-card.js');
const SCRIPT = fileURLToPath(import.meta.url);
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
@@ -103,8 +104,6 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
}, scenario);
mkdirSync(OUTPUT, { recursive: true });
copyFileSync(BUNDLE, DEMO_BUNDLE);
copyFileSync(BUNDLE, INTEGRATION_BUNDLE);
const { page, browser } = await launch();
const browserErrors = [];
+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);
+1 -1
View File
@@ -72,7 +72,7 @@ const res = await page.evaluate(async () => {
out.legacyCardStillRestoresNav = ordinary._space === ids[1]
&& ordinary._fixedFloorState().kind === 'absent';
const editor = document.createElement('houseplan-card-editor');
const editor = await customElements.get('houseplan-card').getConfigElement();
editor.hass = base.hass;
editor.setConfig({ type: 'custom:houseplan-card', floor: 1, title: 'Fixed' });
document.body.appendChild(editor);
+6 -1
View File
@@ -58,7 +58,12 @@ const res = await page.evaluate(async () => {
// terser либо инлайнит строку (v1.56.0), либо оставляет переменную (v${xx}) —
// во втором случае доразрешаем её по присваиванию xx="1.56.0".
// Версия — SemVer, у пре-релиза есть суффикс (1.58.0-beta.1), он тоже часть строки.
const bundle = readFileSync(new URL('./srv/assets/houseplan-card.js', import.meta.url), 'utf8');
const assetsRoot = new URL('./srv/assets/', import.meta.url);
const assetManifest = JSON.parse(readFileSync(new URL('houseplan-assets.json', assetsRoot), 'utf8'));
const bundle = assetManifest.files
.filter((file) => file.path.endsWith('.js'))
.map((file) => readFileSync(new URL(file.path, assetsRoot), 'utf8'))
.join('\n');
const SEMVER = '\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?';
const m = bundle.match(new RegExp(`HOUSEPLAN-CARD %c v(?:(${SEMVER})|\\$\\{(\\w+)\\})`));
const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[2]}="(${SEMVER})"`))?.[1]);
+5 -2
View File
@@ -38,7 +38,9 @@ await page.evaluate(async () => {
d_temp: { s: 'f1', x: 0.72, y: 0.24 },
};
const sharedLayout = card._layout;
const originalGridLevels = card._gridLevels.bind(card);
await card._ensureEditorRuntime();
const runtime = card._editorRuntime;
const originalGridLevels = runtime._gridLevels.bind(runtime);
const scalePoint = (point, factor) => point.map((value) => value * factor);
const scaledFixture = (cellCm) => {
const factor = 5 / cellCm;
@@ -96,7 +98,8 @@ await page.evaluate(async () => {
card._decorTool = mode === 'decor' ? 'backdrop' : 'select';
// Grid density is the one allowed difference. Mask it in every editor
// raster pair and prove the precision ratio separately below.
card._gridLevels = mode === 'view' ? originalGridLevels : () => null;
runtime._gridLevels = mode === 'view' ? originalGridLevels : () => null;
card._gridLevels = () => runtime._gridLevels();
card._labs = { ...card._labs, active: projection === 'iso' ? ['iso'] : [] };
card._viewPreference = { ...card._viewPreference, f1: projection };
card._isoFallback.clear();
+5 -4
View File
@@ -83,8 +83,9 @@ const res = await page.evaluate(async () => {
// The production registry adapter is covered by unit/backend tests. Stub
// only the transactional projection here so the smoke owns no shared
// registry singleton state and remains deterministic.
const realPreview = c._markerPreviewDevice.bind(c);
c._markerPreviewDevice = (draft) => draft.devId === multi.id ? {
const runtime = c._editorRuntime;
const realPreview = runtime._markerPreviewDevice.bind(runtime);
runtime._markerPreviewDevice = (draft) => draft.devId === multi.id ? {
...multi,
entities: [multi.primary, extra],
marker: {
@@ -99,7 +100,7 @@ const res = await page.evaluate(async () => {
});
await c.updateComplete;
out.leadingDraftIsAlways = c._markerDialog.lightRole === 'always';
out.leadingPreviewHasExtra = c._markerPreviewDevice(c._markerDialog)?.entities?.includes(extra) === true;
out.leadingPreviewHasExtra = runtime._markerPreviewDevice(c._markerDialog)?.entities?.includes(extra) === true;
const leading = sr().querySelector('hp-dialog #marker-light-entity');
out.leadingSelectorForMultiple = !!leading && leading.options.length === 3;
out.savedLeadingEntitySelected = leading?.value === extra
@@ -114,7 +115,7 @@ const res = await page.evaluate(async () => {
out.leadingUpdatesPreview = false;
}
c._markerDialog = null; await c.updateComplete;
c._markerPreviewDevice = realPreview;
runtime._markerPreviewDevice = realPreview;
const single = c._devices.find((device) => device.id === 'd_lamp');
c._openMarkerDialog(single); await c.updateComplete;
+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);
+2 -1
View File
@@ -3,6 +3,7 @@ const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
await c._ensureEditorRuntime();
// Permission-delayed warm navigation must use the complete transition
// authority instead of assigning the private mode field directly.
c._setMode('view', false); await c.updateComplete;
@@ -38,7 +39,7 @@ const res = await page.evaluate(async () => {
document.body.appendChild(legacy);
await new Promise((r) => setTimeout(r, 300));
out.legacySpaceOnly = legacy._space === 'garden' && legacy._mode === 'view';
legacy._setMode('devices'); await legacy.updateComplete;
await legacy._requestMode('devices'); await legacy.updateComplete;
const migrated = JSON.parse(localStorage.getItem('houseplan_card_nav_v1'));
out.legacyRewrittenOnNextNav = migrated.space === 'garden'
&& !Object.hasOwn(migrated, 'mode');
+1 -1
View File
@@ -188,7 +188,7 @@ const out = await page.evaluate(async () => {
card._spaceDialog = null;
await card.updateComplete;
const editor = document.createElement('houseplan-card-editor');
const editor = await customElements.get('houseplan-card').getConfigElement();
editor.hass = {
...card.hass,
callWS: async (message) => message.type === 'houseplan/config/get'
+5 -4
View File
@@ -44,7 +44,7 @@ const out = await page.evaluate(async () => {
card._planSnapGeometryCache = null;
card.requestUpdate();
};
card._setMode('plan');
await card._requestMode('plan');
card._activateOpeningPlacement('door');
await update();
@@ -71,16 +71,17 @@ const out = await page.evaluate(async () => {
&& Math.abs(endpointCandidate.host.t - (endpointX - 250) / 500) < 1e-9
&& !!endpointCandidate.measure.labels[0].text;
const originalSvgPoint = card._svgPoint;
const runtime = card._editorRuntime;
const originalSvgPoint = runtime._svgPoint;
card._opDrag = {
id: saved.id, moved: false, sx: 0, sy: 0, dirty: false,
before: card._geometrySnapshot(),
};
card._svgPoint = () => [250, 500];
runtime._svgPoint = () => [250, 500];
card._opPointerMove(new PointerEvent('pointermove', {
pointerId: 186, clientX: 20, clientY: 0, bubbles: true,
}), saved);
card._svgPoint = originalSvgPoint;
runtime._svgPoint = originalSvgPoint;
card._opPointerUp(new PointerEvent('pointerup', { pointerId: 186, bubbles: true }), saved);
await update();
result.directDragStopsAtSameJambBoundary = Math.abs(saved.host.t - (endpointX - 250) / 500) < 1e-9
+1
View File
@@ -51,6 +51,7 @@ const rec = await page.evaluate(async () => {
// A detach during resume/mode motion kills the one RAF owner. The transient
// state must die with it, otherwise the same element comes back inert or
// permanently pinned to an intermediate camera.
await c._ensureEditorRuntime();
c._resumeSettling = true;
c._slide = 'left';
const from = c._currentModeVisual('view');
+1 -1
View File
@@ -43,7 +43,7 @@ await page.waitForFunction(() => {
await page.evaluate(async () => {
const card = window.__card;
card._setMode('plan');
await card._requestMode('plan');
await card.updateComplete;
const button = [...card.renderRoot.querySelectorAll('button')]
.find((entry) => entry.textContent?.trim() === 'Resize');
+23
View File
@@ -192,6 +192,29 @@ await settle();
await page.waitForTimeout(550);
out.outsideReleaseEndedDrag = await page.evaluate(() => window.__card._tabDrag === null);
out.outsideReleaseDidNotWrite = await writeCount() === writesBeforeOutside;
// Exercise the window-level release fallback independently. Real mouse input
// normally grants pointer capture, which would route pointerup back to the
// source tab and let a missing window listener pass this regression.
const fallbackIds = await ids();
const writesBeforeFallback = await writeCount();
await holdDrag(fallbackIds.at(-1), fallbackIds[0]);
out.outsideFallbackReleasedCapture = await page.evaluate(() => {
const c = window.__card;
const drag = c._tabDrag;
const source = [...c.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
.find((tab) => tab.dataset.id === drag?.id);
if (!source || !drag || !source.hasPointerCapture(drag.pointerId)) return false;
source.releasePointerCapture(drag.pointerId);
return !source.hasPointerCapture(drag.pointerId);
});
await page.mouse.move(stagePoint.x, stagePoint.y, { steps: 3 });
await page.mouse.up();
await settle();
await page.waitForTimeout(550);
out.outsideFallbackReleaseEndedDrag = await page.evaluate(() => window.__card._tabDrag === null);
out.outsideFallbackReleaseDidNotWrite = await writeCount() === writesBeforeFallback;
const activeBeforeRecovery = await page.evaluate(() => window.__card._space);
const recoveryTarget = (await ids()).find((id) => id !== activeBeforeRecovery);
const recoveryPoint = await tabPoint(recoveryTarget);
+11 -2
View File
@@ -35,6 +35,11 @@ const res = await page.evaluate(async () => {
while (c._booting && performance.now() - t0 < 2500) await sleep(30);
await sleep(350);
};
const waitFor = async (predicate, timeout = 2500) => {
const started = performance.now();
while (!predicate() && performance.now() - started < timeout) await sleep(20);
return predicate();
};
const rect = (c) => (c._view ? [c._view.x, c._view.y, c._view.w, c._view.h] : null);
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
/** покадрово: ни один кадр после пересоздания не отличается от эталона */
@@ -61,6 +66,7 @@ const res = await page.evaluate(async () => {
c.requestUpdate(); await c.updateComplete; await sleep(100);
const zoomA = c._zoom, viewA = rect(c);
out.aPanned = viewA[0] > 1 || viewA[1] > 1; // sanity: вид действительно смещён
await c._ensureEditorRuntime();
c._openSpaceDialog('edit', c._space); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'ЧЕРНОВИК-42' }; // недосохранённая правка
await c.updateComplete;
@@ -80,7 +86,8 @@ const res = await page.evaluate(async () => {
out.aNoZombieAfterClose = !c._spaceDialog;
// ================= B. редактор устройств: зум редактора + карточка =======
c._setMode('devices'); await c.updateComplete; await sleep(120);
await c._requestMode('devices'); await c.updateComplete;
await waitFor(() => !c._modeTransitionBusy);
c._applyView(3.4, 430, 380); c.requestUpdate(); await c.updateComplete; await sleep(100);
const zoomB = c._zoom, viewB = rect(c);
const dev = c._devices.find((d) => d.space === c._space);
@@ -91,6 +98,8 @@ const res = await page.evaluate(async () => {
c.remove(); await sleep(20);
c = mk();
await waitFor(() => c._mode === 'devices' && !!c._markerDialog);
await c.updateComplete;
out.bModeRestored = c._mode === 'devices';
out.bViewBitExact = await watchView(c, zoomB, viewB);
out.bDialogSurvived = !!c._markerDialog;
@@ -116,7 +125,7 @@ const res = await page.evaluate(async () => {
out.dAlignNotRevived = !c._alignDialog;
// ================= E. реальный уход с маршрута завершает редактор =======
c._setMode('devices'); await c.updateComplete; await sleep(60);
await c._requestMode('devices'); await c.updateComplete; await sleep(60);
const returnUrl = `${location.pathname}${location.search}${location.hash}`;
const returnSpace = c._space;
const routeDev = c._devices.find((d) => d.space === returnSpace);
+4 -1
View File
@@ -48,13 +48,14 @@ const res = await page.evaluate(async () => {
A._applyView(2.15, 300, 420);
A.requestUpdate(); await A.updateComplete; await sleep(60);
const zoomA = A._zoom;
await A._ensureEditorRuntime();
A._openSpaceDialog('edit', A._space); await A.updateComplete;
A._spaceDialog = { ...A._spaceDialog, title: 'DRAFT-FROM-A' };
await A.updateComplete; await sleep(30);
const B = mk(); // вторая карточка, тот же конфиг
await settle(B);
B._setMode('devices'); await B.updateComplete; await settle(B);
await B._requestMode('devices'); await B.updateComplete; await settle(B);
B._applyView(3.35, 200, 200);
B.requestUpdate(); await B.updateComplete; await sleep(60);
const zoomB = B._zoom;
@@ -77,6 +78,7 @@ const res = await page.evaluate(async () => {
HP._warmBootReset(400);
let c = mk();
await settle(c);
await c._ensureEditorRuntime();
c._openSpaceDialog('edit', c._space); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'DRAFT-RAPID' };
await c.updateComplete; await sleep(30);
@@ -95,6 +97,7 @@ const res = await page.evaluate(async () => {
HP._warmBootReset(400);
c = mk();
await settle(c);
await c._ensureEditorRuntime();
c._openSpaceDialog('edit', c._space); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'DRAFT-TTL', planFile: 'x'.repeat(4096) };
await c.updateComplete; await sleep(30);
+98
View File
@@ -0,0 +1,98 @@
{
"schema": 1,
"fingerprint": "edcb61bd3c2c9a6056c747c366bbd7c73f1678dce2c656f631b56a4241a4b8dc",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-CdKyxHZ7.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 255910,
"lazyFiles": [
"houseplan-assets/editor-MqqOS4sP.js",
"houseplan-assets/houseplan-editor-runtime-Bbmj7A6f.js",
"houseplan-assets/houseplan-onboarding-runtime-C28J-NJP.js",
"houseplan-assets/space-deletion-BxF_6aDU.js"
],
"lazyGzipBytes": 138425,
"lazyEditorFiles": [
"houseplan-assets/editor-MqqOS4sP.js",
"houseplan-assets/houseplan-editor-runtime-Bbmj7A6f.js",
"houseplan-assets/space-deletion-BxF_6aDU.js"
],
"lazyEditorGzipBytes": 131779,
"lazyOnboardingFiles": [
"houseplan-assets/houseplan-onboarding-runtime-C28J-NJP.js",
"houseplan-assets/space-deletion-BxF_6aDU.js"
],
"lazyOnboardingGzipBytes": 7199,
"files": [
{
"path": "houseplan-assets/editor-MqqOS4sP.js",
"sha256": "140369d29021c71a88b788224fca477b97fc4fc1d5254bbad9e68f01fee403c9",
"rawBytes": 3699,
"gzipBytes": 1529,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CdKyxHZ7.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-CdKyxHZ7.js",
"sha256": "66c490a5ab13cff53463d315b0d18c8dfd26f1caf150836de5559ac3df2aa82e",
"rawBytes": 910654,
"gzipBytes": 255727,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/editor-MqqOS4sP.js",
"houseplan-assets/houseplan-editor-runtime-Bbmj7A6f.js",
"houseplan-assets/houseplan-onboarding-runtime-C28J-NJP.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-Bbmj7A6f.js",
"sha256": "0d2dd1b6cfc0c05a010e7b2345623f1063ed5e0fe0f9a95be9afb81e90c26e6b",
"rawBytes": 504952,
"gzipBytes": 129697,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CdKyxHZ7.js",
"houseplan-assets/space-deletion-BxF_6aDU.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-C28J-NJP.js",
"sha256": "38044f0836917a8f91705b61b5d48fcf4c0ca72d41b919831d2b66a0a78e4ff9",
"rawBytes": 26356,
"gzipBytes": 6646,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-CdKyxHZ7.js",
"houseplan-assets/space-deletion-BxF_6aDU.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/space-deletion-BxF_6aDU.js",
"sha256": "137b0f78bc5e11c7b1e95c138c1fcf0f982f89013053a1cc648e6df52092401e",
"rawBytes": 911,
"gzipBytes": 553,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "3a1818c954f8d4a8a8cb143d7b9c39196a8a340a43c578fd6ca5b6fc01ac8065",
"rawBytes": 190,
"gzipBytes": 183,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-CdKyxHZ7.js"
],
"dynamicImports": []
}
]
}
+14
View File
@@ -0,0 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edcb61bd3c2c9a6056c747c366bbd7c73f1678dce2c656f631b56a4241a4b8dc";import{c as e,l as o,t,d2 as s,A as a,b as l}from"./houseplan-card-CdKyxHZ7.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__="edcb61bd3c2c9a6056c747c366bbd7c73f1678dce2c656f631b56a4241a4b8dc";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` не поддерживается.
В обычном просмотре код редакторов не загружается. Поэтому первое открытие
«Плана», «Устройств» или «Подложки» может занять короткое время. Если внутренний
модуль не загрузился и после одной повторной попытки, план остаётся в просмотре
и предлагает обновить страницу; наполовину открытого редактора не остаётся.
### Права пользователей
| Настройка интеграции | Просмотр | Управление устройствами | Редактирование планов и загрузка файлов |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 143 KiB

+22 -22
View File
@@ -2,8 +2,8 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"sourceFingerprint": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
"captureScriptSha256": "23e17620d030b52ea1da5187d4f638f029478ad4f1c56bb64b07dd635397972a",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
"view-desktop": {
@@ -14,8 +14,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
"imageSha256": "0ca982495a7749907c48ebbddfd089c54385a4347f79f64e94af2c5a8d5c03eb"
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
"imageSha256": "9190bd86a07b0cb0502019d76d3286c0542181e1b4a4f862077d9cefa120613c"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -25,8 +25,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
"imageSha256": "1c8e28ff34736b936ba7c9ec047d1da27aaf6fbb01411155f2d5c05282c02c41"
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
"imageSha256": "cfa2f77eb68df69ae9fc9b51ffe2f14df01f7b74444fd2b038cadc1e9dfee22b"
},
"space-create": {
"file": "03-space-create.png",
@@ -36,8 +36,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
"imageSha256": "a55b463055a565892ca139014e314a8f63fb5301c87fff2e48d7e69210fe9b6c"
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
"imageSha256": "a86d4211af24923d5048e9094d0d35f9b28129394d00cd72582a8f8c2dd7ff75"
},
"room-contour-close": {
"file": "04-room-contour-close.png",
@@ -47,8 +47,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
"imageSha256": "dfad9a27371cfa397a3fd8dce7005f42d24649743f5322105ee894067d3a9f16"
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
"imageSha256": "c7effd4ccd19bcaff9138458583368aaab488dcb6fc7ea5ab766383cb5377ac4"
},
"plan-context-tray": {
"file": "05-plan-context-tray.png",
@@ -58,8 +58,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
"imageSha256": "b75f348c632296da5c2fc142d53df613f757e1516acac315be50f5b4557729cc"
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
"imageSha256": "af1194f073f00b03af02b949a072cffb21bfd29995aa65a067eaaaec74ef8490"
},
"device-editor": {
"file": "06-device-editor.png",
@@ -69,8 +69,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
"imageSha256": "6b9cd281450e7782cff292f11efc58d227bedd0df86afc3be987feb2edec9dc3"
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
"imageSha256": "36ed21b66920ff2de9e3346cf6f27c67e64a9f81692599b9c70d8373f5eb7241"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -80,8 +80,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
"imageSha256": "7151c96dc048381f7a7070ecb85a82a890b2f4c703628f14fa472eb8dacbf3dc"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -91,8 +91,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
"imageSha256": "9f08f3f3711b100d68fb1a05af269e43e47d8f8d12c0cbe4f9ab557138722989"
},
"room-card": {
"file": "08-room-card.png",
@@ -102,8 +102,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
"imageSha256": "66d8b0a509909822ff6b891a483bad9d2c72b20f08e287355883aa5205c6cee0"
},
"device-info": {
"file": "09-device-info.png",
@@ -113,8 +113,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd5a7383c969b1a8e03fc334c745d90a1d0f2c5c8df5434b18a57474712e83c5",
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
"sourceSha256": "5f56dab65ab3ec45e84eb724fd5b13a4b4d3d94413f86c529b7b66702282e2af",
"imageSha256": "f4d3bda00a2fce54f408b1f20e03b5c590ba4b168fd214a28aafb1692b1f3124"
}
}
}
+344
View File
@@ -0,0 +1,344 @@
# CODE-REVIEW-337-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/337
- Материал: `git diff origin/dev...HEAD` на `6d338b78a0794491639edee9171662cdd260c42b`
(ветка `issue/337-lazy-editor-chunk`, HEAD detached), 99 файлов, +33197/-26570.
- Этап: код-ревью, заход r1, блокирующих циклов израсходовано 0/4 до этого разбора.
- **Вердикт: красный.**
## Скоуп
ТЗ (`docs/specs/337-lazy-editor-chunk.md`, принято зелёным на SPEC-REVIEW-337-r1)
требует: (1) View не должен загружать код трёх редакторов до намерения открыть
редактор; (2) редакторы, kiosk-контролы, onboarding и Lovelace GUI config editor
должны продолжать работать **без изменения наблюдаемого поведения**; (3) при
ошибке загрузки редактора View остаётся рабочим и показывает новое сообщение;
(4) multi-asset build/раздача/CI/HACS-контракт. Диапазон правки: `src/houseplan-card.ts`
похудел на ~11.6 тыс. строк, весь этот код перенесён в новый
`src/houseplan-editor-runtime.ts` (13 016 строк) + `src/houseplan-onboarding-runtime.ts`
(843 строки) + `src/editor-runtime-loader.ts` (72 строки); плюс полностью новый
multi-asset build/раздача (`scripts/bundle-*.mjs`, `frontend_assets.py`,
`frontend_asset_manifest.py`), CSS-минификатор и обновлённая документация/changelog.
Это перенос практически всего некогда-монолитного класса через новую границу
`host ↔ typed port ↔ lazy runtime` — диапазон дельты сопоставим с исходной
задачей целиком, разбор ведётся полностью.
## Как проверялось
Дешёвые гейты подтверждены зелёным Validate на этом же SHA
(https://github.com/Matysh/houseplan-card/actions/runs/33137282955) **частично** —
см. ниже отдельное расследование, почему доверять этому прогону в части
smoke/golden нельзя. Я перепрогнал самостоятельно:
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` (через `npm run build`) | pass |
| unit | `npm test` | 1438 тестов, 1437 pass, 1 skip, 0 fail |
| build + budget | `npm run build && npm run bundle:sync && npm run bundle:budget` | initial View 255 385 B ≤ 256 000 B (запас 615 B); lazy editor 131 765 B |
| docs fingerprint | `node scripts/check-docs.mjs` | pass (7 файлов, 10 внешних ссылок) |
| **browser smokes — ВСЕ 195** | `for f in demo/smoke_*.mjs; do node "$f"; done` (после `bundle:sync`) | **178 pass / 17 FAIL** |
| golden | `npm run golden:verify` | **127 passed / 4 different** |
Почему прогнан весь набор smoke, а не выборка: diff перемещает весь бывший
монолит в новый файл, поэтому `node scripts/smoke-select.mjs --base origin/dev --head HEAD`
называет **175 из 195** смоков «прямым совпадением» (символы на изменённых
строках) — порог «широкого» символа превышен на два порядка. Это ровно случай
«дельта не локальна» из инструкции разбора: выборка не имела бы смысла,
проверен весь набор.
**Почему нельзя было принять зелёный Validate за доказательство smoke/golden.**
Разобрал историю прогонов ветки (`gh run list --branch issue/337-lazy-editor-chunk`):
1. Коммит `876e709c` («test: enter lazy editor in resize smoke» — тот же коммит,
что сейчас на одну позицию перед HEAD) получил **свой собственный** прогон
Validate (`33136940969`) **до** финального docs-коммита. В нём **фронтенд,
HACS, hassfest, backend, perf выполнились и прошли**, но **все три шарда
smoke упали**, **golden упал** (4 разных кадра), и **preflight (docs/провенанс)
тоже упал**.
2. Следующий и последний коммит `6d338b78` («docs: refresh screenshots after dev
rebase») меняет только `docs/images/06-device-editor.png` и
`docs/images/screenshots.json`. Job `changes` в `validate.yml` при push на
ветку задачи считает diff **инкрементально** — `git diff` между предыдущим и
новым tip пуша, а не `merge-base(dev)..HEAD`. Поэтому для этого пуша
`frontend=false`, и jobs `frontend`/`smoke`/`golden`/`hacs`/`hassfest`/`backend`
**пропущены** (`skipped`, не «reused по маркеру» — маркер smoke/golden явно
«Cache not found» в логе job «Переиспользование»). Итоговый зелёный статус
прогона `33137282955` относится только к docs/provenance/process-gate этого
инкремента и никогда не перепроверял smoke/golden после
`876e709c`.
3. Следствие: **ни один зелёный прогон CI на этой ветке не подтверждает, что
smoke/golden проходят на финальном дереве.** Последний прогон, где они
реально исполнялись, был красным. Хендофф-комментарий автора («Целевые
browser smokes... green», «Точный CI:...— green: (переиспользованы зелёные
...markers)») интерпретирует переиспользование/пропуск как подтверждение,
но по факту это не так — маркер smoke/golden для точного дерева `876e709c`
не «success», а «Cache not found», и последующий пуш этот маркер не создал,
потому что fingerprint jobs совпадает только внутри `reuse`, а `changes`
для пути smoke/golden использует другой, инкрементальный механизм.
4. Мой независимый прогон на `HEAD=6d338b78` **воспроизводит содержательно те
же классы падений**, что были красными в `876e709c` (kiosk, warm-remount,
device-inbox, resize-preflight, golden device-editor/dialogs) — то есть
ничего из перечисленного не было исправлено между `876e709c` и `6d338b78`,
несмотря на заявление «Готово к автоматическому S7 code review».
## Находки
### High (блокируют)
**H1. Warm-remount коммитит editor mode в обход loader-гейта — падения на
уже существующих smoke.**
`src/houseplan-card.ts:3164-3206`, `_warmAdoptViewport()`, строка 3180:
```ts
this._adoptMode(vp.mode !== 'view' && this._canEdit && !config.kiosk ? vp.mode : 'view');
```
Это прямой вызов `_adoptMode()`, минуя `_requestMode()` — единственное место,
которое перед сменой режима вызывает `_ensureEditorRuntime()`
(`src/houseplan-card.ts:890-898`). ТЗ §6.2.1 требует: «Mode, editor chrome и
editor camera не коммитятся до успешной установки runtime». При warm-remount
(HA пересоздаёт элемент карточки на том же месте — `docs/WARM-REMOUNT.md`)
новый экземпляр получает собственный `_editorRuntimeLoader`/`_editorRuntime`
(поля инстанса, не модуля), но `_warmAdoptViewport` восстанавливает
предыдущий `mode` немедленно и синхронно, до того как что-либо вызвало
`_ensureEditorRuntime()` на новом инстансе.
**Воспроизведение:** три существующих (не изменённых этой задачей) smoke
детерминированно падают на этом:
- `demo/smoke_preloader_lifecycle.mjs` — `page.evaluate: Error: Houseplan
editor runtime is not loaded`, стек `_renderMarkupDefs → render → update`;
- `demo/smoke_warm_dialogs.mjs` — 8 идентичных необработанных исключений с тем
же стеком в процессе штатного сценария «тёплого» возврата к диалогу;
- `demo/smoke_warm_owners.mjs` — `aOwnersDraftRestored`/`bDraftOpenBefore`/
`bDraftSurvivedDoubleRemount` — все три `expected true, got false` (черновик
не восстанавливается после повторного remount).
Тест умеет падать: ни один из трёх не является новым или переписанным этой
задачей (не входят в diff), то есть до правки они проходили, а сейчас —
красные на HEAD.
**H2. Kiosk-контролы, явно названные в принятом ТЗ eager (§6.3: «kiosk
controls остаются eager, если они нужны View сами по себе»), зависят от
ленивого runtime и не работают в первом View.**
`src/houseplan-card.ts:10599-10600`:
```ts
private _saveKioskScale(patch: Partial<{ icon: number; font: number }>): void {
return this._editorRuntimeOrThrow()._saveKioskScale(patch);
}
```
Диалог, из которого этот метод вызывается, открывается напрямую из View по
3-секундному long-press на пустой сцене в kiosk-режиме —
`src/houseplan-card.ts:5980-5997` (`_stagePointerDown`, `this._kioskDialog =
true`) — без единого вызова `_ensureEditorRuntime()` на этом пути. Рендер
диалога отдельно защищён (`this._editorRuntime ? this._renderKioskDialog() :
nothing`, строка 11289), поэтому на «холодном» View (никто ещё не открывал
редактор) long-press на планшете/панели **молча не открывает попап** размера
значков/текста — ключевая функция kiosk/планшетной персоны, для которой View
и есть продукт (`docs/SCOPE.md`), просто перестаёт быть доступна до первого
входа администратора в редактор где-то ещё.
**Воспроизведение:** `demo/smoke_kiosk.mjs` (существующий, не тронут этой
задачей) — `page.evaluate: Error: Houseplan editor runtime is not loaded` при
вызове `_saveKioskScale`, воспроизводится детерминированно.
**H3. Безопасность resize «fail-closed» сломана: принудительный отказ
preflight-проверки всё равно приводит к реальному, отличному от исходного,
коммиту геометрии.**
`demo/smoke_room_resize.mjs` (существующий) подменяет
`card._checkSpacePhysicalGeometry = () => ({ ok: false, status: 'failed' })`
и тянет край комнаты во время активного (мокнутого) отказа. Ожидание по
контракту #199/#277: ноль записей, комната остаётся на исходной геометрии.
Факт:
```
safe_resize.preflight_visible_reason: expected true, got false
safe_resize.preflight_reason_once: expected 1, got 0
safe_resize.preflight_no_commit: expected [[0.1,0.1],[0.4,...]], got [[0.1,0.1],[0.5333...,...]]
safe_resize.preflight_zero_write: expected 0, got 1
safe_resize.commit_preflight_no_commit: expected 0.4, got 0.5
safe_resize.commit_preflight_zero_write: expected 0, got 1
```
Комната реально переместилась на позицию под курсором и в истории геометрии
появилась запись — то есть подмена `card._checkSpacePhysicalGeometry` (метод
на HOST, `src/houseplan-card.ts:9955`, форвардящий в
`_editorRuntimeOrThrow()._checkSpacePhysicalGeometry`) не перехватывает
реальный вызов внутри перенесённого resize-контроллера. Тот же класс
поломки — на независимом safety-контуре `_checkOptimizeGeometry`
(`demo/smoke_optimize_geometry_preflight.mjs`, комментарий в файле явно
ссылается на #199 «production bundle must fail closed before the Optimize WS
write»): подмена того же вида, `forceRed`-обёртка, даёт **9 упавших проверок
из 9** — preflight в проде для «Оптимизировать план» тоже не подтверждён
рабочим.
Это не вопрос тестируемости в узком смысле — на `preflight_no_commit`/
`commit_preflight_no_commit` наблюдается **реальный commit геометрии,
отличной от исходной**, когда по контракту не должно быть commit вообще.
Учитывая, что это ровно тот класс дефектов, ради которого заведены #199, #253,
#258, #259, #277, #291 (записи толщины/геометрии, zero-write гарантии) —
серьёзность максимальная.
**H4. Стрелочная навигация по вкладкам инвентаря устройств падает с
необработанным исключением, сама функция не работает.**
`demo/smoke_device_inbox.mjs` (существующий) — `ArrowRight` в открытом
инвентаре устройств:
```
EXC TypeError: Cannot read properties of undefined (reading '_deviceInbox')
at fm._deviceInboxTabKey (...houseplan-editor-runtime-pmkXpWgM.js:929:37170)
```
`src/houseplan-editor-runtime.ts:7398-7407`, тело метода читает
`this.host._deviceInbox` — на живом дереве `this` внутри обработчика,
привязанного как `@keydown=${this._deviceInboxTabKey}`
(`src/houseplan-editor-runtime.ts:11374`), оказывается не инстансом runtime
(`this.host` undefined). Результат: `arrowChangesTab: expected true, got
false`, плюс необработанное исключение внутри карточки — функция полностью
неработоспособна, а не просто «отличается от ожидания».
**H5. Визуальная порча вторичного тулбара Device editor + 3 неподтверждённых
golden-дельты по диалогам устройств.**
`npm run golden:verify` на HEAD: **4 из 131 кадра отличаются** (не 0, как
требует AC3 «без golden delta», и не согласовано отдельным решением владельца,
как требует §12 «принятие эталонов только через `golden:accept --reviewed`»):
- `geometry-devices-editor-dark` — подписи кнопок вторичного тулбара Device
editor визуально **накладываются друг на друга** (скриншот diff:
`artifacts/golden/diff/geometry-devices-editor-dark.png`) — реальная порча
layout, а не смещение на пиксель;
- `device-dialog-mobile-ru`, `toggle-entity-dialog-mobile-ru`,
`device-ripple-color-popover-mobile-ru` — одинаковый паттерн: узкая полоса
различий по правому/нижнему краю диалога на мобильной ширине (изменился
размер/переполнение диалога).
Все четыре сценария относятся к Device editor/device-диалогам — подсистеме,
чей код перенесён этой задачей в `houseplan-editor-runtime.ts`. Ни один из
четырёх не упомянут в хендоффе автора и не принят через
`golden:accept -- --reviewed`.
### Medium (в скоупе, чинится в этой же задаче)
**M1. Два существующих smoke ожидают, что `houseplan-card-editor` уже
зарегистрирован на странице, но ТЗ сделало регистрацию асинхронной (AC11), а
фикстуры не обновлены.**
`demo/smoke_fixed_floor.mjs:74` и `demo/smoke_orphan_space_references.mjs:6`
делают `document.createElement('houseplan-card-editor')` напрямую, без
предварительного `await HouseplanCard.getConfigElement()` (или `import
'./editor'`). До задачи `./editor` импортировался статически, поэтому элемент
был зарегистрирован при загрузке бандла; после задачи — нет. Обе фикстуры
падают с `TypeError: editor.setConfig is not a function`. План автотестов ТЗ
(§14) требует, чтобы существующий набор проходил; это два конкретных, легко
чинимых места (добавить `await customElements.whenDefined(...)` после явного
триггера лениво загрузки, либо dynamic import перед `createElement`).
**M2. Смок-инструментовка чтения версии сборки не адаптирована к
multi-asset дереву.**
`demo/smoke_general_settings.mjs:60-65` ищет строку консольного баннера
`HOUSEPLAN-CARD %c v...` в `demo/srv/assets/houseplan-card.js` (entry-файл).
После разделения баннер переехал в общий чанк
`houseplan-assets/houseplan-card-<hash>.js` — `grep` подтверждает: строки в
entry нет, есть только в чанке. `BUNDLE_VERSION` получается `undefined`,
`bundleVersionFound` красный, второе сравнение сравнивает с заведомо неверной
строкой `"Houseplan Card vundefined"`. Само отображение в диалоге "About"
похоже корректно (`v1.68.1`, видно из вывода теста) — это регресс
инструментовки теста под новое дерево ассетов, а не видимого поведения, но
именно такую регрессию должен был поймать и починить сам этот таск (AC8/§14).
**M3. Ещё 9 существующих smoke красные на HEAD, не разобраны до корня в этом
документе — логи сохранены, требуют триажа автором.**
`smoke_edit_walk`, `smoke_ha_controls`, `smoke_help_affordance`,
`smoke_nav_persist`, `smoke_opening_entity_search`, `smoke_partition_openings`,
`smoke_grid_scale_invariance` — детерминированно красные при отдельном
запуске на HEAD (полные логи: `for f in ...; do node "$f"; done`, доступны по
запросу/в артефакте ревью-агента). Показательные симптомы: необработанные
исключения внутри карточки (`opening_entity_search`, ещё один случай кроме
H4), сравнение пикселей не сходится при скрытой сетке
(`grid_scale_invariance`), функциональные регрессии в конкретных диалогах
(`ha_controls`: выбор «ведущей» сущности при нескольких источниках;
`partition_openings`: прямой drag больше не останавливается на границе
одинакового джамба; `help_affordance`: второй Escape перестал закрывать
диалог; `nav_persist`: устаревший формат навигации не переписывается при
следующем переходе). Не считаю доказанным, что все девять сводятся к H1–H4 —
не проверял по коду каждый до конца; это отдельная работа автора, но
достаточно списка «что именно красное» и «как воспроизвести» (`node
demo/smoke_<name>.mjs` после `npm run bundle:sync`), чтобы задача не
возвращалась в S7 до того, как каждый пункт либо зафиксирован тестом,
либо явно объяснён как заведомо не связанный с #337 регресс из ребейза (в
последнем случае — с доказательством: тот же smoke красный и на чистом
`origin/dev` до задачи).
Итого по census: **17 из 195** существующих browser-smoke красные на
`git rev-parse HEAD` = `6d338b78`; **4 из 131** golden-кадров отличаются.
AC3 («View parity... без golden delta») и AC4 («editor parity... существующие
smokes без изменения ожидаемого DOM/данных») формально заявлены выполненными
в хендоффе автора, но не выполнены фактически — это не единичный дефект, а
систематический разрыв между заявленным прогоном и действительным
состоянием дерева (см. раздел «Как проверялось» о причине несовпадения).
### Low
Нет отдельных Low-находок сверх перечисленного — все наблюдения либо попали в
Medium (в скоуп, чинится здесь), либо в High.
## Что проверено и корректно
- **Бюджет (AC1).** `npm run bundle:budget` на HEAD: initial View 255 385 B ≤
256 000 B, lazy editor 131 765 B — арифметически верно, посчитано моим
собственным прогоном, не переписано со слов автора. Запас 615 B крайне
узкий (сам автор называет это риском) — любой последующий eager-код без
учёта бюджета сломает AC1 на первой же правке; отдельной находкой не делаю,
так как это явный, зафиксированный риск, а не скрытый факт.
- **Импорт-граница (AC2, unit-уровень).** `npm test` — 1438/1438 применимых
(1437 pass, 1 skip, 0 fail): в этот прогон входят unit/manifest-тесты
import-графа и bundle-assets (`test/bundle-assets.test.mjs`,
`test/editor-runtime-loader.test.mjs`, `test/bundle-freshness.test.mjs`) —
на уровне статического графа импортов ленивая граница подтверждена. Именно
поэтому дефекты H1–H5 не unit-уровня — они интеграционные/browser-уровня, то
есть ровно там, где единственная защита это smoke/golden, а не тип-чек.
- **Backend asset route (AC7).** `tests_backend/test_frontend_assets.py`
существует и по заявлению автора зелёный (3 passed); полный HA-harness в
этой песочнице недоступен (нет `homeassistant`), поэтому **не перепрогонял**
— доверяю авторскому запуску только для этого файла, так как он pure-Python
и не завязан на HA.
- **Docs/трейлеры.** `node scripts/check-docs.mjs` — pass. Коммит
`c1aaddc7` (`User-Visible: yes`) правит оба changelog в одном коммите —
требование соблюдено. Остальные коммиты `User-Visible: no`, все несут
`Issue: #337`.
- **Демо-стенд синхронен.** `npm run bundle:sync` — dist/integration/demo
деревья по 6 файлов, побайтово совпадают (проверено самим скриптом при
моём прогоне).
## Чего не проверял
- **Полный HA-harness backend** (`python -m pytest tests_backend -q` с
установленным `homeassistant`) — недоступен в этой песочнице; доверяю
авторскому заявлению только для чистого pure-Python подмножества,
относящегося к asset route.
- **Не довёл до корня 9 из 17 падающих smoke** (список в M3) — это
зафиксировано как Medium с логами и командой воспроизведения, а не
замолчано.
- **Ручное тестирование в реальном Home Assistant** — не выполнялось (не
требуется циклом ревью; недоступно из этой среды).
- **Performance-профили** — не запускал: ни один AC #337 явно не называет
perf-бюджет кадра, только gzip-бюджет (AC1, проверен).
- **`node scripts/model-invariants.mjs`** — не запускал: diff не трогает
геометрические записи/`layout`/`marker.space`/`open_spans` в смысле их
формата хранения (перенос кода, не модели); H3 — поведенческий регресс в
runtime-проверке, а не в структуре записи, поэтому инвариант модели здесь не
тот инструмент, которым он обнаруживается — обнаружен smoke'ом.
- **Полное построчное соответствие каждого пункта §6–§13 спецификации
каждой строке кода** — не сделано построчно; проверка велась от
найденных красных smoke/golden к коду, а не от текста ТЗ построчно вниз, так
как объём диффа (33k+/26k-) делает построчную сверку нецелесообразной при
уже найденных блокирующих находках. Даже без учёта H1–H5 количество
красных существующих smoke (17) само по себе уже является достаточным
основанием для красного вердикта независимо от их индивидуального
разбора до конца.
## Итог
Пять High-находок делают вердикт красным: код не проходит собственный план
автотестов задачи (AC3, AC4), нарушает явное продуктовое решение принятого ТЗ
(§6.3 kiosk eager), ломает независимо проверяемую safety-гарантию
(fail-closed resize/optimize preflight, H3) и содержит минимум одну прямую
runtime-ошибку в рабочей функции (H4). Отдельная системная проблема процесса,
не входящая в оценку кода, но существенная для дальнейшей работы над этой
веткой: зелёный Validate на финальном SHA возник из-за того, что
последний коммит — docs-only и не ретриггерит smoke/golden при инкрементальном
diff'е пуша, а не потому что эти гейты были перепройдены после последнего
красного прогона (`876e709c`). Пока это не учитывается, «зелёный CI» на
последнем коммите задачи с docs-хвостом не является доказательством того, что
smoke/golden проходят.
+248
View File
@@ -0,0 +1,248 @@
# CODE-REVIEW-337-r2
- Issue: https://github.com/Matysh/houseplan-card/issues/337
- Материал: `git diff 6d338b78..HEAD` (r1 SHA `6d338b78a0794491639edee9171662cdd260c42b` →
HEAD `2beafc06398c16d1cad80fddbe77e6a40f111b9a`), ветка `issue/337-lazy-editor-chunk`.
4 коммита, 32 файла (без учёта сгенерированных `dist/*`/`custom_components/houseplan/frontend/*`,
которые зеркалят исходники): `src/houseplan-card.ts` (+79/-13), `src/houseplan-editor-runtime.ts`
(+55/-55, преимущественно механическая правка), `scripts/mutation-gate.mjs`, два unit-теста,
11 browser-smoke фикстур, документ CODE-REVIEW-337-r1, обновлённый `docs/images/screenshots.json`.
- Этап: код-ревью, заход r2, блокирующих циклов израсходовано 1/4 до этого разбора.
- **Вердикт: красный.**
## Скоуп разбора
Предыдущий вердикт (CODE-REVIEW-337-r1, SHA `6d338b78`) — красный, 5 High + 3 Medium.
Дельта этого раунда — точечные правки по каждой находке, а не переработка задачи:
два изменённых src-файла, обновлённые smoke-фикстуры под новый async-контракт,
одна правка mutation-gate (стал ссылаться на актуальный файл стилей после переноса
CSS), плюс сам документ r1 и пересборка bandle-артефактов. Это не ребейз на ушедший
`dev` (branch и так уже стоит на `508945c0` = `origin/dev`), не смена контракта
поведения и не новая подсистема — разбор веду по дельте, а не с нуля, но
дополнительно перепрогоняю **весь** browser-smoke census и `golden:verify`, а не
только смоки, названные автором: H4-фикс (`_deviceInboxTabKey`/`@click=${this._x}`
→ `@click=${() => this._x()}`) механически переписал практически КАЖДЫЙ
обработчик события в `houseplan-editor-runtime.ts` (не только вкладки инвентаря
устройств) — это затрагивает общий рендер-путь всех редакторских диалогов, а не
только те четыре сценария, что назвал автор.
## Как проверялось
Дешёвые гейты (перепрогнаны самостоятельно, а не приняты со слов автора):
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` | pass (5.6s) |
| unit | `npm test` | 1439 total, 1438 pass, 1 skip, 0 fail — совпадает с заявлением автора |
| build+sync+budget | `npm run build && npm run bundle:sync && npm run bundle:budget` | initial View 255 778 B ≤ 256 000 B (запас 222 B — **уже, чем 615 B на r1**); lazy editor 131 785 B; `bundle:sync` не оставил расхождений в дереве (только биты прав доступа 755→644 на двух артефактах из-за локального `tsc`, отменено `git checkout --`, содержимого не касалось) |
| docs fingerprint | `node scripts/check-docs.mjs` (diff трогает `src/**`) | pass (7 файлов, 10 внешних ссылок) |
| mutation, changed-range | `node scripts/mutation-gate.mjs --check --changed=6d338b78..HEAD` | 58/58 применимых guard'ов поймали подмену |
Тяжёлые гейты — по дельте и по решению ревьюера, не выборочно на веру:
- **Полный browser-smoke census** (195 файлов, `for f in demo/smoke_*.mjs; do
node "$f"; done` после `bundle:sync`) — **195/195 pass**, включая
`smoke_warm_dialogs` (единственный прогон в составе census). Это ожидаемо и
НЕ противоречит находке H6 ниже: точечный прогон того же файла 7 раз подряд
дал 5 отказов из 7 (≈71% красных) — при однократном прогоне в общем census
благополучный исход (≈29% по моей выборке) не редкость. Единичный зелёный
census — статистический шум для гонки такой частоты, а не доказательство
исправности; полагаться на однократный прогон здесь означало бы повторить
саму ошибку хендоффа автора.
- **`npm run golden:verify`** — 127 passed / **4 different** — те же четыре
кадра, что в r1: `geometry-devices-editor-dark`, `device-dialog-mobile-ru`,
`toggle-entity-dialog-mobile-ru`, `device-ripple-color-popover-mobile-ru`.
- **Независимая проверка авторского заявления по H5** (не принято на веру: автор
утверждает «pre-existing stale baseline, не регресс #337» — это фактическое
утверждение о состоянии `dev`, которое легко проверить и легко сфальсифицировать).
Поднял `git worktree add /tmp/dev-check origin/dev --detach` (чистый `508945c0`,
ancestor этой ветки), `npm ci && npm run build && npm run bundle:sync`, затем
`node demo/golden/run.mjs --mode=capture --scenario=<id>` (verify требует полную
матрицу, capture позволяет точечный diff) для всех четырёх кадров:
**все четыре «different» воспроизводятся на чистом `dev` без единого изменения
из #337.** Открыл сами diff-PNG: `geometry-devices-editor-dark` — актуальный
рендер показывает два раздельных, не наложенных друг на друга элемента
тулбара («Devices», «Icon rules»); диффовое наложение возникает потому что
**эталон** старше и содержит три кнопки («Add», «Hidden and disabled»,
«Icon rules») — устаревший baseline `dev`, а не порча layout веткой. Три
mobile-ru кадра показывают одинаковую узкую полосу по правому/нижнему краю
диалога и на чистом `dev` — тоже baseline drift, не регресс. **H5 закрыт**:
находка воспроизводится на `dev` и не относится к #337; это отдельный,
предсуществующий дефект золотых эталонов — завёл отдельный issue
([#346](https://github.com/Matysh/houseplan-card/issues/346)).
- **Целевые smoke, привязанные к находкам r1**, каждый прогнан минимум по
разу, некоторые — многократно при подозрении на нестабильность (ниже):
`smoke_preloader_lifecycle`, `smoke_warm_owners`, `smoke_kiosk`,
`smoke_room_resize`, `smoke_optimize_geometry_preflight`,
`smoke_device_inbox`, `smoke_fixed_floor`, `smoke_orphan_space_references`,
`smoke_general_settings`, `smoke_ha_controls`, `smoke_nav_persist`,
`smoke_partition_openings`, `smoke_grid_scale_invariance`,
`smoke_edit_walk`, `smoke_help_affordance`, `smoke_opening_entity_search`.
- **`smoke_warm_dialogs` — прогнан 7 раз подряд после `bundle:sync`, а не один
раз.** Причина повторов: первый запуск дал `bViewBitExact` FAIL на кадре,
близком, но не идентичном сохранённой камере — при плавающей точке одного
разового отказа недостаточно, чтобы отличить гонку от единичной аномалии
окружения. Результат: **5 из 7 запусков красные**, см. находку H6 ниже. Это
прямо противоречит заявлению автора «warm_dialogs green» в хендоффе.
- **model-invariants** — не запускал: дельта не меняет ни один формат хранения
геометрии (`layout`, `marker.space`, `open_spans`, ключи толщины); H3-фикс
меняет только то, ЧЕРЕЗ КАКОЙ объект (`this` vs `this.host`) вызывается уже
существующая preflight-проверка, не сами данные. Не тот инструмент для этой
дельты — то же обоснование, что в r1.
- **Backend/pytest** — не тронуты этой дельтой (`custom_components/**/*.py` не
входит в diff `6d338b78..HEAD`), не перепрогонял.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где видно |
|---|---|---|
| **H1** (warm-remount коммитит mode в обход `_ensureEditorRuntime()`) | `_warmAdoptViewport` больше не коммитит редакторский `mode` напрямую: адаптирует `'view'` синхронно, а восстановление `mode` уходит через `_requestMode(restoredMode, false, true)`, который проходит `_ensureEditorRuntime()`; новый параметр `adopt` в `_requestMode` атомарно вызывает `_adoptMode` + возобновляет отложенный `_warmReviveDialog` | `src/houseplan-card.ts:890-901` (сигнатура/adopt-ветка `_requestMode`), `:3190-3222` (`_warmAdoptViewport`), `:3358-3369` (`_warmReviveDialog` откладывает потребление диалога, если ждёт runtime) | **Частично** — крах устранён (`smoke_preloader_lifecycle`, `smoke_warm_owners` зелёные детерминированно), но см. новую находку **H6**: та же зона правки сломала бит-точность камеры |
| **H2** (kiosk scale зависит от lazy runtime) | `_saveKioskScale`/`_renderKioskDialog` возвращены в eager-код хоста, больше не форвардятся в `_editorRuntimeOrThrow()`; вызов диалога больше не защищён условием `this._editorRuntime ?` | `src/houseplan-card.ts:10629-10662`, `:11350` (`${this._kioskDialog ? this._renderKioskDialog() : nothing}`) | `smoke_kiosk` зелёный (сам прогнал) |
| **H3** (fail-closed resize/optimize preflight не перехватывается подменой) | Внутренние вызовы `_checkSpacePhysicalGeometry`/`_checkOptimizeGeometry` внутри runtime переведены на `this.host._checkSpacePhysicalGeometry(...)`/`this.host._checkOptimizeGeometry(...)` — то есть идут через тот же публичный метод хоста, который переопределяет смок; сами реализации переименованы в `..._Impl` и не вызываются напрямую нигде, кроме хоста | `src/houseplan-editor-runtime.ts:1117-1120` (интерфейс `HouseplanEditorHostPort` пополнен), `:1761, 2085, 2156, 2265, 2288, 3609, 3626, 8591, 8599, 8688, 8721` (все внутренние вызовы через `this.host.`), `src/houseplan-card.ts:9973/9984` (`_checkOptimizeGeometry`/`_checkSpacePhysicalGeometry` хоста форвардят в `..._Impl`) | `smoke_room_resize` и `smoke_optimize_geometry_preflight` зелёные (сам прогнал) |
| **H4** (`_deviceInboxTabKey` падает — `this` не тот) | Метод стал полем-стрелкой (`public _deviceInboxTabKey = (event) => {...}`), а КАЖДЫЙ голый `@event=${this._method}` в файле переписан в `@event=${() => this._method(...)}` — то есть устранён весь класс бага, а не только один обработчик; закреплено регрессионным unit-тестом, который grep'ает файл на голые `@x=${this._y}` и требует пустой список | `src/houseplan-editor-runtime.ts:7401-7410` (метод), десятки мест по файлу (см. `git diff 6d338b78..HEAD -- src/houseplan-editor-runtime.ts`), `test/editor-runtime-loader.test.mjs:124-133` («lazy runtime event handlers keep the runtime receiver») | `smoke_device_inbox` зелёный (сам прогнал); тест зелёный в `npm test` |
| **H5** (4 golden-дельты, включая порчу тулбара Device editor) | Не изменено ни строки, относящейся к golden — **не регресс #337**: independently подтверждено, что все 4 кадра дают «different» и на чистом `origin/dev` (`508945c0`) без единого коммита этой ветки, см. раздел «Как проверялось» | `git diff origin/dev...HEAD -- demo/golden/baselines/` пуст (нулевой diff эталонов); скриншот-diff `geometry-devices-editor-dark` на чистом `dev` показывает то же наложение старого 3-кнопочного baseline поверх нового 2-кнопочного актуального рендера | **Закрыт как не относящийся к #337**; отдельный issue [#346](https://github.com/Matysh/houseplan-card/issues/346), не блокирует эту задачу |
| **M1** (`fixed_floor`/`orphan_space_references` создают `houseplan-card-editor` напрямую) | Обе фикстуры переведены на `await customElements.get('houseplan-card').getConfigElement()` | `demo/smoke_fixed_floor.mjs:75`, `demo/smoke_orphan_space_references.mjs:191` | Оба смока зелёные (сам прогнал) |
| **M2** (`general_settings` ищет версию только в entry-файле) | Смок теперь читает `houseplan-assets.json`, конкатенирует все `.js`-файлы манифеста и ищет баннер в объединённой строке | `demo/smoke_general_settings.mjs:61-67` | Зелёный (сам прогнал) |
| **M3** (9 нерасследованных красных смоков) | `ha_controls`, `nav_persist`, `partition_openings`, `grid_scale_invariance` явно адаптированы под async editor runtime (`_editorRuntime`, `await c._requestMode(...)`, `await c._ensureEditorRuntime()`) | `demo/smoke_ha_controls.mjs`, `demo/smoke_nav_persist.mjs`, `demo/smoke_partition_openings.mjs`, `demo/smoke_grid_scale_invariance.mjs` (везде в диффе `6d338b78..HEAD`) | Все четыре зелёные при точечном прогоне; `edit_walk`, `help_affordance`, `opening_entity_search` не изменялись в дельте — перепрогнаны отдельно, зелёные, входят в «унаследовано» ниже |
## Новые находки
### High (блокирует)
**H6. Warm-remount editor-камера теряет бит-точность — визуальный «прыжок»
кадра, воспроизводится в большинстве прогонов.**
Контракт (комментарий самого смока, `demo/smoke_warm_dialogs.mjs:1-9`,
`docs/WARM-REMOUNT.md`): при пересоздании элемента картой (Lovelace warm
remount) камера редактора должна восстановиться **бит-в-бит — ни один кадр не
отличается от прежнего**. Прогнал `node demo/smoke_warm_dialogs.mjs` **7 раз
подряд** после `npm run bundle:sync`: **5 из 7 — FAIL** на `bViewBitExact`:
```
FAILED (1):
- bViewBitExact: expected true, got "кадр 20мс: zoom=3.4
view=[256.7064599980745,247.64705882352942,346.587080003851,264.70588235294116]
(ждали 3.4 / [254.1156927887434,247.64705882352942,351.7686144225132,264.70588235294116])"
```
Во всех пяти отказов кадр приходит на 9–20мс после пересоздания, `zoom` и `y/h`
точно совпадают с ожиданием, а `x`/`w` — нет (расхождение 2–6 логических
единиц) — то есть не полный сброс камеры, а короткий одиночный кадр с
пересчитанной шириной вида, прежде чем всё встаёт на место. Это ровно та зона,
что переписал H1-фикс: `_warmAdoptViewport` (`src/houseplan-card.ts:3196-3222`)
синхронно восстанавливает `_zoom`/`_view` в точные сохранённые числа ДО того,
как `_mode` меняется на `'devices'`, но сам `_mode` первый кадр или два остаётся
`'view'` (полноширинная сцена без редакторского тулбара), а асинхронный
`_requestMode(restoredMode, false, true)` переключает `_mode` только после
`_ensureEditorRuntime()`. Между этими двумя моментами существует окно, где
камера уже стоит в «devices»-числах, но сцена ещё рендерится в View-раскладке
(другая доступная ширина stage) либо наоборот — и какой-то путь размера/layout
(вероятно, привязанный к `ResizeObserver`/первому измерению `_stageEl` для
нового инстанса, `_lastValidStageSize` пуст на свежесозданном элементе)
пересчитывает `x`/`w` под несовпадающую раскладку на один кадр.
**Смок умеет падать** — это не переписанный этой задачей файл, докстрока прямо
говорит «ПАДАЕТ на сборке до DEV-B703-03» (задача, которая изначально ввела
это требование), и я лично наблюдал детерминированный, воспроизводимый по
большинству прогонов красный результат, а не разовую аномалию: 5/7. Полный
census (195/195, единичный прогон каждого файла) этот файл не поймал —
статистически ожидаемо при частоте отказа 71% и единственной попытке (см. раздел
«Как проверялось»), а не признак исправности.
Это отдельная находка от H1 (не то же самое, что было в r1): в r1 весь смок падал
с необработанным исключением ДО того, как код доходил до этой проверки —
H1-фикс устранил исключение, но обнажил под ним ещё один, ранее не наблюдаемый
дефект в том же коде. Прямо противоречит хендоффу автора («warm_dialogs...
green», перечислено как pass в списке «targeted review smokes»). Учитывая
majority-red частоту (71%) и то, что визуальный прыжок камеры при
warm-remount — это ровно тот UX-дефект, ради предотвращения которого
существует весь контракт DEV-B703-03/WARM-REMOUNT.md, серьёзность максимальная.
### Medium (вне скоупа #337 — отдельный issue)
**M4 (→ [#346](https://github.com/Matysh/houseplan-card/issues/346)). Golden-эталоны
`geometry-devices-editor-dark`, `device-dialog-mobile-ru`,
`toggle-entity-dialog-mobile-ru`, `device-ripple-color-popover-mobile-ru`
устарели на `origin/dev` независимо от #337.** Воспроизведено на чистом
`508945c0`: `node demo/golden/run.mjs --mode=capture --scenario=<id>` для
каждого из четырёх даёт `different`; diff-PNG `geometry-devices-editor-dark`
показывает наложение устаревшего 3-кнопочного тулбара эталона на актуальный
2-кнопочный рендер (тулбар Device editor был упрощён каким-то более ранним,
уже смёрженным в `dev` изменением, после которого эталон не переснимали); три
mobile-ru кадра — устаревший размер диалога по правому/нижнему краю. Нужно
принять новые эталоны через `npm run golden:accept -- --reviewed` по чистому
`dev`, не через эту ветку. Issue заведён.
## Что проверено и корректно
- **Бюджет (AC1).** 255 778 B ≤ 256 000 B, посчитано лично. Запас упал с 615 B
(r1) до 222 B — риск, названный ещё в r1, материализуется: правки H1/H4
добавили код (новый параметр `_requestMode`, обёртки обработчиков), и запас
почти исчерпан. Не блокирую этим, так как AC1 формально выполнен и риск уже
зафиксирован, но следующая правка (в том числе исправление H6) обязана
держать бюджет в уме.
- **H2/H3/H4/M1/M2 фактически исправлены** — не только по диффу, но по
собственному прогону соответствующих smoke, см. таблицу выше.
- **H5 закрыт корректно и добросовестно** — авторское расследование не просто
заявлено, а воспроизведено мной независимо на чистом `dev`.
- **Мутационное покрытие затронутых guard'ов (changed-range)** — 58/58 ловят
подмену; changed-range корректно расширился под новый файл
`src/styles/plan.styles.ts`, где теперь реально лежит проверяемый CSS.
- **Полный browser-smoke census** — 195/195 pass на единичном прогоне (не
противоречит H6, см. выше).
- **Trailers/changelog.** Все 4 коммита несут `Issue: #337`; все — `User-Visible:
no` — корректно, т.к. пользовательское поведение (после исправления
H1–H4) возвращено к состоянию ДО дефектов r1, changelog для #337 уже писался
отдельным коммитом на предыдущем этапе (`c1aaddc7`, зафиксирован в r1).
## Унаследовано из r1
Принято без повторной проверки в этом раунде, дельта их не касается:
- **AC7 (backend asset route)** — `tests_backend/test_frontend_assets.py`,
не входит в diff `6d338b78..HEAD`; вывод r1 (CODE-REVIEW-337-r1.md,
SHA `6d338b78`, раздел «Что проверено и корректно») остаётся в силе.
- **AC8 (bundle sync/freshness/HACS zip/release scripts)** — не входит в
дельту; вывод r1 наследуется.
- **AC9 (CSS-минификатор, adversarial fixtures)** — не тронут этой дельтой,
кроме одной ссылки в `mutation-gate.mjs` на новый путь файла стилей, что не
меняет сам минификатор; вывод r1 наследуется.
- **AC10 (fingerprint mismatch)**, **AC12 (no model drift)**, **AC13 (докс не
обещает single-file install)** — вне дельты, наследуются из r1 документа.
- **Прочие смоки, не входящие в таблицу закрытия r1**, включая полный набор
195 файлов — пройдены заново единичным census + точечными повторными
прогонами там, где я подозревал гонку, а не приняты по r1 без проверки:
дельта тронула общий рендер-путь всех редакторских диалогов (H4-правка),
поэтому я не ограничился списком автора.
## Чего не проверял
- **Полный HA-harness backend** (`python -m pytest tests_backend -q`) —
недоступен в этой песочнице (нет `homeassistant`), дельта не трогает
`custom_components/**/*.py`, поэтому и не требовался в этом раунде.
- **`node scripts/model-invariants.mjs` / `npm run invariants`** — не
запускал, дельта не меняет формат геометрической записи (обоснование выше).
- **Perf-профили** — не запускал, не названы в AC и не затронуты дельтой.
- **Ручное тестирование в реальном Home Assistant** — не выполнялось.
- **Частоту гонки H6 в CI-окружении** (headless Linux CI vs моя песочница) —
не измерял отдельно; предполагаю сравнимую или более высокую частоту (CI
раннеры обычно медленнее/более вариативны по таймингу кадров, что скорее
усилит гонку, чем скроет её), но это предположение, а не измерение.
## Итог
Три из пяти High r1 закрыты полностью (H2, H3, H4), H5 закрыт как посторонний
(и это подтверждено, а не принято на слово), но **H1 закрыт лишь частично**:
устранённый крах открыл дорогу новому, статистически частому (5 из 7 прогонов)
дефекту в той же самой warm-remount/lazy-adopt зоне — H6, видимый прыжок
камеры редактора при пересоздании карточки. Это блокирующая находка, вердикт
остаётся красным. Заявление автора «warm_dialogs... green» не подтвердилось
при независимом многократном прогоне — рекомендую на следующем заходе
прогонять чувствительные к таймингу smoke (`smoke_warm_dialogs`,
`smoke_warm_owners`, `smoke_preloader_lifecycle`) не менее 3–5 раз подряд
перед хендоффом, а не один раз.
Отдельно: заведён новый issue [#346](https://github.com/Matysh/houseplan-card/issues/346)
на предсуществующий (не из #337) дефект золотых эталонов Device
editor/mobile-ru диалогов (M4) — он не блокирует #337 и не должен чиниться в
этой ветке.
+264
View File
@@ -0,0 +1,264 @@
# CODE-REVIEW-337-r3
- Issue: https://github.com/Matysh/houseplan-card/issues/337
- Ветка: `issue/337-lazy-editor-chunk`, ревьюемый SHA: `16c616be165604aca8eb9c7cdeab59024a828006`
- Заход: r3 · блокирующих циклов израсходовано **2/4** до этого разбора
- **Вердикт: красный.**
## Скоуп разбора
Предыдущий вердикт — CODE-REVIEW-337-r2, SHA ревью `2beafc06398c16d1cad80fddbe77e6a40f111b9a`
(красный, High:1 — H6, Medium:0). Этот SHA и SHA r1 (`6d338b78`) в текущей истории
отсутствуют: ветка была перебазирована. Проверил, на что именно:
```
git log --oneline 59ae6b1 -3
59ae6b10 ci: полная история без блобов там, где содержимое старых файлов не нужно
80a664bc docs: рекомендовать blobless-клон вместо полного
508945c0 fix: a 0° pair is the shared-wall model, not a duplicate — field revert of #331 §2.2
```
`508945c0` — это тот же `dev`, на котором уже стояли и r1, и r2 (см. r2-документ:
«branch и так уже стоит на `508945c0` = `origin/dev`»). Между r2 и этим заходом `dev`
продвинулся всего на два коммита класса C/B (`docs`, `ci`, ни один не в `src/**` и не
в `custom_components/**/*.py`) — не «ребейз на ушедший вперёд dev» в смысле §2.10/§7.2
(другая подсистема, смена контракта), а техническая синхронизация без содержательного
чужого кода. Полный разбор с нуля не требуется; веду по дельте.
**Материал дельты r2→r3** — единственный содержательный коммит:
```
16c616be fix: preserve warm editor camera during lazy adoption (src/houseplan-card.ts +28/-4, плюс зеркальные dist/*)
```
(Коммиты `8afc8636` — документ r2, `6b482f4c`/`946f67ac`/`6f9df14d` предшествуют r2 и
уже разобраны в CODE-REVIEW-337-r2 — не входят в дельту этого раунда.)
Единственная находка предыдущего раунда — **H6** (потеря бит-точности камеры
warm-remount при lazy-adopt). Разбор ограничен: (а) верификацией фикса H6, (б) всеми
проверками, до которых дотягивается 15-файловый diff (`src/houseplan-card.ts` +
зеркальные `dist/`/`custom_components/houseplan/frontend/`), (в) обязательным
по AC13/§8 `check-docs` (диф трогает `src/**`).
## Как проверялось
Дешёвые гейты — прогнаны лично на `16c616be`, не приняты со слов автора:
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` | pass, exit 0 |
| unit | `npm test` | 1440 total, **1439 pass, 1 skip, 0 fail** (у автора 1438/2 — расхождение на один conditional skip, см. ниже, не регрессия) |
| build+sync+budget | `npm run build && npm run bundle:sync && npm run bundle:budget` | initial View **255 910 B** ≤ 256 000 B (запас **90 B**, у автора — то же число); lazy editor 131 779 B; три дерева (`dist`, `custom_components/houseplan/frontend`, `demo/srv/assets`) синхронны |
| docs fingerprint (диф трогает `src/**`, AC13 прямо называет `check-docs` доказательством) | `node scripts/check-docs.mjs` | **FAIL, exit 1**: `ERROR screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs` — см. находку H7 |
| mutation, changed-range | `node scripts/mutation-gate.mjs --check --changed=16c616be^..16c616be` | 30/30 применимых guard'ов ловят подмену |
Разница в skip-счётчике: у меня единственный skip — `issue 281 private exact fixture
has no enabled zero-range handle # SKIP private #281 fixture is not present`, условный
пропуск из-за отсутствующей приватной фикстуры в песочнице, к #337 не относится. Не
исследовал, какой второй тест пропущен у автора на Windows — сумма (1440) совпадает,
расхождение не в счёте тестов #337.
Целевые browser smokes (по дельте — файл трогает ровно зону `_requestMode` /
`_refitView` / `_setMode` / disconnect-guard, разбор не «по названию», а по
затронутым символам):
| Smoke | Зачем выбран | Результат |
|---|---|---|
| `smoke_warm_dialogs.mjs` ×7 подряд, свежий `bundle:sync` | тот самый смок, поймавший H6 (5/7 fail в r2) | **7/7 OK**, `bViewBitExact` не подводит ни разу |
| `smoke_warm_owners.mjs` | та же warm-adopt зона (`_warmAdoptViewport`/`_requestMode(adopt)`) | OK |
| `smoke_preloader_lifecycle.mjs` | та же зона, ранее ловил H1 | OK |
| `smoke_kiosk.mjs` | ранее ловил H2, эта зона трогает общий `_requestMode` | OK |
| `smoke_mode_transition.mjs` | напрямую использует `_modeTransitionBusy`, которым теперь делит guard `_refitView` | OK |
| `smoke_resize_wall_thickness.mjs` | использует resize/refit во время editor-режима | OK |
| `smoke_warm_remount.mjs` | смежный warm-remount контракт, слабая связь по `smoke-select` | OK |
| `smoke_room_resize.mjs`, `smoke_optimize_geometry_preflight.mjs` | H3-зона (`this.host.` preflight), проверка на отсутствие регресса от нового `isConnected`/`_warmModeRequest` раннего return | OK |
`node scripts/smoke-select.mjs --base origin/dev --head HEAD` даёт 175/195 «прямое
совпадение» — бесполезно как фильтр для этого раунда: `origin/dev` не содержит всей
задачи #337 целиком, поэтому инструмент показывает список для ВСЕЙ ветки, а не для
дельты r2→r3. Использовал его для контекста (список выше уже включает `smoke_warm_dialogs`,
`smoke_mode_transition` и `smoke_warm_remount` из числа тех, что он называет), но
подбирал набор вручную по символам самого диффа, а не по выдаче инструмента.
Полный census 195 smoke-файлов и `golden:verify` **не перезапускал**: он уже
пройден дважды (195/195 в r1, 195/195 в r2) на неизменной по этому раунду части
дерева, а этот раунд — один файл в одной узкой зоне таймингов, для которой census
уже доказанно нечувствителен (сам census не поймал H6 ни разу, только точечный
7×-прогон). Полный набор — предрелизный гейт (§8), не гейт ревью при такой дельте.
`model-invariants` — не запускал: diff не меняет ни один формат геометрической
записи (`layout`, `marker.space`, `open_spans`, ключи толщины) — правка только
про `_view`/`_zoom` кэш камеры и таймер refit. Backend/pytest — не тронут этой
дельтой, не перепрогонял.
## Находки
### High (блокирует)
**H7. `check-docs` красный на ревьюемом SHA — стало устаревшим ровно из-за
этого коммита, AC13 не доказан.**
`node scripts/check-docs.mjs` → `ERROR screenshot source fingerprint is stale`
(exit 1). Причина механическая и не спорная: `scripts/source-fingerprint.mjs`
хэширует весь `src/**`; `16c616be` меняет `src/houseplan-card.ts` (единственный
src-файл в этом коммите) и не обновляет `sourceFingerprint` в
`docs/images/screenshots.json`, поэтому хэши расходятся. На SHA r2
(`2beafc06`) `check-docs` был зелёным (сам документ r2: «pass (7 файлов, 10
внешних ссылок)») — регрессия введена именно этим, последним коммитом
раунда, а не унаследована.
Это прямо предусмотренный в задаче случай, а не мелочь: AC13 спецификации
(`docs/specs/337-lazy-editor-chunk.md:346-348`) называет `check-docs` **прямым
доказательством** этого критерия приёмки, а §8 `PROCESS.md` называет его
«реальным блокером» именно за то, что отпечаток покрывает весь `src/**` — «любая
правка фронтенда делает его устаревшим… выбирать тут нечего». Цена пропуска
уже была измерена дважды на этой же кодовой базе: «скриншоты не пересняли в
#230 и #234, и `dev` стоял с красным job `docs`, пока это не нашли при
следующей задаче (#237)». Слияние `16c616be` в `dev` в текущем виде
воспроизводит ровно этот сценарий.
Автор в хендоффе смешал эту находку с H5/#346: «известный stale screenshot
fingerprint; H5 уже независимо подтверждён ревьюером и вынесен в #346» — но
H5/#346 про **golden-эталоны** (`demo/golden/baselines/**`, устаревший baseline
тулбара Device editor и mobile-ru диалогов, не связан с #337 и воспроизводится
на чистом `dev`), а это — про **fingerprint скриншотов документации**
(`docs/images/screenshots.json`, `docs-section` в `README`/`USER-GUIDE`),
совершенно другой гейт с другим владением. Ссылка на #346 не закрывает эту
находку: #346 не затрагивает `sourceFingerprint`, а причина здесь — собственный
код-коммит этой ветки, а не предсуществующее состояние `dev`.
Сам автор уже дважды в этой же задаче правильно закрывал этот же гейт без
замены единого PNG («fingerprint обновлён без замены PNG, потому что
наблюдаемого UI-изменения нет» — хендофф после H1–H4). Здесь этот шаг просто
пропущен для последнего коммита.
**Воспроизведение:** `git checkout 16c616be -- .` (или просто на HEAD), затем
`npm run build && node scripts/check-docs.mjs` → exit 1 с текстом выше.
**Исправление в скоупе задачи**: прогнать `Docs screenshots` (workflow_dispatch)
и принять новый fingerprint через `npm run docs:accept -- --reviewed
--from=<артефакт>` (замены PNG не требуется, если рендер не изменился —
ровно то, что уже делалось между r1 и r2 этой же задачи).
### Medium
Нет находок этого уровня в дельте r2→r3.
### Low
Нет.
## Что проверено и корректно
- **Сама механика фикса H6 логически закрывает найденную гонку.** Разобрал
правку построчно (`src/houseplan-card.ts:803-925, 2549, 5880, 6662`):
- новое поле `_warmModeRequest` выставляется только на `adopt`-пути
`_requestMode` (`:894-901`) и гейтит `_refitView` (`:5880`: `if
(this._modeTransitionBusy || this._warmModeRequest) return;`) — то есть
ResizeObserver-триггерный пересчёт `x`/`w` камеры (источник H6) подавлен
на всё время окна между синхронным восстановлением `_view` и асинхронным
переключением `_mode` через `_requestMode(restoredMode, false, true)`;
- после `updateComplete` и двух `requestAnimationFrame` (реальная
расстановка раскладки после переключения режима) код берёт фактический
`stage.clientWidth/Height` как новый `_lastValidStageSize` и снимает
guard (`:918-924`) — следующий настоящий resize сравнивается с верной
базой, а не с устаревшей;
- guard снимается на всех путях выхода: неудача `_ensureEditorRuntime()`
(`:903`), опережающий запрос или дисконнект во время `await`
(`:906-907`, добавленная проверка `!this.isConnected`), обычная смена
режима пользователем через `_setMode` (`:6662`), и
`disconnectedCallback` (`:2549`) — то есть защита не может «залипнуть».
- `_warmAdoptViewport`/`adopt=true` вызывается только на **свежесозданном**
экземпляре карточки при warm-remount (`:3110, 3243`), где `_refitRaf`/
`_pendingRefitSize` заведомо в начальном состоянии — отмена «на всякий
случай» на `:899-900` не теряет ничьих легитимных запросов.
- **Эмпирически гонка не воспроизводится.** `smoke_warm_dialogs.mjs` — тот же
файл и тот же сценарий, что дал 5 отказов из 7 в r2 — **7/7 OK** на этом
SHA после `bundle:sync`. Это выполняет дисциплину «тест умеет падать»: он
падал большинством прогонов на предыдущей ревизии кода и на новой этого не
делает ни разу — не совпадение единичного зелёного прогона.
- **Соседние сценарии той же зоны не пострадали**: `warm_owners`,
`preloader_lifecycle` (H1-зона), `kiosk` (H2-зона), `room_resize`,
`optimize_geometry_preflight` (H3-зона), `mode_transition`,
`resize_wall_thickness`, `warm_remount` — все зелёные лично.
- **Бюджет (AC1)** — 255 910 B ≤ 256 000 B, посчитано лично. Запас **90 B**,
сократился с 222 B (r2) — тенденция, названная риском уже в r1/r2,
продолжается и здесь и стоит держать в уме на любой следующей правке;
формально AC1 выполнен, не блокирую этим отдельно.
- **Mutation gate по коммиту** — 30/30 применимых guard'ов на
`16c616be^..16c616be` ловят подмену; изменённая зона осталась под
контролем существующих guard'ов, новых guard'ов эта точечная правка не
требовала.
- **Трейлеры.** `Issue: #337`, `User-Visible: no` — корректно: пользовательский
changelog для #337 уже зафиксирован отдельным коммитом ранее (r1), сама
правка не добавляет нового видимого поведения, а завершает контракт до
релиза (задача ещё не в `dev`, User-Visible относится к тому, что увидит
пользователь после выпуска).
- **Одно число — один источник.** Диф не вводит и не дублирует ни одной
видимой пользователю величины: `_view`/`_zoom`/`_lastValidStageSize` — это
внутреннее состояние камеры рендера, не выводится текстом/подписью нигде
в UI. Правило `test/single-source-numbers.test.mjs` не затронуто и
проходит в общем `npm test`.
## Закрытие раунда r2
| Находка r2 | Чем закрыта | Где видно |
|---|---|---|
| **H6** (потеря бит-точности камеры warm-remount, 5/7 fail на `smoke_warm_dialogs`) | `_refitView` подавлен на время адаптации новым `_warmModeRequest`-guard'ом; после двух `rAF` после `updateComplete` берётся фактический размер сцены как новая база для рефита — устраняет саму гонку резайза во время переключения режима | `src/houseplan-card.ts:806, 894-925, 2549, 5880, 6662`; эмпирически — `smoke_warm_dialogs.mjs` 7/7 OK (прогнал лично) против 5/7 FAIL на предыдущей ревизии |
## Унаследовано из r2
Принято без повторной проверки в этом раунде — дельта (`16c616be`, один
src-файл + зеркальные `dist/`) их не касается:
- **AC1 (initial budget)** — пересчитан лично в этом же раунде (см. таблицу
гейтов), не просто унаследован: 255 910 B ≤ 256 000 B.
- **AC2 (lazy boundary), AC5 (loader atomicity), AC6 (failure сохраняет View)** —
вне дельты; вывод CODE-REVIEW-337-r2.md (SHA `2beafc06`, раздел «Закрытие
раунда r1» + «Что проверено и корректно») остаётся в силе.
- **AC7 (asset security), AC8 (полнота distribution), AC9 (CSS-минификатор)** —
не тронуты этой дельтой; вывод r2 (наследующий вывод r1, SHA `6d338b78`)
остаётся в силе.
- **AC10 (fingerprint mismatch runtime — версии не смешиваются), AC11
(onboarding/async config editor), AC12 (no model drift)** — вне дельты,
наследуются из r2/r1.
- **H2, H3, H4, M1–M3 (r1)** и **H5/M4 → #346 (golden baselines, вне скоупа
#337)** — закрыты и подтверждены независимым прогоном ещё в r2
(CODE-REVIEW-337-r2.md, SHA `2beafc06`); эта дельта их зону не трогает.
- **Полный browser-smoke census (195/195) и `golden:verify` (127/131, 4
«different» = #346)** — пройдены дважды (r1 SHA `6d338b78`, r2 SHA
`2beafc06`) на неизменной этим раундом части дерева; не перезапускал
целиком, см. «Как проверялось».
Отдельно от AC-цепочки: **AC13 (docs) не наследуется** — именно на нём найдена
новая находка H7 этого раунда (было зелёным на SHA r2, стало красным на SHA r3).
## Чего не проверял
- **Полный HA-harness backend** (`python -m pytest tests_backend -q` с
установленным `homeassistant`) — недоступен в этой песочнице, дельта не
трогает `custom_components/**/*.py`.
- **`node scripts/model-invariants.mjs`** — не запускал, дельта не меняет
формат геометрической записи (см. «Как проверялось»).
- **Полный browser-smoke census (195 файлов) и `golden:verify`** — не
перезапускал в этом раунде; обоснование выше и в разделе «Унаследовано».
- **Perf-профили** — не запускал, не названы в AC и не затронуты этой
дельтой.
- **Частоту гонки H6 в headless Linux CI** отдельно от своей песочницы — не
измерял; 7/7 подряд в своём окружении расцениваю как достаточное
доказательство закрытия конкретно найденной гонки (тот же порог, что
задавал сам r2-документ), но абсолютную частоту в CI не оцениваю числом.
- **Ручное тестирование в реальном Home Assistant** — не выполнялось (в
процессе нет фазы ручного тестирования, код-ревью отвечает за AC вместо
него).
## Итог
Фикс H6 логически и эмпирически закрывает единственную блокирующую находку
r2: разобран по коду, зона правки атомарна и не расширяет скоуп, гонка не
воспроизводится ни разу за 7 прогонов там, где раньше падала в 5 из 7.
Однако тот же самый коммит красит обязательный по AC13 гейт `check-docs` —
не унаследованная, а новая, введённая этим раундом находка (H7), с уже
дважды на этой задаче отработанным способом закрытия. Вердикт остаётся
красным по формальному правилу «High блокирует», при том что содержательная
причина возврата — узкая: один пропущенный шаг (пересъёмка/приёмка
fingerprint), а не дефект самого фикса камеры.
+216
View File
@@ -0,0 +1,216 @@
# CODE-REVIEW-337-r4
- Issue: https://github.com/Matysh/houseplan-card/issues/337
- Ветка: `issue/337-lazy-editor-chunk`, ревьюемый SHA: `0bd5570ae56b878dabe5c440840f5a3c90c523a0`
- Заход: r4 · блокирующих циклов израсходовано **3/4** до этого разбора
- **Вердикт: зелёный.**
## Скоуп разбора
Предыдущий вердикт — CODE-REVIEW-337-r3, SHA ревью `16c616be165604aca8eb9c7cdeab59024a828006`
(красный, High:1 — H7, Medium:0). Этот SHA в текущей истории отсутствует: ветка
была перебазирована. Проверил, на что именно:
```
git log --oneline 59ae6b1..ff6e53a4
ff6e53a4 docs: review document for #333
```
`dev` продвинулся ровно на **один** коммит между базой r3 (`59ae6b1`) и текущей
базой (`ff6e53a4` = текущий `origin/dev`) — класс C, docs-only ревью-документ
чужого issue (#333), не в `src/**`, не в `custom_components/**/*.py`. Это не
«ребейз на ушедший вперёд dev» в смысле §2.10/§7.2 (другая подсистема, смена
контракта), а техническая синхронизация без содержательного чужого кода.
Полный разбор с нуля не требуется; веду по дельте.
**Материал дельты r3→r4:**
```
33a0110e fix: preserve warm editor camera during lazy adoption (тот же код, что 16c616be — рехеширован ребейзом; сверил построчно, см. ниже)
d30cc799 docs: review document for #337 (публикация документа r3, не продукт)
0bd5570a docs: accept lazy editor screenshot fingerprint (H7-фикс: docs/images/screenshots.json + 06-device-editor.png)
```
Проверил тождественность рехешированного `33a0110e` содержимому, разобранному в
r3, а не поверил ребейзу на слово: прочитал `src/houseplan-card.ts:803-925,
2549, 5880, 6662` на текущем HEAD и сверил построчно с описанием фикса в
CODE-REVIEW-337-r3.md — `_warmModeRequest`, гейт `_refitView`, снятие guard'а
после `updateComplete` + два `requestAnimationFrame`, снятие на всех путях
выхода — код идентичен тому, что r3 уже разобрал и принял. Единственный
содержательный новый коммит этого раунда — `0bd5570a` (закрытие H7).
Единственная находка предыдущего раунда — **H7** (`check-docs` красный на
последнем коммите r3, AC13 не доказан). Разбор ограничен: (а) верификацией
закрытия H7, (б) подтверждением, что ребейз не изменил семантику уже принятого
кода, (в) самостоятельным перепрогоном дешёвых гейтов на итоговом SHA, раз
готового зелёного Validate-прогона с реально исполненными job'ами `frontend`/
`smoke`/`golden` на этом конкретном SHA нет (см. ниже).
## Как проверялось
### Дешёвые гейты — прогнаны лично на `0bd5570a`
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` | pass, exit 0 |
| unit | `npm test` | 1440 total, **1439 pass, 1 skip, 0 fail** |
| build | `npm run build` | pass, `dist` пересобран за 13.8s |
| bundle sync | `npm run bundle:sync` | pass; после пересборки `git status` — 0 диффов в содержимом (только file-mode 755→644 в двух файлах от локальной пересборки, откатил `git checkout --`); три дерева (`dist`, `custom_components/houseplan/frontend`, `demo/srv/assets`) синхронны и побайтово совпадают с закоммиченными |
| bundle budget | `npm run bundle:budget` | initial View **255 910 B** ≤ 256 000 B (запас 90 B, то же число, что в r3/хендоффе); lazy editor 131 779 B |
| docs fingerprint (H7) | `node scripts/check-docs.mjs` | **pass**: «Documentation checks passed (7 files, 10 external links)» |
Пересборка бандла на моей машине оказалась побайтово идентична закоммиченному
дереву — это независимое подтверждение, что `bundle:sync`/`bundle:budget` не
разошлись со временем автора, а не просто повтор его чисел.
### Почему проверял `check-docs` и билд лично, а не принял чужой зелёный Validate как есть
В задаче процитирован зелёный `Validate` на `0bd5570a`
(https://github.com/Matysh/houseplan-card/actions/runs/33144989734) как повод
не гонять `tsc`/`test`/`build` самому. Проверил сам прогон, потому что в r1
этой же задачи уже был прецедент: зелёный Validate на docs-only коммите не
означает, что `frontend`/`smoke`/`golden` реально исполнялись (§1, находка r1).
Разобрал этот конкретный прогон:
```
gh run view 33144989734 --json jobs -q '.jobs[] | .name + " => " + .conclusion'
```
Job «Классификация изменённых файлов» вычислил инкрементальный diff между
`before`-SHA пуша (форс-пуш после ребейза, старый tip недостижим локально) и
`0bd5570a`: три файла — `docs/images/06-device-editor.png`,
`docs/images/screenshots.json`, `docs/reviews/SPEC-REVIEW-333-r1.md` (последний
— спутник ребейза на `ff6e53a4`, тоже docs). `frontend=false`, поэтому job
«Фронтенд: типы, юниты, мутанты, синхрон бандла» (где живут `tsc`/`test`/
`build`) в этом конкретном прогоне **skipped**, а не green по содержанию — сам
Validate зелёный только потому, что docs-гейты (которые реально исполнялись)
прошли. Отдельно проверил job «Переиспользование: это дерево уже проверено»
(`scripts/gate-reuse.mjs`): `golden` и `backend` дали `Cache not found` (не
были waived кэшем), но всё равно оказались `skipped` в графе — их пропустила
та же path-классификация `frontend=false`, а не механизм переиспользования.
Вывод не «Validate врёт»: срез diff между force-push before/after корректно
показал, что после ребейза содержимое `dist`/`src` не изменилось относительно
уже прошедшего полный `Validate` состояния (та же логика, что уже описана в
самом r1-документе про докс-коммиты). Но раз я не могу подтвердить это архивной
реконструкцией истории (старые SHA недостижимы после форс-пуша), надёжнее было
перепрогнать `tsc`/`test`/`build`/`bundle:*` самому на итоговом дереве — они
дешёвые (минуты) — чем полагаться на цепочку рассуждений о том, что должно было
быть проверено раньше. Результат подтвердил: числа и статусы совпадают с тем,
что репортили автор и r3.
### Что не прогонял и почему
- **Полный browser-smoke census (195 файлов) и `golden:verify`** — не
перезапускал. Дельта r3→r4 (`0bd5570a`) касается исключительно
`docs/images/**`; ни один файл `src/**`, `demo/**`, `test/**` не изменился.
Census и golden уже пройдены дважды на продуктовом дереве (r1: 195/195 и
127/131 golden; r2: 195/195; targeted smokes зоны H6 — многократно в r2/r3) и
не могут быть чувствительны к правке `screenshots.json`/PNG документации —
они не читают эти файлы. `smoke-select` не гонял: применять его к диффу, не
содержащему ни одного `src`/`demo` файла, не даёт сигнала.
- **`node scripts/model-invariants.mjs`** — не запускал. Дельта не меняет
формат геометрической записи (`layout`, `marker.space`, `open_spans`, ключи
толщины); последний src-коммит, трогавший геометрию/камеру (`33a0110e`),
уже прогонялся через `npm test` (модельные инварианты входят в общий набор)
и мной, и r3 — без изменений с тех пор.
- **`node scripts/mutation-gate.mjs`** — не запускал повторно. Дельта не
трогает `src/**`; r3 уже прогнал `--changed` для единственного src-коммита
этой цепочки (30/30).
- **`python -m pytest tests_backend -q`** — не запускал. Диапазон дельты не
трогает `custom_components/**/*.py`.
- **Perf-профили** — не запускал, не названы в AC и не затронуты этой дельтой.
- **Ручное тестирование в реальном Home Assistant** — не выполнялось (фазы
ручного тестирования в процессе нет).
## Находки
Находок этого раунда нет — High/Medium/Low: 0/0/0.
## Что проверено и корректно
- **H7 закрыт.** `node scripts/check-docs.mjs` зелёный на `0bd5570a`: «pass (7
files, 10 external links)», ровно та же форма, что r1 фиксировал как норму.
Причина закрытия видна в самом коммите: `sourceFingerprint` в
`docs/images/screenshots.json` обновлён с `d1ea5e9d…` на `5f56dab6…` —
значение, полученное реальной пересъёмкой (`sourceSha256` во всех 10
сценариях синхронно обновлён на то же значение), а не скопировано вручную.
Единственный изменившийся `imageSha256` — `06-device-editor.png`
(`a01bad41…` → `36ed21b6…`), все остальные 9 `imageSha256` не тронуты —
соответствует утверждению автора и r3, что рендер изменился только на 1 байт
PNG-кодирования и только у одного кадра (r3 сам это независимо
воспроизвёл ещё до принятия).
- **Ребейз не изменил семантику.** `dev` продвинулся на один класс-C коммит
(docs-review чужого issue), не в `src/**`. Локальная пересборка (`npm run
build && npm run bundle:sync`) дала 0 диффов содержимого относительно
закоммиченного дерева — рехешированные ассеты `33a0110e` соответствуют
ровно той сборке, что уже стояла до ребейза.
- **Код фикса H6 (унаследован из r3, но перечитан для очистки от риска «ребейз
= другой код»)** — построчно совпадает с описанием r3: `_warmModeRequest`
выставляется только на `adopt`-пути (`src/houseplan-card.ts:894-901`), гейтит
`_refitView` (`:5880`), снимается после `updateComplete` + двух `rAF`
(`:918-924`) и на всех путях выхода (`:903, 906-907, 2549, 6662`). Изменений
в этой логике между `16c616be` и `33a0110e` нет.
- **Бюджет (AC1)** — пересчитан лично: 255 910 B ≤ 256 000 B, то же число, что
в r3 и в хендоффе — запас 90 B не изменился и не ухудшился этим раундом.
- **Трейлеры.** Оба коммита раунда (`33a0110e`, `0bd5570a`) несут `Issue: #337`
и `User-Visible: no` — корректно: ни один не добавляет нового наблюдаемого
пользователем поведения (H6 — внутреннее исправление тайминга камеры,
H7-фикс — служебный docs-артефакт), changelog для #337 уже закрыт в r1.
- **Одно число — один источник.** Дельта этого раунда не вводит и не дублирует
ни одной пользовательской величины: правка ограничена внутренним
fingerprint/хешами и байтами PNG документации, не выводимыми в UI как текст.
`test/single-source-numbers.test.mjs` проходит в общем `npm test`.
## Закрытие раунда r3
| Находка r3 | Чем закрыта | Где это видно |
|---|---|---|
| **H7** (`check-docs` красный на последнем src-коммите r3, AC13 не доказан) | Канонический прогон `Docs screenshots` (workflow_dispatch) на ветке задачи, Linux-артефакт принят через `npm run docs:accept -- --reviewed --from=...`; `sourceFingerprint`/`sourceSha256` синхронно обновлены во всех 10 сценариях, изменился ровно 1 `imageSha256` | Коммит `0bd5570a` (`docs/images/screenshots.json`, `docs/images/06-device-editor.png`); `node scripts/check-docs.mjs` → pass лично на `0bd5570a` |
## Унаследовано из r3 (и транзитивно из r1/r2)
Принято без повторной проверки в этом раунде — дельта (`33a0110e`/рехеш +
`0bd5570a`, docs-only) их не касается:
- **AC1 (initial budget)** — пересчитан лично в этом раунде (см. таблицу
гейтов), не просто унаследован.
- **AC2 (lazy boundary), AC5 (loader atomicity), AC6 (failure сохраняет
View)** — вне дельты; вывод CODE-REVIEW-337-r2.md (SHA `2beafc06`) остаётся в
силе.
- **AC3/AC4 (View и editor parity), включая всю зону H1–H4 из r1 (warm-remount,
kiosk, resize/optimize preflight, device inbox)** — закрыты и подтверждены
независимым прогоном в r2 (CODE-REVIEW-337-r2.md, SHA `2beafc06`); эта дельта
их зону не трогает.
- **AC7 (asset security), AC8 (полнота distribution), AC9 (CSS-минификатор)** —
не тронуты этой дельтой; вывод r1/r2 (CODE-REVIEW-337-r1.md, SHA `6d338b78`)
остаётся в силе.
- **AC10 (fingerprint mismatch), AC11 (onboarding/async config editor), AC12
(no model drift)** — вне дельты, наследуются из r1/r2.
- **H6 (потеря бит-точности камеры warm-remount)** — закрыт и подтверждён в r3
(CODE-REVIEW-337-r3.md, SHA `16c616be`, `smoke_warm_dialogs` 7/7 против 5/7
fail); код идентичен на текущем SHA (см. «Что проверено и корректно» выше —
перечитан заново, не просто принят на слово).
- **H5/#346 (устаревшие golden-эталоны Device editor/mobile-ru)** — не
относится к #337, независимо воспроизведён на чистом `dev` в r2, заведён
отдельным issue #346 (проверил: issue открыт, `S1-new`, `bug`, `P3`).
- **Полный browser-smoke census (195/195) и `golden:verify` (127/131, 4
«different» = #346)** — пройдены в r1/r2 на неизменной этим и предыдущим
(r3) раундами части дерева; не перезапускал, см. «Что не прогонял».
Отдельно от AC-цепочки: **AC13 (docs) не наследуется** — на нём в r3 была
найдена находка H7, в этом раунде она закрыта заново проверенным гейтом, а не
принята со слов автора.
## Итог
Единственная блокирующая находка предыдущего раунда (H7 — устаревший
screenshot fingerprint) закрыта корректно: канонический Linux-прогон,
воспроизводимый дважды с одинаковым хешем, обновил fingerprint и ровно один
байтово отличающийся кадр, без замены остальных девяти. Ребейз между r3 и r4
не изменил продуктовый код — на `dev` прилетел один нерелевантный docs-коммит
другого issue, и локальная пересборка дала 0 диффов с закоммиченным деревом.
Дешёвые гейты (`tsc`, `npm test`, `build+sync+budget`, `check-docs`)
перепрогнаны лично на итоговом SHA `0bd5570a`, а не приняты по цепочке
рассуждений о докс-коммитах — все зелёные, числа совпадают с отчётами автора и
r3. Новых находок нет. Вердикт: зелёный.
+140
View File
@@ -0,0 +1,140 @@
# SPEC-REVIEW-337-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/337
- ТЗ: `docs/specs/337-lazy-editor-chunk.md`, commit `6e7ab6b7` (ветка `issue/337-lazy-editor-chunk`, HEAD detached)
- Трек: полный (обоснование в аналитике: производительность + несколько
поверхностей/public asset contract — критерий §5 назван, `small` корректно отклонён)
- Этап: spec-review, заход r1, блокирующих циклов израсходовано 0/4
- Вердикт: **зелёный**
## Скоуп
Задача переносит View/editor границу frontend в реальную модульную границу
(`src/editors/runtime/`), делает multi-asset build/раздачу/CI/HACS-контракт и
безопасно минифицирует статические CSS-литералы, чтобы initial View graph
уложился в ≤256 000 B gzip. Данные/layout/геометрия/UX/touch policy не
меняются, только: (а) до входа в редактор код редакторов не грузится, (б) при
редкой ошибке загрузки чанка показывается новое локализованное сообщение, а
View остаётся рабочим.
Проверка по `docs/SCOPE.md`: работа не добавляет функциональность — она
инфраструктурно защищает J1/J6 (быстрый рабочий View для kiosk/tablet-персон,
которые являются «the product» по SCOPE) и не задевает ни один пункт
out-of-scope. Отдельный вопрос владельцу не нужен: видимое поведение не
меняется, кроме двух явно описанных добавлений (индикатор ожидания, сообщение
об ошибке), и автор прямо заявил в комментарии, что продуктовых вопросов нет —
это соответствует действительности, разночтений о персоне/сценарии в ТЗ я не
нашёл.
## Как проверялось
Ревью документа на этом этапе не имеет кода для гейтов (typecheck/test/build
неприменимы — commit `6e7ab6b7` меняет только `docs/specs/337-lazy-editor-chunk.md`).
Вместо этого каждое фактическое утверждение ТЗ, которое можно спутать с
непроверенной догадкой, сверено с текущим репозиторием на `HEAD`:
| Утверждение ТЗ | Проверка | Результат |
|---|---|---|
| `dist/houseplan-card.js` 1 353 147 B raw / 378 335 B gzip, `src/houseplan-card.ts` ≈1052 KiB, View+3 редактора в одном классе | `wc -c src/houseplan-card.ts` → 1 071 305 B (22 809 строк) | согласуется с заявленным порядком величины |
| `custom_components/houseplan/__init__.py` раздаёт один точный файл `StaticPathConfig(FRONTEND_URL, ...)` без auth | прочитан файл целиком | подтверждено; новый asset-route не вводит новый класс уязвимости — паритет с текущим |
| GUI config editor (`houseplan-card-editor`) сейчас eager | `grep "import './editor'"` в `houseplan-card.ts:190` — статический импорт | подтверждено, AC11 закрывает реальный источник веса |
| Существующий `mode-transition` CSS-класс переиспользуется под loading surface | найден в `plan.styles.ts`/`houseplan-card.ts`, уже используется как de-emphasis coordinate | правдоподобно, а конкретная реализация прямо отнесена к §18 «принято предположительно» |
| «Старый браузер, способный исполнять текущий ESM bundle, способен исполнять native dynamic import» (§12) | `tsconfig.json` target `es2021`, без даунлевелинга; в коде 811 использований `?.` и 110 `??` — уже требуется ES2020+ движок | **верно**: минимальный движок, способный распарсить нынешний бандл, уже новее движков без `import()` (Chrome 63/FF 67/Safari 11.1) — вычислено, не принято на веру |
| Ручная установка сегодня — это копирование папки `custom_components/houseplan`, а не одного JS (README.md:109-112, USER-GUIDE.ru.md:97-102) | прочитаны оба файла | подтверждено — multi-asset дерево не ломает существующий документированный manual-install путь; отдельный top-level `houseplan-card.js` GitHub-asset никогда не был этим путём |
| `docs/specs/README.md`/PROCESS.md §7.1 обязательные разделы | построчная сверка ТЗ §1–§18 | все присутствуют по содержанию (см. ниже) |
| Трейлеры коммита `6e7ab6b7` | `git log -1 --format=%B` | `Issue: #337` / `User-Visible: no`, корректно — ТЗ не является пользовательским изменением |
| #34 (направление декомпозиции, на которое ссылается §7.1) | `gh issue view 34` | #34 сам в `S3-spec`, ещё не утверждён; его текущий целевой tree (`editors/plan/`, `editors/devices/`, `editors/render/`, …) и «безопасный порядок» (render-слои → диалоги → контроллеры, маленькими слайсами) не совпадают дословно с `editors/runtime/` из #337 |
Дополнительно сверены обязательные разделы ТЗ (`PROCESS.md` §7.1): сценарий (§1)
· что человек видит (§2) · проблема (§3) · скоуп/не-скоуп (§4–§5) · контракт
поведения и UX (§6) · архитектурный контракт (§7) · модель данных и миграция
(§12) · i18n (§11) · AC1…AC13 с доказательством (§13) · план автотестов (§14) ·
риски (§15) · откат (§16) · release-артефакты (§17) · блок «принято
предположительно» (§18). Все присутствуют по существу, не только по названию.
## Находки
Ничего в статусе High или Medium-в-скоупе, требующего возврата автору. Три
наблюдения Low, ревьюер снимает их с записью (правка необязательна, вносить
можно по желанию автора в этом же цикле без отдельного возврата):
1. **`houseplan-space-card` не упомянута в границе runtime.** У неё есть
собственный GUI config editor (`src/space-editor.ts`, статически
импортированный в `space-card.ts:39`, 93 строки/3.3 KB raw). ТЗ §7.1/§6.2.6
описывает границу и AC11 только для `houseplan-card`. Снято: вклад в gzip
пренебрежимо мал (на два порядка меньше бюджета), «Обе карточки в одном
файле» и обе регистрируются надёжно — п.6.1.1 это покрывает; не наблюдается
риска для AC1/AC2. Если авторы решат вынести и его — не помешает, но не
является требованием этого ТЗ.
2. **Число/размер editor-чанков не ограничено.** Формально ТЗ можно закрыть
набором очень мелких чанков, что било бы по числу HTTP-запросов при первом
входе в редактор, хотя раздел «Риски» уже фиксирует намерение «переносить
законченными typed slices». Снято: нет продуктового контракта на задержку
входа в редактор кроме «не выглядит зависшим» (150 ms порог, §15), а именно
этим и ограничен риск.
3. **Имя `src/editors/runtime/` разойдётся с целевым деревом #34** (`editors/plan/`,
`editors/devices/`, `editors/decor/`, `render/`, `dialogs/` — сам #34 ещё в
`S3-spec`, не утверждён). Это два разных среза: #34 — про читаемость и
единственный источник состояния, #337 — про то, что статически не
импортируется в entry; частичное совпадение целей не значит, что нужно
ждать #34. Снято: §18 прямо относит имена внутренних модулей и форму typed
host port к «принято предположительно, можно менять свободно» — реальной
привязки к продукту нет, а сведение выполнимости и проверяемости ТЗ не
страдает.
Ни одна из трёх не задевает выполнимость или проверяемость ни одного AC1…AC13,
поэтому они не поднимаются выше Low и не создают жёлтый вердикт.
## Что проверено и корректно
- Числовой бюджет непротиворечив: «250 KiB» (§4.2) и «256 000 B» (issue,
AC1/§13) — один и тот же порог (250×1024=256000), никакого двойного
источника значения нет.
- Каждый AC1…AC13 имеет явный, разный и проверяемый способ доказательства
(unit/manifest-тест/Playwright-smoke/backend-тест/golden/статический гейт);
ни один не сформулирован как «проверено ревьюером на глаз».
- Не-цели (§5) корректно исключают геометрию, config/layout, `polyclip-ts`,
lazy i18n, backend API/SVG-вычисления и automatic idle-prefetch —
согласуется с `docs/SCOPE.md` (никакой новый функционал сверх заявленной
работы) и не противоречит `docs/CONFIG-COMPATIBILITY.md` (миграции нет,
контракт не сформулирован — не нужен, раз stored data не меняется).
`docs/WALL-THICKNESS.md`/`CANVAS.md` не затронуты (геометрия explicitly
вне скоупа) — глубокая сверка с ними не требовалась.
- §6.4 (failure/retry/fingerprint) и §10 (fingerprint/cache) описывают ровно
одну попытку retry, запрет auto-hard-reload и явную обработку mismatch —
внутренне непротиворечиво и без недосказанных веток.
- Формулировки пользовательских сообщений (§6.2.3, §6.4.2) — новый текст, а не
изобретённая замена существующей терминологии `USER-GUIDE.ru.md`: там нет
готовой фразы под этот сценарий (grep пуст), так что это не нарушение
правила «UI не говорит на языке разработчика».
- Блок «принято предположительно» (§18) корректно отделяет технические решения
(имена модулей, формат manifest, реализация delayed-surface, механизм
инвалидации manifest, gzip-параметры сборки, разбиение на коммиты) от того,
что зафиксировано как продуктовое решение и менять нельзя (URL ресурса,
отсутствие auto-reload, сохранение рабочего View при ошибке, отсутствие
prefetch) — ровно то разделение, которого требует PROCESS.md §7.1.
- Трек и метка (`S4-spec-review`, полный трек) корректны для задачи со
сложностью 9/10 и влиянием на public asset contract.
## Чего не проверял
- Гейты `npx tsc --noEmit` / `npm test` / `npm run build` / `bundle:sync` /
`check-docs.mjs` / инварианты геометрии — **не запускал**: на этом этапе нет
продуктового кода, диапазон коммита r1 — один файл в `docs/specs/`. Они
становятся обязательными на этапе код-ревью (§2.7), когда появится реализация.
- Достижимость бюджета ≤256 000 B самой декомпозицией — это инженерный риск,
который ТЗ само называет негарантированным («если модульная декомпозиция не
доводит граф до бюджета, задача не считается выполненной») и оставляет
измеримым на этапе реализации; ревью ТЗ не может предсказать фактический
результат code-splitting, только то, что критерий сформулирован проверяемо.
- Полное содержание `docs/ARCHITECTURE.md`, `docs/DEVELOPMENT.md`,
`docs/TOUCH-SUPPORT.md` не перечитывалось построчно — сверены только
фрагменты, релевantные конкретным утверждениям ТЗ (раздел «Как
проверялось»); геометрия/touch/config не в скоупе задачи, поэтому глубокого
разбора WALL-THICKNESS.md/CANVAS.md/CONFIG-COMPATIBILITY.md не требовалось.
## Итог
ТЗ выполнимо и проверяемо, разделы полны, продуктовых вопросов владельцу нет и
не появилось при ревью, скоуп соответствует `docs/SCOPE.md`. Три Low-находки
сняты с записью выше. Статус — «Готово к разработке».
+419
View File
@@ -0,0 +1,419 @@
# ТЗ #337 — ленивый editor-runtime и multi-asset frontend
- Issue: https://github.com/Matysh/houseplan-card/issues/337
- Тип: техдолг / производительность
- Приоритет: P2
- Трек: полный — задача влияет на производительность и меняет несколько
поверхностей и release-asset contract, поэтому не проходит критерии `small`
из `PROCESS.md` §5
- Связанные задачи: #34 — направление декомпозиции frontend; #62 — единый
eager-реестр двух текущих языков, lazy i18n в этой задаче отсутствует
## 1. Сценарий
Основная персона — жилец или гость (`docs/SCOPE.md`), который открывает
дашборд Home Assistant на телефоне, планшете или настенной панели, чтобы быстро
увидеть дом и управлять устройствами в View. Он не должен загружать код трёх
администраторских редакторов, пока сам не попросил открыть редактор.
Вторичная персона — владелец/администратор. При первом входе в Plan, Devices
или Background после открытия дашборда он допускает короткое ожидание загрузки,
но не потерю View, текущего пространства, масштаба или незавершённого клика.
## 2. Что человек увидит до и после
До изменения каждый вход на дашборд загружает весь редактор; после изменения
обычный View становится рабочим после заметно меньшей загрузки, а код редактора
подгружается только перед первым фактическим входом в него, без изменения
интерфейса и поведения редакторов.
При редкой ошибке загрузки редактора View остаётся рабочим, а пользователь
видит локализованное сообщение с просьбой обновить страницу вместо зависшей или
наполовину открытой панели.
## 3. Подтверждённое исходное состояние
Замеры выполнены на `origin/dev` `eff786f`, версия 1.68.1:
- `dist/houseplan-card.js` — 1 353 147 B raw / 378 335 B gzip;
- `src/houseplan-card.ts` даёт около 1 052 KiB кода до terser и содержит View,
editor state machines, команды, панели и диалоги в одном классе;
- backend раздаёт только точный файл
`/houseplan_files/houseplan-card.js`; относительный chunk сейчас получит 404;
- bundle sync, freshness, demo, CI и release scripts считают артефактом один
JS-файл;
- консервативная минификация всех текущих статических `css\`\``-литералов
уменьшает bundle примерно на 32 016 B raw, но только на 2 114 B gzip. Она не
заменяет реальное code splitting;
- #62 намеренно сохранила `en` и `ru` синхронными/eager. Утверждение старого
описания #337 о lazy dictionaries неверно и не является предпосылкой ТЗ.
## 4. Цели
1. Не загружать runtime Plan/Devices/Background и editor-only dialogs в первом
рабочем View.
2. Ограничить сумму gzip initial View graph величиной **250 KiB**.
3. Сохранить наблюдаемое поведение View, переходов, редакторов, конфигурации и
Home Assistant card editor.
4. Сделать несколько frontend-файлов полноценным проверяемым артефактом HACS,
backend, demo, CI и release automation.
5. Безопасно минифицировать статические Lit CSS templates без визуальной
разницы.
## 5. Не-цели
- не менять stored config/layout и не добавлять миграцию;
- не менять UX, состав инструментов, DOM-контракт, pointer thresholds,
Undo/Redo, touch policy или геометрию;
- не заменять и не упрощать `polyclip-ts`;
- не делать lazy loading текущих i18n dictionaries;
- не оптимизировать backend API или SVG/Glow вычисления, если они нужны View;
- не считать успехом маленький entry, который тут же синхронно или автоматически
скачивает тот же код до готовности View;
- не делать автоматический idle-prefetch editor runtime: он вернул бы сетевой
расход всем пользователям, которые редактор не открывают.
## 6. Пользовательский контракт
### 6.1 Первый View
1. `houseplan-card` и `houseplan-space-card` регистрируются так же надёжно, как
до изменения; HA не показывает промежуточное `Custom element doesn't exist`.
2. View считается готовым только когда видны план и устройства и доступны
обычные View-действия. Все JS-файлы, обязательные до этого момента, входят в
initial View graph и budget §13.
3. Ни один editor-only asset не запрашивается до намерения пользователя открыть
редактор или editor-only диалог. Hover без клика и простои страницы не
считаются намерением.
4. Пустая новая установка сохраняет текущий onboarding: диалог создания первого
пространства не может зависеть от editor runtime.
### 6.2 Вход в редактор
1. Нажатие Plan, Devices или Background сначала запускает единственный shared
loader. Mode, editor chrome и editor camera не коммитятся до успешной
установки runtime.
2. Повторные/конкурентные клики используют один Promise; runtime не скачивается
и не устанавливается дважды.
3. Пока загрузка занимает меньше 150 ms, отдельная плашка не появляется. После
150 ms поверх неизменившегося View показывается неблокирующий существующий
transition surface с текстом «Загружаем редактор…» / “Loading editor…”.
4. После успеха выполняется обычный переход текущего `mode-transition`; первая
editor-панель не появляется в полуготовом состоянии. Пространство, View zoom
snapshot и selection contracts сохраняются.
5. После первой успешной загрузки все три редактора и editor-only dialogs
используют уже установленный runtime без новой сети.
6. Lovelace GUI editor загружается тем же lazy graph через async
`getConfigElement()`. Создание config editor не должно заставлять View заранее
загружать editor runtime.
### 6.3 Editor-only dialogs из View
К editor-only относятся формы, изменяющие plan/device/decor data и не нужные для
обычного просмотра. Если такая форма вызывается из доступной в View цепочки
(например, Edit из device info), loader завершается до открытия формы. Info,
more-info, подтверждение действия, статусы проёмов, kiosk controls и onboarding
остаются eager, если они нужны View сами по себе.
Точный список экспортов фиксирует `editor-runtime-manifest.ts`; новый editor-only
диалог должен добавляться туда, а не импортироваться из View entry напрямую.
### 6.4 Ошибка загрузки и обновление во время открытой вкладки
1. Первый сетевой/parse/fingerprint failure повторяет import один раз с
versioned cache-buster текущей карточки. До retry View остаётся рабочим.
2. После повторной ошибки mode остаётся `view`, editor session не создаётся,
toolbar/camera/selection не меняются. Показывается локализованное сообщение:
«Не удалось загрузить редактор. Обновите страницу и повторите попытку.» /
“Could not load the editor. Refresh the page and try again.”
3. Runtime сообщает build fingerprint до установки. Несовпадение entry/runtime
рассматривается как тот же failure; код разных версий не смешивается.
4. Автоматический hard reload запрещён: он может прервать другое действие на
HA-дашборде. Пользователь сам решает, когда обновить страницу.
5. Ошибка editor asset никогда не скрывает план и не ломает View-действия.
## 7. Архитектурный контракт frontend
### 7.1 Граница runtime
Создаётся `src/editors/runtime/` с двумя явными сторонами:
- eager `editor-loader.ts`: маленькая state machine `idle → loading → ready |
failed`, dedupe, retry и fingerprint handshake;
- lazy `houseplan-editor-runtime.ts`: composition root редакторов;
- `editor-host-port.ts`: typed минимальный порт к snapshot, командами, save,
translation, requestUpdate и mode transition владельца карточки;
- editor-specific render/controller modules, вынесенные из
`houseplan-card.ts` законченными ответственностями.
Runtime не получает `HouseplanCard` через `any`, не патчит prototype и не читает
произвольные private fields. Он работает через typed host port и собственный
state. Число `any` в `src/` не увеличивается. Pure geometry authorities остаются
в текущих модулях и передаются runtime как обычные imports; второй модели
геометрии не создаётся.
Изменение является крупным independently releasable slice направления #34:
root сохраняет HA lifecycle, View projection/render, server revisions, common
dialogs, navigation и visual continuity; runtime получает editor gestures,
tool state, editor chrome/secondary tray, editor-only drafts/dialogs и команды.
### 7.2 Import graph
- eager entry не имеет static import из `src/editors/runtime/houseplan-editor-runtime.ts`
или editor-only descendants;
- единственное runtime-ребро — `import()` внутри loader;
- общие модули, реально нужные View и editor, остаются eager либо становятся
shared chunk, который входит в initial budget;
- модуль не признаётся editor-only только по имени: Rollup manifest и тест
import graph являются источником истины;
- `polyclip-ts` сохраняется там, куда его помещают реальные потребители.
## 8. Multi-asset build и раздача
### 8.1 Выход Rollup
Rollup пишет дерево:
```text
dist/
houseplan-card.js
houseplan-assets/
<rollup chunks>.js
houseplan-assets.json
```
Имена chunk содержат content hash. Manifest детерминированно содержит:
- source fingerprint;
- entry filename;
- для каждого asset: relative path, SHA-256, raw bytes, gzip bytes;
- static imports и dynamic imports;
- рассчитанные `initialViewGzipBytes` и `lazyEditorGzipBytes`.
Порядок записей и gzip settings фиксированы, поэтому Windows/Linux build одного
SHA создаёт побайтово одинаковые committed snapshots.
### 8.2 Backend route
Точный публичный URL `/houseplan_files/houseplan-card.js` остаётся без изменений.
Chunks раздаются публичным read-only view по
`/houseplan_files/houseplan-assets/{filename}`.
View:
- принимает только один basename без `/`, `\\`, `..` и percent-decoded обходов;
- раздаёт только `.js`, присутствующий в текущем `houseplan-assets.json`;
- разрешает real path только внутри `frontend/houseplan-assets/`;
- возвращает 404 для отсутствующего/неразрешённого asset;
- не требует auth, потому что Lovelace ESM assets должны загружаться до карточки;
- перечитывает/инвалидирует manifest после обновления integration, чтобы config
entry reload не оставлял старый allowlist.
Plans и marker files не возвращаются в public static path; существующий signed
content API не меняется.
### 8.3 Копии и HACS
`bundle:sync` атомарно синхронизирует **всё дерево**, а не один файл:
1. `dist/` — build output;
2. `custom_components/houseplan/frontend/` — committed release snapshot;
3. `demo/srv/assets/` — materialized untracked test copy.
Перед копированием target asset directory очищается только по списку старого
manifest, с path containment check; посторонние файлы не удаляются. После копии
каждый hash сверяется с manifest.
`houseplan.zip` включает entry, manifest и все chunks. Отдельный GitHub asset
`houseplan-card.js` сохраняется как диагностический entry-файл, но каноническая
установка остаётся целым `houseplan.zip`; документация не обещает, что один
скачанный entry без integration assets является самостоятельной установкой.
Release verification проверяет полный manifest внутри zip.
## 9. CSS minification
Build plugin обрабатывает только static Lit `css\`\`` templates без `${…}`:
- удаляет CSS comments вне строк;
- схлопывает ASCII whitespace вне строк;
- удаляет пробелы вокруг безопасной пунктуации, не меняя значения custom
properties, strings, escapes, `url()`, `calc()`, media/container queries и
descendant combinators;
- fail-closed сообщает файл/позицию при interpolation или незакрытой
строке/comment вместо частичной порчи CSS;
- порядок templates и rules не меняется.
Минификация не имеет отдельного продуктового budget: ожидаемая gzip-экономия
мала и учитывается в общем initial View graph.
## 10. Fingerprint и cache contract
1. Source fingerprint продолжает покрывать `src/`, Rollup, TypeScript и lockfile;
manifest несёт то же значение.
2. Entry публикует fingerprint как сейчас. Lazy runtime экспортирует тот же
fingerprint; loader сверяет его до `ready`.
3. Demo freshness проверяет entry и manifest, затем SHA-256 каждого asset.
4. Content-hashed filename предотвращает выдачу нового кода из старого browser
cache. Entry version query продолжает меняться вместе с release version.
5. Старый открытый entry после обновления может запросить удалённый hash; это
штатный failure §6.4, а не основание хранить бесконечно старые chunks.
## 11. I18n и документация
Добавляются четыре ключа (RU/EN): loading editor, load failed, refresh advice и
доступное имя busy-state. Остальные подписи не меняются.
Обновляются:
- `docs/ARCHITECTURE.md` — multi-asset graph, runtime boundary и public routes;
- `docs/DEVELOPMENT.md` — build/sync/deploy всего дерева, проверка manifest;
- `docs/USER-GUIDE.ru.md` и README RU/EN — прежний resource URL сохраняется,
но frontend состоит из entry и внутренних assets; ручное копирование одного
JS не является поддерживаемой установкой;
- `docs/TESTING.md`/релевантный testing guide — сценарий failure и network gate;
- оба changelog — изменение пользовательски заметно по скорости и редкому
сообщению ошибки.
## 12. Совместимость и миграция
Stored data не меняется; миграции нет. Старый браузер, способный исполнять
текущий ESM bundle, способен исполнять native dynamic import. При отсутствии
assets обновлённый entry сохраняет View и выдаёт §6.4.
Обновление integration требует полный HACS zip и обычный restart/reload HA.
Новый backend с новым frontend является штатной парой; mixed-version runtime
блокируется fingerprint handshake.
## 13. Критерии приёмки
**AC1. Честный initial budget.** На production build сумма gzip entry и всех
транзитивных static imports до рабочего первого View ≤ 256 000 B; dynamic editor
assets не учитываются только если browser smoke подтверждает, что они не
запрошены до editor intent. **Доказательство:** `bundle-budget` unit + CI command
с manifest + Playwright network smoke.
**AC2. Реальная lazy boundary.** Initial import graph не содержит editor runtime,
editor toolbar/gesture/dialog modules и GUI config editor; первый вход в любой
из трёх редакторов загружает один deduplicated editor graph. **Доказательство:**
manifest/import-graph unit и Playwright network smoke.
**AC3. View parity.** Configured View, kiosk, touch View, device actions,
Glow/sun/vacuum, openings, room hover, space switching and visual continuity
проходят без golden delta. **Доказательство:** связанные smokes + полный golden
verify на pre-release gate; на code-review — selected smokes из registry.
**AC4. Editor parity.** Plan, Devices и Background открываются после cold load;
основные select/draw/save/undo flows и editor-to-editor transitions проходят
существующие smokes без изменения ожидаемого DOM/данных. **Доказательство:**
selected editor smokes и unit tests controller/loader.
**AC5. Loader atomicity.** Двойной клик/конкурентные запросы выполняют один
import/install; mode и camera меняются только после ready. **Доказательство:**
unit с controllable Promise + browser smoke.
**AC6. Failure сохраняет View.** Первый 404/parse/fingerprint failure retry-ится
один раз; второй оставляет mode=view, план интерактивным и показывает
локализованное сообщение. **Доказательство:** unit loader state machine +
Playwright route abort/mismatch scenarios RU/EN.
**AC7. Asset security.** Backend отдаёт только manifest-listed JS из asset root и
отказывает traversal, encoded traversal, nested path, unknown extension и stale
asset. **Доказательство:** HA backend tests.
**AC8. Полнота distribution.** Build/sync/zip/release verification падают при
удалении или подмене любого manifest asset; dist, integration и demo после sync
побайтово совпадают по manifest. **Доказательство:** Node release-contract,
bundle-sync и freshness tests + zip test.
**AC9. CSS без семантической порчи.** Plugin корректно сохраняет strings,
escapes, URLs, custom properties, calc/media/container и combinators, умеет
падать на interpolation/malformed input; computed styles выбранных компонентов
и golden images не меняются. **Доказательство:** unit с adversarial fixtures +
computed-style smoke + golden verify.
**AC10. Fingerprint mismatch не смешивает версии.** Runtime с другим fingerprint
не устанавливается и проходит failure contract. **Доказательство:** unit +
browser injected mismatch.
**AC11. Onboarding и GUI editor.** Empty-config onboarding работает без editor
asset; async `getConfigElement()` загружает editor graph и возвращает прежний
custom element/config contract. **Доказательство:** browser smokes обоих путей.
**AC12. Без model drift.** No-op config/layout roundtrip и backend validation не
изменяют данные; `any` count в `src/` не растёт; второй geometry authority не
появляется. **Доказательство:** existing roundtrip/schema tests + static gate.
**AC13. Документация не обещает single-file install.** Resource URL остаётся
прежним, а install/deploy docs требуют целое asset tree. **Доказательство:**
`check-docs` и ревью текста.
## 14. План автотестов и гейты реализации
На каждом implementation slice:
```text
npm run typecheck
npm test
npm run build
npm run bundle:sync
node scripts/check-docs.mjs
node scripts/smoke-select.mjs --base origin/dev --head HEAD
<все smokes, выбранные registry и перечисленные AC>
git diff --check
```
Перед code review дополнительно:
- backend pure tests и полный HA subset в WSL/CI для frontend asset view;
- new network/failure/editor cold-load smoke;
- `npm run inventory`;
- hashes полного asset tree;
- `npm run golden:verify` как диагностический локальный прогон; канонический
full golden/performance остаётся pre-beta CI по `PROCESS.md`.
Mutation guards обязаны уметь сломать: dynamic boundary, retry cap, fingerprint
check, manifest allowlist, omitted zip chunk, CSS string/comment handling и
initial budget.
## 15. Риски и меры
| Риск | Мера |
|---|---|
| Big-bang перенос editor state ломает жесты | переносить законченными typed slices; одинаковые smokes до/после каждого slice; без новой geometry model |
| Chunk доступен в demo, но 404 в HA | production backend route + HA harness test; smoke использует production URL layout |
| Entry мал, но eager shared chunk возвращает вес | budget следует transitive static graph из manifest и подтверждается network trace |
| Update смешивает версии | content hash + source fingerprint handshake; отказ до install |
| Сломан ручной deploy | sync/deploy docs и script работают с tree, не с одним `scp` |
| CSS whitespace меняет selector/value | token-aware conservative transform, adversarial tests, computed style + golden |
| Release zip неполон | manifest-driven zip validation и публичный asset verification |
| First editor click выглядит зависшим | 150 ms delayed loading surface, View остаётся на месте |
## 16. Откат
Откат выполняется одним release commit к предыдущему monolithic Rollup output,
точному static route и single-file bundle tooling. Stored config/layout не
менялись, поэтому data rollback не нужен. Backend не удаляет старый entry URL;
после отката новые chunk routes становятся неиспользуемыми.
## 17. Release-артефакты
- production entry + manifest + chunk tree в `dist/` и integration snapshot;
- обновлённый `houseplan.zip` с полным tree;
- прежний top-level GitHub `houseplan-card.js` entry asset;
- RU/EN changelog со ссылкой на #337;
- architecture/development/user docs;
- новые unit/backend/browser tests; golden baselines не меняются без отдельного
reviewed acceptance.
## 18. Принято предположительно, можно менять без владельца
- имена внутренних modules/chunks и форма typed host port;
- формат manifest, если он остаётся детерминированным и доказывает все AC;
- точная реализация delayed loading surface поверх существующей transition UI;
- механизм перечитывания manifest backend view;
- gzip implementation и fixed compression level в budget script;
- разбиение editor extraction на внутренние commits/slices.
Нельзя менять без продуктового решения владельца: прежний resource URL,
отсутствие автоматического hard reload, сохранение рабочего View при failure и
отсутствие заранее загружаемого editor prefetch.
+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 };
},
};
}
+108 -10
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'
+ ' };',
@@ -3244,9 +3330,9 @@ export const MUTANTS = [
because: 'Plan architecture ink is visual chrome; leaving the wall outline at raw SVG units '
+ 'makes it five times thinner on a 1 cm grid (#239)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' stroke="${stroke}" stroke-width="${gridVisualUnits(0.6, this._cellCm)}"',
replace: ' stroke="${stroke}" stroke-width="0.6"',
file: 'src/styles/plan.styles.ts',
find: ' stroke-width: calc(0.6px * var(--hp-cell-visual-scale, 1));',
replace: ' stroke-width: 0.6px;',
}],
},
{
@@ -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);
}
}
+784 -10775
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);
+16 -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,24 @@ 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();
const eagerCardSource = readFileSync(
new URL('../src/houseplan-card.ts', import.meta.url),
'utf8',
);
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(
eagerCardSource,
/const candidate = canonicalizeConfigGeometry\(this\._serverCfg\);/,
'the eager View writer keeps the same canonicalization barrier as the lazy editor writer',
);
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', () => {
+139
View File
@@ -0,0 +1,139 @@
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}`);
});
test('source type references stay portable across build hosts', () => {
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'));
assert.doesNotMatch(
sources.join('\n'),
/import\(["'][A-Za-z]:[\\/]/,
'type imports must not capture an absolute Windows workspace path',
);
});
test('lazy runtime event handlers keep the runtime receiver', () => {
const runtime = readFileSync(join(repoRoot, 'src', 'houseplan-editor-runtime.ts'), 'utf8');
const bareHandlers = [...runtime.matchAll(/@[a-zA-Z-]+=\$\{this\.(_[A-Za-z0-9]+)\}/g)]
.map((match) => match[1]);
assert.deepEqual(
bareHandlers,
[],
`Lit binds bare listeners to the host element, not the lazy runtime: ${bareHandlers.join(', ')}`,
);
});
test('documentation capture materializes the complete bundle tree', () => {
const capture = readFileSync(join(repoRoot, 'demo', 'docs', 'capture.mjs'), 'utf8');
assert.match(capture, /import ['"]\.\.\/\.\.\/scripts\/bundle-sync\.mjs['"]/);
assert.doesNotMatch(capture, /copyFileSync\(BUNDLE,\s*DEMO_BUNDLE\)/);
});
+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);

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