mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Волна 3 эпика #674. ARCHITECTURE.md 2330 → 653 строки: система координат прототипа 1489×1053, разделы Hidden Isometric Stage 2/4 (текущее — ISOMETRIC.md, история — ADR 122/570), хроники «Additions v1.28–v1.41» и «Audit follow-ups» сняты; подсистемы — короткое описание со ссылкой на канонический документ. Устаревшее исправлено по коду: дерево Layout (store.py, frontend_registration, весь список модулей), хранилища (config, layout, virtual_lights, trails), ленивые локали de и fr, таблица WS API (31 + 3 команды, #256-проекция, space/delete, files/cleanup без keep, контент через /api/houseplan/content), DevItem без несуществующих полей, отказ help/feedback по support_api. Всё ещё верное и не записанное в другом месте перенесено в канонические документы: DEVICE-PRESENTATION (заметки реализации, Action authority, черновик диалога), RADAR (карта реализации), VACUUM (владение кодом), CANVAS (icon_size, --hp-cell-visual-scale, барьер записи координат), FILTERING (#44, каталог), DECOR-EDITOR §7 (инварианты бэкенда ассетов), WALL-THICKNESS (§1 идентичность и нулевые стены, §2 кэши, §4 hover/туннели/острова, §6 удаление комнаты, §9 failed-core, §11 завершение цепочки и комната по грани), ISOMETRIC (isoPlaneMatrix, iso-overlays, створки, служебные атрибуты), LIGHT (Glow над заливкой #55, формула и screen-смешение), CONFIG-COMPATIBILITY (манифест схемы #33, Masonry-слоты #561, квадратный холст v1.48, устаревшие URL контента, layout/set #356), SCOPE (таблица владельцев при сборке файлов), WARM-REMOUNT §5 (холодная загрузка и визуальная непрерывность), UX-MODES (порядок стартового пространства), PDF-EXPORT (граница реализации), FURNITURE (adopt, BOOT_MAX_MS). Раздел TESTING «Backend quality gates (#42)» и строка DEVELOPMENT о bundle-freshness — из того же разбора, в предыдущем коммите. Issue: #680 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
300 lines
28 KiB
Markdown
300 lines
28 KiB
Markdown
# Тёплый ре-маунт: возврат на вкладку без «перезагрузки»
|
||
|
||
> Код: `src/houseplan-card.ts`,
|
||
> модульная памятка `warmBoot`. Смоки: `demo/smoke_warm_remount.mjs`,
|
||
> `demo/smoke_warm_dialogs.mjs`, `demo/smoke_warm_owners.mjs` (владение слотами)
|
||
> и `demo/smoke_nav_persist.mjs` (постоянно хранится только пространство).
|
||
|
||
## 1. Что вообще происходит
|
||
|
||
Lovelace **пересоздаёт элемент карточки** — выбрасывает старый DOM-элемент и
|
||
создаёт новый — когда websocket переподключается после долго свёрнутой
|
||
вкладки, при перестройке дашборда и при переключении видов. Страница при этом
|
||
никуда не девается, но экземпляр карточки умирает, и вместе с ним умирает
|
||
**всё, что жило в полях экземпляра**.
|
||
|
||
Для владельца это выглядит как «карточка перезагрузилась»:
|
||
|
||
* мигал прелоадер (закрыто DEV-B703-01);
|
||
* «чуть-чуть дёргается масштаб» (DEV-B703-03, §2);
|
||
* **исчезают открытые диалоговые окна** (DEV-B703-03, §3) — это и есть прямое
|
||
доказательство пересоздания: состояние диалога больше нигде не хранится.
|
||
|
||
Лекарство одно и то же: **модульная памятка** `warmBoot` — обычный `Map`,
|
||
который живёт в загруженном JS-модуле, а не в экземпляре. Ключ:
|
||
|
||
```
|
||
`${window.innerWidth}x${window.innerHeight}|${location.pathname}|${JSON.stringify(config)}`
|
||
```
|
||
|
||
Смена размера окна → промах → полный защитный бут (единственный случай, когда
|
||
хром HA может реально пересобраться заново). `location.pathname` — это дашборд
|
||
И путь вида: две вкладки Lovelace никогда не делят ключ, поэтому карточка,
|
||
открытая на другом виде, стартует холодной, а не с чужим вьюпортом. Хеш в ключ
|
||
НЕ входит: `#space=` — наш собственный диплинк, а не другое размещение.
|
||
|
||
Памятка не является постоянным хранилищем режима. С issue #93 уход на другой
|
||
маршрут Home Assistant завершает редакторскую сессию: перед отсоединением слот
|
||
запечатывается безопасным состоянием View без диалога и editor fingerprint.
|
||
При возврате сохраняется пространство, но не редактор. Полный viewport и
|
||
черновик переносятся только при техническом ре-маунте **на том же маршруте**.
|
||
|
||
Одинаковый конфиг дважды на одном виде ключ по-прежнему не различает — этим и
|
||
занимается §1.1.
|
||
|
||
### 1.1. Одна запись — одно РАЗМЕЩЕНИЕ карточки (AUD-159B1-01)
|
||
|
||
Пока в памятке лежали два числа (высота шапки и высота сцены), «две одинаковые
|
||
карточки неразличимы» было косметикой: высоты у них всё равно совпадают. Как
|
||
только в записи появились вьюпорт и диалог, ограничение стало функциональным —
|
||
аудит v1.59.0-beta.1 воспроизвёл это так:
|
||
|
||
1. A: вид, зум 2.15, открытый диалог пространства с черновиком;
|
||
2. B (тот же конфиг): «Устройства», зум 3.35 — последний писатель общей записи;
|
||
3. Lovelace создаёт замену A′ **до** того, как отсоединит A;
|
||
4. A′ усыновляет вьюпорт B, а черновик A потом отбраковывается проверкой
|
||
«тот же режим» — пользователь видит чужой экран и теряет несохранённое.
|
||
|
||
Поэтому значение ключа теперь **список слотов**, по одному на каждое
|
||
размещение карточки. В слоте, кроме геометрии/вьюпорта/диалога, лежат:
|
||
|
||
* `owner` — поколение живого экземпляра, сидящего в слоте;
|
||
* `place` — **WeakRef** на родительский элемент, в который карточка была
|
||
вставлена (слабая ссылка: памятка не имеет права держать DOM живым);
|
||
* `idx` — позиция карточки среди детей этого родителя;
|
||
* `live` — сидит ли в слоте кто-то живой.
|
||
|
||
Слот занимается не в `setConfig` (Lovelace зовёт его **до** вставки элемента,
|
||
размещения ещё не существует), а в `connectedCallback` — всё ещё до первого
|
||
рендера, поэтому вуаль не мигает. Кандидаты ранжируются:
|
||
|
||
| Оценка | Что это | Усыновляем |
|
||
|---|---|---|
|
||
| 4 | тот же родитель и тот же индекс — буквально наш DOM-слот; именно так Lovelace подменяет карточку (предшественник может быть ещё жив) | да |
|
||
| 0 | **другое** размещение, владелец которого жив — соседняя карточка | никогда |
|
||
| 3 | тот же родитель, индекс сдвинулся, владелец ушёл | да |
|
||
| 2 | надгробие, чьё размещение исчезло вместе с поддеревом — обычная перестройка Lovelace | да |
|
||
|
||
Ничья (два одинаково правдоподобных кандидата) означает: усыновляется **только
|
||
осевшая высота шапки** — она у одинаковых карточек и так одинаковая, — но не
|
||
вьюпорт и не диалог. Это худший случай: карточка открывается без вуали, но со
|
||
своим собственным видом.
|
||
|
||
Диалог воскрешается **из своего слота**, а не «из записи по ключу», поэтому
|
||
чужой черновик физически недостижим.
|
||
|
||
### 1.2. Черновик едет по цепочке (AUD-159B1-02)
|
||
|
||
`_warmSnapshot()` не трогает `dlg`, пока у экземпляра поднят
|
||
`_warmRevivePending` — «я ещё не забрал диалог предшественника». Раньше
|
||
`disconnectedCallback()` сбрасывал этот флаг **до** снимка, и промежуточный
|
||
экземпляр в цепочке A→B→C записывал `dlg: null` поверх чужого черновика:
|
||
двойное пересоздание в одном такте теряло несохранённый ввод. Теперь снимок
|
||
делается **первым**, и черновик просто едет дальше по цепочке, пока кто-нибудь
|
||
не проживёт достаточно долго, чтобы его открыть. Регрессия:
|
||
`demo/smoke_warm_owners.mjs`, секция B.
|
||
|
||
### 1.3. TTL освобождает payload, а не только запрещает воскрешение (AUD-159B1-03)
|
||
|
||
`WARM_REVIVE_MS` (10 c) был правилом, которое проверялось только в момент
|
||
воскрешения. Запись при этом продолжала держать объект диалога — а у диалога
|
||
пространства это **целый файл плана в base64** (бэкенд разрешает 8 MiB). На
|
||
настенном планшете, который не перезагружают неделями, это десятки мегабайт
|
||
бесполезного удержания. Теперь отсоединение заводит защищённый таймер: через
|
||
`WARM_REVIVE_MS` он обнуляет `dlg`, если у слота та же метка `freed` и тот же
|
||
владелец (успел прийти преемник — таймер молчит). Высота и вьюпорт остаются:
|
||
это байты, и именно они делают следующий маунт тёплым. Заодно опустевший слот
|
||
удаляется из списка, если он там не последний — чтобы следующее усыновление
|
||
снова было однозначным.
|
||
|
||
TTL является также независимой от порядка событий защитой редакторской
|
||
сессии. После истечения окна слот переводит сохранённый viewport в `view`,
|
||
сбрасывает editor-only выделения, инструмент и fingerprint. Поэтому поздний
|
||
`location-changed`, отсоединение старого дерева и возвращение на прежний
|
||
маршрут не могут воскресить редактор. Каждый слот дополнительно хранит
|
||
`location.pathname`; viewport принимается только на том же маршруте.
|
||
|
||
## 2. Рывок масштаба: фактическая причина
|
||
|
||
Памятка хранила только `hdrH`/`stageH`. Этого мало, потому что:
|
||
|
||
1. **Пан (`_view`) не переживал экземпляр вообще.** Единственным, что
|
||
восстанавливалось, был зум — из `localStorage` (`LS_ZOOM`, per space).
|
||
Новый экземпляр приходил с `_view = null`, `updated()` → `_refitView()`, а
|
||
затем `_loadFromServer()` звал `_restoreZoom()`, который **центрирует план**
|
||
(`_applyView(z, центр vb)`). Вид, отъеханный в угол, возвращался в центр.
|
||
Измерено смоком: до пересоздания `view.x=50, y=493`, после — `x=250, y=293`
|
||
при zoom 2.2.
|
||
2. **Зум редактора не сохраняется намеренно** (`_saveZoom()` выходит при
|
||
`_mode !== 'view'`: рабочий зум редактора — инструмент, а не «как я хочу
|
||
смотреть»). Исторически `LS_NAV` сохранял и режим, поэтому ре-маунт внутри
|
||
редактора возвращался в тот же редактор, но на зуме ПРОСМОТРА: измерено
|
||
3.0 → 1.0. Теперь `LS_NAV` хранит только пространство; тот же редактор и его
|
||
viewport переносит лишь same-route warm memo.
|
||
3. `_viewModeSnap` (виджет «куда вернуться при выходе из редактора») умирал
|
||
вместе с экземпляром — выход из редактора после ре-маунта прыгал второй раз.
|
||
4. `_showFar` («показать дальние объекты») меняет `_baseVb()`, то есть саму
|
||
систему координат, против которой клампится вид.
|
||
|
||
**Правило для технического ре-маунта на том же маршруте:** памятка хранит не
|
||
«зум», а **весь вьюпорт**: пространство, режим, зум, сам прямоугольник `_view`, `_viewModeSnap`,
|
||
`_showFar`, а также инструмент редактора (`_tool`, `_decorTool`), выделение
|
||
(`_selId`, `_rszSel`, `_decorSel`) и локальный переключатель «показать
|
||
скрытые» (`_showHidden`). Восстановление даёт **бит-в-бит тот же прямоугольник**,
|
||
а не «тот же зум». `_loadFromServer()` в этом случае **не зовёт**
|
||
`_restoreZoom()` — центрирующее восстановление осталось только для настоящей
|
||
навигации (хеш/`LS_NAV` привели на другое пространство).
|
||
|
||
Явная команда пользователя всегда сильнее ещё не применённой памятки. Пока
|
||
backend уточняет `can_write`, старый DOM редактора может оставаться видимым, а
|
||
новый экземпляр уже fail-closed находится в Просмотре и держит режим только в
|
||
`_pendingNavMode`. Любой вызов `_setMode()`, включая повторный `view` от
|
||
видимого крестика, сначала отменяет этот pending: поздний ответ сервера не
|
||
имеет права снова открыть уже закрытый редактор (#95).
|
||
|
||
Диплинк `#space=<id>` — явная навигация и по-прежнему сильнее памятки. Он
|
||
выбирает пространство, но, как и обычный возврат на карточку, не включает
|
||
редактор.
|
||
|
||
Памятка обновляется **на каждом `updated()`** (метод `_warmSnapshot()`), то
|
||
есть всегда отражает последний ОТРИСОВАННЫЙ кадр. Родиться запись может только
|
||
из осевшей геометрии (`_bootSettled`), поэтому холодный бут по-прежнему платит
|
||
полный защитный цикл.
|
||
|
||
### 2.1. Возврат после долгого сна вкладки
|
||
|
||
Тёплый viewport решает пересоздание экземпляра, но после долгого background
|
||
браузер, HA chrome и websocket могут проснуться в разных кадрах. В этот момент
|
||
`ResizeObserver` способен выдать нулевой или промежуточный размер сцены. Такой
|
||
размер больше никогда не меняет `_view`.
|
||
|
||
Порог 15 секунд теперь только отмечает возможный browser freeze. Быстрый
|
||
возврат — строгий no-op: не меняются token, DOM, viewport, day/night-фон и
|
||
hover. После долгого сна общий `VisualContinuityController` удерживает на
|
||
экране последний полноценный кадр, параллельно перепроверяет config/layout и
|
||
принимает только подтверждённый положительный размер сцены. Класса
|
||
`hpresume` и скрытия `.zoomwrap` больше нет ни в Просмотре, ни в редакторах,
|
||
ни в киоске.
|
||
|
||
Памятка `warmBoot` не стала вторым lifecycle-механизмом: она по-прежнему
|
||
принадлежит placement и теперь дополнительно переносит fingerprint последнего
|
||
полного кадра и компактный roster устройств. Подписи и readiness загруженных
|
||
подложек принадлежат общему cache соединения, поэтому новый экземпляр может
|
||
нарисовать защищённую подложку в первом кадре. Промежуточный `0×0` размер,
|
||
неполный candidate и новая подписанная ссылка до decode в памятку не попадают.
|
||
|
||
Толстые стены дополнительно имеют критические `fill`, `stroke`, opacity и
|
||
`fill-rule` как SVG presentation attributes. Поэтому даже промежуточный кадр
|
||
нового DOM-экземпляра не может получить браузерную заливку `black` до того,
|
||
как применятся стили компонента; CSS hooks при этом сохранены и по обычному
|
||
каскаду остаются сильнее presentation attributes.
|
||
|
||
## 3. Диалоги переживают пересоздание
|
||
|
||
В той же записи памятки лежит `dlg`: `{ kind, space, mode, data }`, где
|
||
`data` — **живой объект-черновик**. Памятка — состояние модуля, её никто не
|
||
сериализует, поэтому наполовину заполненный диалог устройства вместе с
|
||
загруженными PDF переезжает бесплатно и без потерь.
|
||
|
||
Восстановление (`_warmReviveDialog`) происходит **не в `setConfig`**, а на
|
||
следующий такт после `connectedCallback`: Lovelace в момент `setConfig` может
|
||
ещё держать старый элемент, а живому владельцу диалог красть нельзя.
|
||
|
||
Условия воскрешения — все обязательны:
|
||
|
||
1. запись памятки есть и маршрут не менялся (технический тёплый ре-маунт, а
|
||
не холодный старт или возврат с другой страницы HA);
|
||
2. `dlg` не пуст — то есть в **последнем отрисованном кадре** прошлого
|
||
экземпляра диалог был открыт;
|
||
3. прошлый экземпляр **отсоединился** (`freed`), и с этого момента прошло не
|
||
больше `WARM_REVIVE_MS` (10 c). Перестройка Lovelace укладывается в один
|
||
такт; затянувшийся технический ре-маунт не должен возвращать диалог, о
|
||
котором пользователь уже забыл;
|
||
4. **то же пространство и тот же режим** (`d.space`/`d.mode`);
|
||
5. **одноразово**: `dlg` съедается в момент попытки восстановления. Третий
|
||
экземпляр диалог уже не увидит, зомби невозможен.
|
||
|
||
Осознанное закрытие (Esc / Отмена / Сохранить) чистить памятку отдельно **не
|
||
нужно**: следующий же `updated()` запишет `dlg: null`. Это сильнее ручной
|
||
чистки — ни один путь закрытия нельзя забыть.
|
||
|
||
Уход на другой маршрут HA сильнее TTL и всех условий выше: editor/dialog
|
||
сбрасываются немедленно и при возврате не воскрешаются даже в пределах 10 с.
|
||
|
||
### 3.1. Что восстанавливается с черновиком
|
||
|
||
`_spaceDialog` (настройки/создание пространства), `_markerDialog` (устройство),
|
||
`_settingsDialog` (общие настройки), `_rulesDialog` (правила иконок),
|
||
`_openingDialog` (проём), `_decorTextDialog` (надпись), `_decorShapeDialog`
|
||
(полные свойства объекта подложки), `_backdropDialog` (числовая трансформация
|
||
картинки), `_roomDialog` вместе со всей своей
|
||
обвязкой (`_roomEditId`, `_roomFill`, источники температуры и влажности,
|
||
масштабы подписей, `_areaSel`/`_nameSel`, `_pendingSplit`, `_path`).
|
||
|
||
Информационные попапы — `_infoCard` (карточка устройства) и `_openingInfo` —
|
||
восстанавливаются **по id**, а не по объекту: конфиг мог перезагрузиться под
|
||
нами, и карточка, отрисованная из устаревшего объекта, была бы враньём. Если
|
||
объекта с таким id больше нет — попап просто не открывается.
|
||
|
||
### 3.2. Что НЕ восстанавливается — и почему
|
||
|
||
| Не воскрешаем | Причина |
|
||
|---|---|
|
||
| «**Оптимизировать планы**» (`_alignDialog`, `CONFIG-COMPATIBILITY.md` › Optimize plans) | Модалка, всё содержимое которой — «нажмите OK, и я перепишу ваш план». Воскресить подтверждение рядом с человеком, который только что вернулся на вкладку, — прямой путь к слепому клику по разрушающей записи. Открывается одним нажатием из общих настроек. |
|
||
| Подтверждение **объединения комнат** (`_mergeDialog`) | Тот же класс: подтверждение необратимой правки геометрии. |
|
||
| Подтверждение действия по тапу (`_tapConfirm`) | Держит замыкание `exec` на **мёртвый** экземпляр. |
|
||
| Мастер импорта этажей (`_importDialog`) | `updated()` сам открывает его, пока конфиг пуст; воскрешение удвоило бы очередь. |
|
||
| Любой диалог с `busy: true` | Запись/загрузка была в полёте. Новый экземпляр не знает, доехала ли она; показать «Сохранить» ещё раз — это приглашение записать дважды. Правду покажет перезагрузка конфига. |
|
||
| Киоск | У киоска памятки нет вообще (`100dvh`, ничего не оседает, редактирование недоступно). |
|
||
|
||
Правило одной строкой: **воскрешаем черновик, не воскрешаем решение.**
|
||
|
||
## 4. Что осознанно отложено
|
||
|
||
Ре-маунт по-прежнему теряет мелкое промежуточное состояние жестов, у которого
|
||
нет осмысленного «продолжения» после смены экземпляра: незавершённый контур
|
||
рисования вне диалога комнаты (`_path`/`_cursorPt`), общий сессионный стек
|
||
геометрических команд (`_geometryHistory`), выбор разреза (`_splitSel`, `_mergeSel`), черновик фигуры декора
|
||
(`_decorDraft`), незавершённое перетаскивание подложки (`_bdDrag`), тост
|
||
(`_toast`). Все они живут внутри одного жеста; пересоздание элемента жест и так
|
||
прерывает.
|
||
|
||
## 5. Холодная загрузка и визуальная непрерывность (#73, #131)
|
||
|
||
`src/visual-continuity.ts` владеет одним машинным состоянием с токенами, общим
|
||
для полной и статической карточек. Готовый кадр остаётся на экране через
|
||
возобновление, переподключение, структурную перепроверку и изменения размера с
|
||
положительной площадью; наблюдения `0×0` viewport не меняют. Config и layout
|
||
несут независимые идентичности «ревизия + отпечаток содержимого»: эхо, где
|
||
сменилась только ревизия, сохраняет авторитетные объекты и кэши геометрии, а
|
||
изменённое содержимое не спрячется за равной ревизией. Кандидат готов только
|
||
после того, как Lit осел, обязательные подписанные ресурсы загрузились и для его
|
||
токена прошли две возможности кадра анимации; `data-continuity-state`,
|
||
`data-continuity-token`, `data-frame-fingerprint` и условный
|
||
`data-recovery-reason` показывают это без entity id и URL. Рантайм подписанных
|
||
ресурсов привязан к `hass.connection`, ограничен и общий для размещений, поэтому
|
||
тёплый ре-маунт может синхронно переиспользовать загруженную подложку;
|
||
обновление — stale-while-decode. Только когда сохранить нечего — ни готового, ни
|
||
устаревшего кадра, — через 150 мс появляется локализованный непрозрачный оверлей
|
||
восстановления; он никогда не забирает начальный фокус и, пока виден, делает
|
||
сцену `inert`.
|
||
|
||
Обязательная загрузка заканчивается, когда `houseplan-card` принял config и
|
||
layout, построил модель, выбрал одно точное пространство, закэшировал снимок и
|
||
восстановил viewport. Подписки на config, след и layout стартуют после этого
|
||
вместе, как независимые необязательные обогащения: отказ одного канала не
|
||
блокирует остальные, не стирает снимок и не планирует повтор полной загрузки;
|
||
недостающие каналы повторяются при следующей загрузке или переподключении.
|
||
|
||
`src/initial-load.ts` — единственный источник решения о пространстве
|
||
закэшированного снимка, живого снимка и его кандидата защищённой подложки.
|
||
`floor` в конфиге карточки абсолютен (`resolveFixedFloor()`: строка — точный id,
|
||
конечное неотрицательное целое — индекс с нуля по серверу); неверные значения
|
||
отказывают закрыто, числовой индекс ждёт свежую серверную модель до первого
|
||
пространственного кадра, а закреплённый экземпляр никогда не читает и не пишет
|
||
`houseplan_card_nav_v1` — через этот гард проходит каждый переход `_space`. Без
|
||
`floor` холодная загрузка перебирает валидные id в порядке: хеш URL, сохранённая
|
||
навигация, `default_floor`, первое живое пространство; после того как начальный
|
||
хеш использован, валидный выбор в том же маршруте сохраняется; план без
|
||
пространств оставляет авторитет `null`.
|