diff --git a/docs/specs/337-lazy-editor-chunk.md b/docs/specs/337-lazy-editor-chunk.md new file mode 100644 index 00000000..72784e19 --- /dev/null +++ b/docs/specs/337-lazy-editor-chunk.md @@ -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/ + .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.