Files
houseplan-card/docs/superpowers/specs/2026-08-10-plan-visual-continuity-design.md

71 KiB
Raw Permalink Blame History

ТЗ: визуальная непрерывность плана при возврате на вкладку

Issue: #73 — Исключить мигание плана при возврате на вкладку
Статус: ТЗ принято после двух раундов ревью; implementation gate подтверждён публикацией v1.61.0-beta.6 на SHA d2bc9082807452c88e735c72d4a06d1ff873eaa9; локальная реализация начата 2026-08-11 по прямому поручению владельца
Область: houseplan-card, houseplan-space-card, lifecycle браузера и Home Assistant
Модель данных: без изменений
Backend API: без обязательных изменений

1. Краткое решение

House Plan должен сохранять на экране последний полностью собранный кадр плана, пока новый кадр не готов целиком. При возврате на вкладку запрещено заранее скрывать сцену, сбрасывать viewport, фон, Glow или геометрию.

Допускается кратковременно показывать последнее известное состояние устройств. Визуальная непрерывность имеет приоритет над мгновенным отображением свежего HA-state. Новые состояния устройств, конфигурация, layout, viewport и защищённые изображения применяются атомарно — без промежуточных кадров.

Непрозрачный слой восстановления является аварийным fallback, а не штатной анимацией возврата. Он появляется только тогда, когда сохранить предыдущий полноценный кадр невозможно, и только после короткой задержки, чтобы сам слой не стал новым источником мигания.

2. Проблема

После возврата на вкладку, восстановления свёрнутого браузера или переподключения Home Assistant пользователь может увидеть один или несколько артефактов:

  • план исчезает и появляется снова;
  • на мгновение меняется масштаб или центр;
  • контур дома временно становится чёрным;
  • пропадает и возвращается подложка;
  • Glow меняет яркость или режим смешивания;
  • разные слои плана восстанавливаются в разные кадры;
  • артефакт возникает даже после короткого ухода с вкладки.

Предыдущие правки защищали отдельные симптомы, однако сформировали несколько несвязанных lifecycle-механизмов. Каждый из них локально оправдан, но совместно они не гарантируют целостный кадр.

3. Результаты исследования

3.1. Принудительное скрытие сцены

После длительного background текущий _beginResumeSettle() в обычном режиме просмотра включает _resumeSettling, а класс hpresume задаёт visibility: hidden для .zoomwrap и связанных элементов на 220–750 мс. Это гарантированно создаёт исчезновение плана даже тогда, когда предыдущий кадр корректен и размеры сцены не изменились.

В kiosk и редакторах _beginResumeSettle() и _resumeSettleTick завершаются до включения hpresume: принудительного скрытия там уже нет. Контракт #73 всё равно распространяется на эти режимы, но задача в них другая — сохранять фон, Glow, device snapshot, viewport и защищённые ресурсы при reconnect/remount, а не удалять несуществующий veil.

3.2. Подписанные изображения принадлежат экземпляру карточки

ContentSigner хранит подписанные URL внутри экземпляра. При remount новый экземпляр начинает с пустого cache, а _display() возвращает пустую строку до batch-delay, ответа WS и загрузки/декодирования изображения. warmBoot переносит viewport и часть UI-состояния, но не переносит готовность защищённых ресурсов.

3.3. Reconnect безусловно запускает обновление модели

_onConnReady() повторно загружает config/layout либо вызывает _reloadConfigOnly(). Даже если ревизия и содержимое не изменились, новый объект _serverCfg повышает _cfgEpoch, инвалидирует геометрические memo/cache и создаёт лишние render-проходы.

3.4. Режим смешивания Glow меняется после первого кадра

svgScreenBlendSupported() уже дедуплицирует асинхронную проверку через document-scope WeakMap<Document, Promise<boolean>>. Проблема не в отсутствии promise-cache: новый экземпляр создаётся с _glowScreenBlend = false, а присваивание даже уже вычисленного результата выполняется через .then() и откладывается на микрозадачу. Первый синхронный render поэтому может пройти с normal, а следующий — с screen.

Требуемое исправление — хранить рядом с promise синхронно доступный разрешённый результат. При его наличии поле инициализируется до первого render; асинхронный путь остаётся только для первого холодного probe и входит в readiness gate.

3.5. Visibility и ResizeObserver создают лишние refit/render

visibilitychange всегда вызывает requestUpdate(). ResizeObserver вызывает _refitView(), а тот — _applyView() и ещё один requestUpdate() даже при фактически неизменившемся размере и viewport. Нулевые размеры уже частично отфильтрованы, но положительный размер после восстановления всё равно не проверяется на эквивалентность предыдущему.

3.6. Статическая карточка имеет отдельный lifecycle

houseplan-space-card создаёт собственный ContentSigner, при config event временно обнуляет _snap, отдельно измеряет сцену и загружает данные. Поэтому исправление только основной карточки не закрывает проблему на компактных карточках пространств.

3.7. GPU-композиция не является авторитетным состоянием

SVG filters, mix-blend-mode, opacity и большие composited layers могут быть выгружены браузером в background и пересобраны после возврата. Это нельзя полностью контролировать, но можно не усиливать эффект одновременным скрытием DOM, заменой ресурсов и сменой режима смешивания.

4. Цели

  1. При обычном возврате на вкладку ни один уже видимый слой плана не исчезает и не меняет вид промежуточным кадром.
  2. Размер, масштаб и центр плана остаются прежними, если фактическая рабочая область не изменилась.
  3. Если рабочая область изменилась, новый viewport применяется одним законченным кадром.
  4. Подложка, Glow, геометрия, устройства и room fill не восстанавливаются по отдельности.
  5. Переподключение с совпадающими парами config/layout revision + content fingerprint не инвалидирует геометрию.
  6. Краткая задержка обновления состояний устройств считается допустимой и не должна вызывать overlay.
  7. Одинаковый контракт действует для основной и статической карточек.
  8. Причины редкого fallback-восстановления должны быть диагностируемы.

5. Не цели

  • Изменение серверной модели House Plan или формата сохранённой конфигурации.
  • Гарантия актуальности устройств во время отсутствия связи с HA.
  • Маскировка холодной загрузки страницы после Ctrl+F5, обновления ресурса карточки или полного уничтожения документа браузером.
  • Сохранение незавершённого pointer-жеста при уходе с вкладки.
  • Создание bitmap/screenshot-копии плана для штатного возврата.
  • Анимация каждого изменения HA-state.
  • Исправление багов самого GPU/браузера ценой отключения Glow или визуальных эффектов.

6. Термины

Последний полноценный кадр (last complete frame) — уже показанное согласованное состояние, включающее структуру плана, layout, viewport, доступные защищённые изображения, выбранный режим смешивания и render-снимок устройств.

Кандидатный кадр (candidate frame) — новое состояние, которое подготавливается после resume, reconnect, remount, resize или обновления данных, но ещё не заменило видимый кадр.

Структурные данные — config, layout, выбранное пространство, геометрия, настройки отображения и viewport.

Live-данные — состояния HA, activity pulse, температура, влажность, сигнал, vacuum state/trail и прочие быстро меняющиеся значения.

Визуальная готовность — кандидат можно показать без пустого фона, промежуточного viewport, смены blend-mode следующим кадром или частично обновлённых слоёв.

Stale frame — полноценный кадр с последними известными, но потенциально уже устаревшими live-данными.

7. Базовые инварианты

  1. Наличие нового data snapshot само по себе не разрешает убирать старый DOM с экрана.
  2. document.visibilityState = visible не означает, что размеры, connection и protected assets уже готовы.
  3. Нулевой или переходный размер никогда не записывается в _view, warm memo или layout cache.
  4. Эквивалентный размер не вызывает refit.
  5. Идентичность config/layout определяется парой revision + content fingerprint. Только совпадение обоих значений сохраняет авторитетный объект модели и geometry epoch; при одинаковой ревизии, но разном fingerprint приоритет имеет фактическое содержимое с соблюдением optimistic-write/conflict-контракта.
  6. Состояние устройств может быть старее structural frame. Это разрешённая асимметрия.
  7. Render-снимок устройств используется только для изображения. Любое действие по клику разрешается по последнему доступному runtime HA, а не по замороженному render-снимку.
  8. Устаревший кадр не должен отправлять unsigned content requests.
  9. Overlay не показывается только потому, что отсутствует новый HA-state.
  10. Первый полностью видимый кадр после resume должен уже иметь окончательный blend-mode.
  11. Hover, tooltip и focus не должны сбрасываться при быстром возврате. При реальном remount они восстанавливаются только если уже входят в существующий warm lifecycle; эта задача не расширяет хранение transient hover.
  12. Если предыдущий полноценный кадр существует, временная ошибка WS не заменяет его loading/error-состоянием.

8. Продуктовое поведение

8.1. Быстрый возврат

Для ухода с вкладки менее чем на 15 секунд:

  • план остаётся в DOM без veil и overlay;
  • _view, zoom, pan, выбранное пространство и режим не пересчитываются без фактического изменения размеров;
  • текущий hover сохраняется, если тот же экземпляр карточки остался подключён;
  • новый HA snapshot применяется обычным render-проходом;
  • отсутствие нового HA snapshot не вызывает никаких специальных эффектов.

Порог 15 секунд перестаёт означать «обязательно скрыть план» и используется только как диагностический признак возможного browser freeze.

8.2. Длительный background без remount и reconnect

  • Последний полноценный кадр остаётся видимым.
  • Положение солнца, daylight, Glow и live-данные пересчитываются в candidate frame.
  • Candidate frame коммитится атомарно после проверки размеров и visual capability.
  • Если размеры совпали, refit запрещён.
  • Overlay не показывается.

8.3. Длительный background с reconnect

  • Последний полноценный кадр продолжает отображаться как stale frame.
  • Состояния устройств могут оставаться прежними до получения первого пригодного snapshot после resume.
  • Возраст render-снимка 1000 мс после resume — только диагностический порог: controller записывает trace-событие device-snapshot-stale, но не меняет UI и не запускает таймер принудительной замены. При отсутствии нового snapshot stale frame продолжает отображаться.
  • Config/layout проверяются по парам revision + content fingerprint. Совпадение обеих частей не пересобирает геометрию.
  • Новый согласованный кадр заменяет последний полноценный кадр одной операцией.
  • Offline/reconnecting сам по себе не закрывает план overlay, пока последний полноценный кадр доступен.

8.4. Remount в том же DOM-слоте

Новый экземпляр должен получить из session/module runtime:

  • warm viewport и mode;
  • structural fingerprint последнего кадра;
  • последний render-снимок устройств;
  • результат определения Glow blend capability;
  • доступные и ещё пригодные signed URL;
  • сведения о decoded/loaded protected assets.

Если всё перечисленное пригодно, первый кадр нового экземпляра визуально эквивалентен последнему кадру старого. Новый экземпляр не должен сначала рисовать normal Glow, пустую подложку или default fit.

Если protected asset уже нельзя безопасно использовать, применяется раздел 8.7.

8.5. Реальное изменение размеров

При orientation change, изменении dashboard chrome, split-screen или resize:

  • последний полноценный кадр остаётся видимым до получения положительного стабильного размера;
  • candidate viewport вычисляется с сохранением zoom и центра;
  • старый размер не перезаписывается нулём;
  • новый viewport применяется атомарно;
  • обычный resize не требует непрозрачного overlay;
  • если последний полноценный кадр невозможно корректно удержать внутри изменившегося контейнера, разрешено временно clip-нуть его границами stage, но не масштабировать через промежуточные значения.

8.6. Реальное изменение config/layout во время отсутствия

  • Последний полноценный кадр остаётся до готовности новой структурной ревизии.
  • Config и layout образуют один candidate structural frame. Нельзя показать новую геометрию со старым layout или наоборот, если обе ревизии получены в рамках одного recovery cycle.
  • Перед применением защищённые ресурсы новой конфигурации должны получить безопасные display URL и пройти decode/load gate.
  • Если изменён выбранный space или он удалён, fallback-space выбирается до commit, а не после первого render.
  • Удалённый в новой конфигурации asset не является blocker.
  • Незавершённые локальные записи и conflict/reload продолжают соблюдать существующий optimistic-write контракт; recovery controller не имеет права молча отбросить edit history.

8.7. Когда допустим непрозрачный слой восстановления

Overlay разрешён только если одновременно выполняются условия:

  1. предыдущий полноценный кадр невозможно показать безопасно или его нет;
  2. candidate frame ещё не готов;
  3. проблема сохраняется дольше 150 мс после первого видимого кадра документа.

Примеры:

  • remount после истечения подписанного URL, когда background image обязателен для полноценного кадра и общего session-cache недостаточно;
  • браузер уничтожил composited/DOM state, а новый защищённый ресурс ещё не декодирован;
  • выбранное пространство удалено удалённой конфигурацией, но replacement candidate ещё не собран;
  • stage остаётся нулевого размера после восстановления и последний полноценный кадр физически некуда показать.

Overlay запрещён при:

  • обычном quick return;
  • наличии старого полноценного кадра с устаревшими устройствами;
  • простом reconnect при сохранной геометрии;
  • ожидании только новых HA states;
  • повторной подписи ресурса, пока старый display URL ещё пригоден и уже видим;
  • неизменившемся config/layout.

Текст:

  • общий случай: «Восстанавливаем план…» / “Restoring floor plan…”;
  • только при подтверждённом disconnected/reconnecting HA и отсутствии пригодного кадра: «Восстанавливаем подключение к устройствам…» / “Restoring device connection…”.

Overlay:

  • полностью непрозрачен относительно плана;
  • появляется fade-in 150 мс после delay;
  • если candidate готов до полного завершения fade-in, появление немедленно отменяется и overlay удаляется без минимальной выдержки;
  • минимум 250 мс применяется только после того, как overlay стал полностью непрозрачным;
  • исчезает fade-out 180 мс после paint barrier готового кадра;
  • не меняет размеры stage;
  • блокирует pointer interaction со скрытым неготовым кадром;
  • при prefers-reduced-motion: reduce не анимируется, но сохраняет delay появления;
  • имеет role="status", aria-live="polite", без перехвата focus;
  • не должен зависнуть: при постоянной ошибке соблюдает числовые пределы и переходы из раздела 9.9; если позже появился последний полноценный кадр, controller использует его как stale frame.

8.8. Cold boot

Холодная загрузка без предыдущего кадра остаётся отдельным сценарием. Текущий boot veil можно сохранить, но он должен использовать общий readiness contract там, где это возможно. Данная задача не требует заменить onboarding/loading UX, однако не допускается ошибочно классифицировать warm remount как cold boot.

9. Архитектура

9.1. Единый VisualContinuityController

Основная и статическая карточки используют общий state machine/controller. Конкретное имя может измениться, но запрещены две независимые реализации правил resume.

Минимальные состояния:

Состояние Видимый результат Допустимые действия
steady актуальный полноценный кадр обычные updates
holding последний полноценный кадр подготовка candidate, stale live-data допустимы
candidate-ready пока последний полноценный кадр atomic commit + paint barrier
overlay-pending последний полноценный кадр либо stage background таймер 150 мс, отмена при быстрой готовности
overlay-visible непрозрачный recovery overlay ожидание candidate, отмена неполного fade-in или error fallback
offline-stale последний полноценный stale frame ожидание связи без обязательного overlay
recovery-error непрозрачный error/offline fallback явный retry после timeout paint barrier

Controller получает события:

  • visibility-hidden / visibility-visible;
  • stage-size-valid / stage-size-invalid;
  • connection-ready / connection-lost;
  • config-candidate / layout-candidate;
  • hass-snapshot;
  • asset-ready / asset-failed;
  • blend-capability-ready;
  • card-connected / card-disconnected;
  • render-committed / paint-barrier.

Controller не хранит пользовательскую конфигурацию и не вызывает HA service. Он только решает, какой frame можно показывать и нужен ли overlay.

9.2. Двойной буфер данных

Нужны отдельные immutable references:

type RenderDeviceState = Readonly<{
  markerId: string;
  entityIds: readonly string[];
  spaceId?: string;
  position: Readonly<{ x: number; y: number }>;
  icon: string;
  displayMode: string;
  hidden: boolean;
  opacity: number;
  visualState: 'static' | 'idle' | 'active' | 'unavailable';
  classes: readonly string[];
  value?: string;
  valueFull?: string;
  activity: Readonly<{
    kind: 'none' | 'motion' | 'presence' | 'cover-motion' | 'working' | 'pulse' | 'vacuum' | 'other';
    generation: number;
    expiresAt?: number;
  }>;
  roomContribution: Readonly<{
    isLightSource: boolean;
    lightOn: boolean;
    temperature?: number;
    humidity?: number;
    signal?: number;
    sourceEntityIds: readonly string[];
  }>;
  controlPresentation: Readonly<Record<string, string | number | boolean | null>>;
}>;

type RenderDeviceSnapshot = Readonly<{
  sourceSequence: number;
  capturedAt: number;
  devices: readonly RenderDeviceState[];
}>;

type VisualFrame = Readonly<{
  structuralFingerprint: string;
  configFingerprint: string;
  layoutFingerprint: string;
  configRev: number;
  layoutRev: number;
  spaceId: string;
  mode: 'view' | 'plan' | 'devices' | 'decor';
  viewport: Readonly<{ x: number; y: number; w: number; h: number }>;
  stageSize: Readonly<{ width: number; height: number }>;
  deviceSnapshot: RenderDeviceSnapshot;
  assetKeys: readonly string[];
  blendMode: 'screen' | 'normal';
}>;

Тип иллюстративен на уровне имён, но граница обязательна: render-снимок содержит уже разрешённые presentation-данные устройства, activity на текущий момент, вклады в данные комнаты и presentation-state controls. Он не содержит полный hass, entity objects или callbacks. Реализация может хранить отдельные memo и fingerprints, но обязана различать visible и candidate state. Нельзя мутировать объект, который считается последним полноценным кадром.

Все render-пути, входящие в атомарный кадр, читают device-derived данные только из RenderDeviceSnapshot; прямое чтение this.hass в них запрещено. Event handlers и отправка service calls, напротив, всегда читают актуальный runtime HA в момент действия.

9.3. Structural fingerprint

Fingerprint должен позволять дешёво ответить, изменилась ли визуальная структура. Для config и layout отдельно хранится пара revision + content fingerprint; общий structural fingerprint собирается из этих пар и визуального контекста. В него входят как минимум:

  • config revision и identity/fingerprint содержимого;
  • layout revision;
  • выбранный space;
  • mode и настройки, влияющие на состав слоёв;
  • viewport/stage size;
  • theme/display параметры, меняющие геометрию или palette.

Обычный HA-state tick не меняет structural fingerprint и не инвалидирует geometry cache.

9.4. Config/layout revalidation

_onConnReady() не должен безусловно заменять _serverCfg.

Обязательное поведение:

  1. Получить ревизии и дешёвые content fingerprints config/layout из фактически принятых данных.
  2. Если и revision, и fingerprint совпадают с применёнными, сохранить текущие object references и geometry epoch.
  3. Если revision совпадает, но fingerprint отличается, фактическое содержимое считается изменившимся: собрать candidate и провести его через существующие optimistic-write/defer/conflict-правила. Совпавшая revision не имеет права скрыть локальную или удалённую правку.
  4. Если revision изменилась, но fingerprint визуально значимого содержимого совпал, обновить revision metadata без пересборки geometry.
  5. Если изменился только layout fingerprint, не пересобирать config-derived geometry.
  6. Если изменился config fingerprint, собрать candidate model и только затем заменить visible structural frame.
  7. Одновременные ответы config/layout относятся к одному recovery cycle и коммитятся согласованно.
  8. Ошибка запроса сохраняет stale frame.

Backend endpoint только для ревизий может быть добавлен как оптимизация позже; он не является обязательным условием реализации.

9.5. Shared protected-asset runtime

ContentSigner должен разделить instance lifecycle и page/session cache:

  • подписанные URL и время выдачи доступны преемнику в том же документе;
  • cache key включает backend/content identity и не смешивает разные HA connections;
  • устаревающая, но ещё пригодная подпись продолжает отображаться, пока новая запрашивается в фоне;
  • истёкшая подпись никогда не вставляется в новый DOM;
  • факт успешной загрузки/decode ресурса хранится отдельно от факта наличия URL;
  • resource candidate считается готовым только после load/decode либо когда тот же уже видимый DOM-resource остаётся на месте;
  • cache ограничен по количеству/TTL и освобождается без удержания DOM nodes;
  • несколько карточек дедуплицируют одинаковые sign requests.

Не требуется превращать все изображения в data/blob URL. Если после истинного remount нет пригодного URL и удержать последний полноценный кадр нельзя, используется overlay из раздела 8.7.

9.6. Blend capability

Существующий document-scope promise-cache svgScreenBlendSupported() сохраняется и расширяется синхронным кэшем уже разрешённого boolean-результата. Создавать параллельный capability cache или повторный probe на экземпляр запрещено.

  • При наличии разрешённого результата warm remount инициализирует _glowScreenBlend синхронно до первого render и не проходит через default false.
  • Асинхронный путь используется только для первого cold probe; его blend-mode входит в readiness gate.
  • Запрещён видимый переход normal -> screen сразу после resume/remount.
  • Изменение capability вследствие смены документа/браузерного контекста применяется только как часть candidate frame.

9.7. Viewport и observers

Наблюдение stage должно хранить последнее валидное измерение.

Refit выполняется только если:

  • новый размер положителен;
  • он отличается от последнего применённого больше допустимого epsilon;
  • размер подтверждён в следующем animation frame либо браузер уже завершил resize;
  • controller разрешил candidate commit.

Если новый _view численно эквивалентен текущему, reactive fields не присваиваются и requestUpdate() не вызывается.

visibilitychange без изменившихся данных не вызывает обязательный render. Оно только уведомляет controller и запускает проверку readiness.

9.8. Live-device snapshot

На входе в holding фиксируется последний render-снимок устройств. После resume:

  1. Геометрия и фон продолжают отображаться с ним.
  2. Приходит новый hass object/snapshot — устройства пересчитываются в candidate buffer.
  3. Все связанные derived-данные одного устройства (иконка, dynamic background, pulse, room light contribution, temperature/signal и controls) вычисляются из одного candidate snapshot.
  4. Candidate применяется одним render commit.
  5. Если свежий snapshot не пришёл, старый остаётся; overlay не нужен.

Activity/pulse timers, истёкшие в background, не должны воспроизводить пропущенную анимацию. После resume они нормализуются к состоянию на текущий момент и коммитятся вместе с candidate.

Обработчик клика обязан читать последний runtime HA в момент действия. Замороженный render-снимок запрещено использовать как источник решения «что отправить».

9.9. Paint barrier

Готовность данных не равна готовности пикселей. Перед удалением overlay либо объявлением candidate полноценным кадром требуется:

  1. Lit updateComplete;
  2. готовность обязательных images;
  3. минимум два requestAnimationFrame: первый позволяет браузеру собрать layout/layers, второй подтверждает следующий paint opportunity;
  4. повторная проверка положительного stage size и того же candidate token.

Если за это время candidate устарел, старый candidate не раскрывается.

Один paint barrier имеет верхний предел PAINT_BARRIER_MAX_MS = 2000 мс с момента готовности данных candidate:

  • если timeout наступил при наличии последнего полноценного кадра, candidate отклоняется, stale frame остаётся видимым, записывается paint-barrier-timeout, а поздняя готовность ресурса создаёт новый candidate token;
  • если timeout наступил без последнего полноценного кадра, recovery overlay остаётся непрозрачным, controller переходит в recovery-error и показывает существующий локализованный recoverable error/offline UX с явным retry;
  • поздний asset-ready, reconnect или ручной retry запускает новый recovery cycle; просроченный token не может раскрыть старый candidate;
  • бесконечное ожидание image decode, rAF или положительного stage size запрещено.

Paint barrier и таймеры recovery overlay независимы: достижение PAINT_BARRIER_MAX_MS само по себе не снимает overlay. Если последнего полноценного кадра нет, уже показанный overlay остаётся непрозрачным при переходе в recovery-error и до явного retry/нового recovery cycle; если stale frame есть, timeout оставляет его видимым и не включает overlay задним числом.

10. Слои и атомарность

В один visual commit входят:

  • paper/room fills;
  • backdrop;
  • decor;
  • walls/openings/partitions/columns;
  • Glow и его masks/filters;
  • солнечные лучи;
  • room hover и tooltip, если их состояние осталось валидным;
  • устройства и device-derived room data;
  • vacuum map/trail;
  • viewport/viewBox;
  • theme-dependent palette.

Допускается не пересоздавать неизменившиеся DOM-узлы. Требование относится к наблюдаемому результату: пользователь не должен увидеть комбинацию старых и новых слоёв.

11. Режимы карточки

11.1. Обычный просмотр

Полный контракт обязателен.

11.2. Kiosk

Kiosk не использует hpresume уже сейчас, поэтому удалять veil в нём не требуется. Тем не менее он входит в continuity contract: обязан сохранять фон, Glow, device snapshot и signed assets при reconnect/remount. Он может не нуждаться в resize settle из-за 100dvh; overlay применяется по тем же редким условиям.

11.3. Редакторы

  • Незавершённый pointer/touch gesture отменяется существующим контрактом _interruptViewGesture, принятым в #59 и покрытым smoke_long_press_gesture; отдельная параллельная логика отмены не создаётся.
  • Сохранённая конфигурация, mode, selection и command stack не сбрасываются.
  • Видимый план не скрывается при возврате.
  • Recovery overlay при необходимости блокирует редактирование до готовности structural frame.
  • Замороженное live-состояние устройств допустимо; локальный geometry draft не подменяется серверным candidate без существующего conflict workflow.

11.4. houseplan-space-card

Статическая карточка использует общий shared signer/capability cache и общий controller либо его упрощённый adapter. Config event не должен сначала обнулять _snap при наличии валидного текущего snapshot. Старый snapshot остаётся видимым до успешной загрузки нового.

11.5. Несколько карточек на странице

  • Controller state принадлежит placement, а asset/capability cache — document/connection scope.
  • Существующие module-scope pageHiddenAt, pageLongResumeEvent и общий visibility handler являются основой реализации: они расширяются единым resume token, а не заменяются вторым document listener/state-механизмом.
  • Visibility event обрабатывается один раз на document и распространяется всем экземплярам с единым resume token.
  • Одна карточка не очищает timestamp/token до обработки соседней.
  • Одинаковая конфигурация в двух placements не даёт права наследовать чужой viewport или device snapshot.

12. Edge cases

Сценарий Требуемое поведение
Вкладка hidden 2 секунды Никаких специальных визуальных действий
Вкладка hidden 30 секунд, размеры те же Последний полноценный кадр остаётся; candidate коммитится без refit
Браузер свёрнут, HA connection жива То же, что long background без reconnect
HA reconnect, revision и fingerprint те же Stale frame → один live update; geometry epoch не меняется
HA reconnect, revision та же, fingerprint изменился Содержимое выигрывает; candidate проходит optimistic-write/conflict workflow и коммитится атомарно
HA reconnect, revision изменилась, fingerprint тот же Обновляется revision metadata без пересборки geometry
HA reconnect, config изменён Последний полноценный кадр остаётся до готовности новой структуры; atomic commit
ResizeObserver дал 0×0, затем прежний размер Ничего не менять и не рендерить новый viewport
ResizeObserver дал новый положительный размер Один refit по стабильному размеру
Remount с валидной shared подписью Первый кадр уже с подложкой
Remount с истёкшей подписью Последний полноценный кадр, если он доступен; иначе delayed recovery overlay
Фоновая картинка удалена в новой config Asset gate её не ждёт
Устройство изменилось во время hidden Старое состояние допустимо до atomic candidate commit
Устройство стало unavailable Новое состояние применяется вместе со всем device snapshot, без структурного repaint
Связь не вернулась Stale frame остаётся видимым без обязательного overlay
Theme сменена в background Palette и dependent layers меняются одним candidate frame
Space удалён удалённо Fallback space выбирается до reveal
Пользователь сменил Lovelace view Разделение использует существующий warmBootKey(config); внутри его scope действуют текущие placement/owner rules. Новый ключ по DOM-позиции и наследование чужого frame запрещены
Browser back/forward cache После pageshow используется тот же readiness workflow; persisted учитывается как сигнал, не как причина скрытия
Touch pinch был активен при hidden Gesture отменяется, click suppression сохраняется на безопасное окно
prefers-reduced-motion Нет fade, но нет промежуточного пустого кадра
Несколько Glow sources Blend-mode и filters готовы до общего commit
Vacuum animation/timer истёк Переход к текущему состоянию без проигрывания пропущенных pulse frames

13. Диагностика

В development/demo runtime нужен кольцевой trace последних 50–100 событий без production console spam:

type ContinuityTraceEvent = {
  at: number;
  token: number;
  event: string;
  state: string;
  reason?: string;
  stage?: [number, number];
  configRev?: number;
  layoutRev?: number;
  assetPending?: number;
};

Тестовые hooks/data attributes:

  • data-continuity-state, data-continuity-token и data-frame-fingerprint — стабильный низкокардинальный контракт на корневом элементе в production и test builds;
  • data-recovery-reason — стабильный production/test атрибут только при overlay/error fallback;
  • data-device-snapshot-age и read-only hook кольцевого trace — только development/demo/test.

Обязательные production-атрибуты не исключаются build-флагом и могут использоваться lifecycle smoke. Они не содержат entity IDs, URL или другие чувствительные/высококардинальные данные. Ошибки signed assets и connection не должны логироваться на каждом render.

14. Accessibility и interaction

  • Recovery overlay не получает initial focus и не крадёт текущий focus.
  • aria-live="polite"; повтор одного и того же текста не анонсируется заново.
  • Пока overlay видим, скрытая сцена inert для pointer/keyboard, но сама карточка не исчезает из accessibility tree целиком.
  • Если stale frame остаётся видимым без overlay, room navigation и безопасные действия доступны.
  • Device actions используют live runtime на момент клика; при фактическом disconnected HA существующая обработка service error остаётся авторитетной.
  • Не добавляется новый постоянный индикатор stale state в рамках этой задачи.

15. Производительность и память

  1. Обычный hass tick не делает deep clone всей config/layout.
  2. Structural fingerprint не пересчитывает тяжёлую геометрию при state-only update.
  3. Shared asset cache имеет LRU/TTL и верхнюю границу.
  4. Warm device snapshot хранит только данные, необходимые renderer, а не полный hass object.
  5. Не допускаются параллельные continuity rAF loops на один placement.
  6. Observer callback без реального изменения не вызывает Lit update.
  7. Sign requests дедуплицируются между карточками.
  8. Trace ограничен по размеру.

Производительность измеряется существующим профилем large-house-glow-overlay-v1 и политикой issue #69, а не отдельным неизмеримым обещанием «<1 мс». После внедрения фиксируется continuity baseline в том же runner. Обязательные условия: отсутствие дополнительного Lit render при quick return без изменений и отсутствие регресса текущего профиля сверх принятого в #69 допуска.

16. Тестирование

16.1. Unit tests

Отдельный state machine тестируется с fake clock:

  • quick return остаётся steady;
  • long return с complete frame переходит holding -> steady без overlay;
  • отсутствие device snapshot не вызывает overlay;
  • overlay появляется только после 150 мс и только при отсутствии последнего полноценного кадра;
  • candidate, готовый до полного fade-in, отменяет overlay без minimum visible duration;
  • minimum visible duration применяется только к полностью непрозрачному overlay;
  • stale candidate token не коммитится;
  • offline сохраняет stale frame;
  • prefers-reduced-motion меняет только animation, не state logic;
  • одинаковые размеры не создают viewport candidate;
  • нулевой размер не уничтожает последний валидный;
  • совпадающие пары revision + content fingerprint сохраняют references/epoch;
  • одинаковая revision с разным fingerprint создаёт candidate, а изменившаяся revision с тем же fingerprint не пересобирает geometry;
  • asset gate различает signed, loaded/decoded и expired;
  • уже разрешённый document-scope blend result читается синхронно до первого render;
  • timeout paint barrier сохраняет stale frame либо переводит отсутствие последнего полноценного кадра в recovery-error;
  • atomic render-пути не читают this.hass в обход RenderDeviceSnapshot.

16.2. Lifecycle smoke

Нужен отдельный smoke-сценарий с управляемыми:

  • document.visibilityState;
  • fake timers/resume token;
  • connection ready;
  • задержкой content/sign;
  • задержкой image decode;
  • ResizeObserver (0×0, прежний и новый размер);
  • remount в том же placement;
  • обновлением state во время hidden;
  • основной и статической карточкой.

Новый controller расширяет существующий module-scope warm memo и не дублирует/не подменяет его. Без ослабления assertions обязаны оставаться зелёными smoke_warm_remount, smoke_warm_dialogs, smoke_warm_owners, smoke_preloader_lifecycle, smoke_ws_resilience, smoke_dialog_zombie, smoke_long_press_gesture и контракт docs/WARM-REMOUNT.md.

Проверки должны подтверждать не только наличие DOM:

  • .zoomwrap не получает visibility:hidden при resume;
  • viewBox не меняется при том же размере;
  • protected backdrop не получает пустой href между кадрами;
  • geometry epoch не меняется при совпадающих парах revision + content fingerprint;
  • Glow не проходит видимый normal -> screen после remount;
  • overlay не появляется в быстрых/нормальных сценариях;
  • при forced overlay текст и reason корректны;
  • device render snapshot кратко остаётся старым, затем заменяется новым одним commit.

16.3. Frame sequence и pixel test

Обычный golden одного конечного кадра и цикл page.screenshot() недостаточны: Playwright screenshot не синхронизирован с presented frames и может пропустить артефакт длительностью один кадр. Проверка разделяется на два нормативных уровня.

Обязательный CI-гейт — in-page rAF sampler. До перевода вкладки в visible страница вооружает sampler; начиная с первого requestAnimationFrame после resume он записывает не менее 30 последовательных animation opportunities:

  • наличие и computed visibility/display .zoomwrap;
  • viewBox и стабильный stage bounding box;
  • data-continuity-state, token и frame fingerprint;
  • фактический href обязательной подложки;
  • data-blend/применённый Glow blend-mode;
  • факт наличия recovery overlay и его reason.

Sampler запускается внутри страницы, использует один и тот же clock/token с фикстурой и падает при любом запрещённом промежуточном состоянии. Это детерминированный обязательный тест для PR/beta CI, но он доказывает DOM/render-state, а не фактически представленные браузером пиксели.

Нормативный список запрещённых промежуточных состояний и переходов:

Поле sampler Запрещённое состояние или переход
.zoomwrap Отсутствует, получает display:none или visibility:hidden, когда доступен last complete/stale frame и его не закрывает разрешённый непрозрачный recovery overlay
viewBox Пустое/нечисловое значение; изменение при неизменившихся положительных размерах stage; промежуточный default-fit перед возвратом к сохранённому viewport
Stage bounding box 0×0 становится авторитетным кадром; при неизменившемся размере контейнера bbox дома меняется между samples до атомарного commit нового token
data-continuity-state Переход в loading/recovery-состояние при доступном stale frame; возврат уже завершённого token из steady в предыдущее состояние
Continuity token Уменьшается, повторно используется новым recovery cycle либо меняется без события, создающего candidate
Frame fingerprint Пуст после наличия complete frame; меняется до атомарного commit соответствующего candidate либо расходится между слоями одного sample
Backdrop href Пригодный URL временно становится пустым/непригодным без удаления подложки новой structural revision и без разрешённого непрозрачного overlay
Glow data-blend Пустое/неопределённое значение после complete frame; видимый переход screen -> normal -> screen или normal -> screen после remount при уже разрешённом document capability
Recovery overlay Появляется при доступном stale frame, раньше 150 мс, без reason либо с connection-текстом без подтверждённого disconnected/reconnecting состояния

Любое совпадение со строкой таблицы является падением sampler. Тест не имеет права заменять эти правила более слабой проверкой только конечного sample.

Pixel-level sequence — CDP Page.startScreencast. Настоящая последовательность presented frames захватывается через CDP screencast в nightly/manual профиле и обязательно перед стабильным релизом. page.screenshot() не считается заменой. Кадры связываются с resume token и контрольными временными метками sampler; после захвата screencast обязательно останавливается и acknowledgements кадров завершаются.

Фикстура должна содержать:

  • непрозрачный фон пространства;
  • защищённую подложку;
  • толстые стены и проёмы;
  • room fill;
  • Glow с screen blending;
  • солнечные лучи;
  • несколько устройств с динамической подложкой;
  • vacuum с детерминированным состоянием и видимым overlay;
  • минимум один детерминированный decor-объект.

Утверждения rAF sampler:

  1. .zoomwrap, viewBox, frame fingerprint и blend-mode не проходят через запрещённое промежуточное значение.
  2. Bounding box дома и viewBox стабильны при неизменном размере.
  3. Backdrop имеет пригодный href во всех samples либо закрыт разрешённым непрозрачным overlay.
  4. Overlay не появляется в сценариях с доступным stale frame.

Утверждения CDP screencast:

  1. В области, где до hidden был план, ни один presented frame не равен чистому stage background.
  2. Контрольные пиксели стены не переходят во временный чёрный цвет.
  3. Яркость контрольных пикселей Glow не имеет одиночного выброса сверх tolerance.
  4. Backdrop присутствует во всех presented frames либо полностью закрыт разрешённым непрозрачным overlay.

Порог pixel-diff должен учитывать субпиксельный raster noise, но не позволять исчезновение слоя. Конкретное значение фиксируется после измерения baseline в Chromium CDP-профиле. Критерий «нет чистого stage background» доказывается только screencast, а не последовательностью screenshot().

16.4. Реальный browser lifecycle

Минимум один ручной чек перед стабильным релизом:

  • desktop Chrome/Edge: quick switch, 30–60 секунд background, minimize/restore;
  • Android Chrome или HA Companion WebView: background/foreground;
  • reconnect HA при открытой карточке;
  • обычный View, kiosk и houseplan-space-card;
  • signed backdrop;
  • переход между Lovelace views с remount.

17. Критерии приёмки

  1. При уходе с вкладки на 1–10 секунд и возврате план визуально не меняется, если данные и размеры не изменились.
  2. При уходе более чем на 15 секунд нет обязательного скрытия .zoomwrap/плана.
  3. При неизменившихся размерах viewBox, zoom и центр идентичны до и после возврата.
  4. 0×0 -> прежний размер не меняет viewport и не создаёт visible repaint plan geometry.
  5. Reconnect с совпадающими парами config/layout revision + content fingerprint не повышает geometry epoch и не заменяет structural model; разный fingerprint при той же revision не игнорируется.
  6. Warm remount с пригодным shared asset cache показывает backdrop с первого видимого кадра.
  7. Glow не меняет blend-mode видимым отдельным кадром.
  8. Старое состояние устройства разрешено до первого atomic live commit; промежуточное отсутствие устройства/иконки запрещено.
  9. Отсутствие свежего HA snapshot не вызывает recovery overlay.
  10. Overlay не появляется раньше 150 мс, не показывается при наличии stale frame, отменяется без минимальной выдержки до завершения fade-in и плавно исчезает только после успешного paint barrier.
  11. Основная и статическая карточки проходят одинаковые lifecycle-инварианты.
  12. Обязательный CI rAF sampler не обнаруживает ни одного перехода из нормативной таблицы §16.3; CDP screencast перед стабильным релизом не обнаруживает пустого, чёрного или несогласованного presented frame.
  13. Холодная загрузка, signed URL security и stale-while-revalidate при outage не регрессируют.
  14. Ни одна локальная geometry edit/command stack не теряется из-за recovery/reconnect.

18. План реализации

Implementation gate. Реализация #73 не начинается, пока не закрыты и не прошли code review renderer-задачи #67, #71 и #72. После их закрытия действующие golden images переснимаются только при подтверждённом ожидаемом изменении, переутверждаются и фиксируются как авторитетный baseline для frame-sequence fixture. SHA baseline записывается в issue #73/тестовую документацию до первой реализации continuity controller.

Текущий статус gate на 2026-08-11. Gate выполнен релизом v1.61.0-beta.6 на exact SHA d2bc9082807452c88e735c72d4a06d1ff873eaa9: зависимости #71/#72 опубликованы и закрыты, Linux golden matrix v7, full browser smoke и performance smoke прошли в Validate run 31437504173 без candidate/diff artifact. Каноническое описание света после этих изменений — docs/LIGHT.md. По прямому поручению владельца #73 переведена в In Progress и реализация controller начата от этого baseline; отдельный frame-sequence baseline создаётся на этапе 5 и не подменяет зафиксированный renderer baseline.

Этап 1. Наблюдаемость и state machine

  • Расширить существующие module-scope pageHiddenAt/pageLongResumeEvent единым document-level resume token без параллельного lifecycle-механизма.
  • Добавить общий controller и trace.
  • Убрать hpresume как механизм штатного скрытия только в обычном View; kiosk/редакторы покрыть сохранением ресурсов и frame state.
  • Добавить no-op guards в ResizeObserver/refit и visibility path.
  • Покрыть unit tests.

Этап 2. Atomic structural/live frames

  • Разделить visible/candidate frame references.
  • Добавить adoption config/layout по паре revision + content fingerprint.
  • Ввести typed immutable RenderDeviceSnapshot; заморозить только presentation-данные live devices, не service runtime.
  • Добавить paint barrier.

Этап 3. Assets и Glow capability

  • Перенести signer cache в connection/document scope.
  • Добавить asset loaded/decode readiness.
  • Дополнить существующий document promise-cache синхронным resolved blend result и читать его до первого visible render.
  • Расширить warm placement memo только необходимыми fingerprints/snapshots.

Этап 4. Статическая карточка и overlay fallback

  • Подключить houseplan-space-card к общему контракту.
  • Реализовать delayed overlay, локализацию и a11y.
  • Добавить offline/error fallback.

Этап 5. Lifecycle/pixel QA

  • Добавить deterministic smoke fixture.
  • Добавить обязательный 30-frame rAF state sampler и отдельный CDP screencast профиль.
  • Провести manual checks desktop/Android/Companion.
  • Только после этого закрывать issue #73.

Этапы являются порядком разработки, а не разрешением выпускать промежуточное пользовательское поведение. В beta должен попасть законченный continuity contract либо явно ограниченный, протестированный slice без нового мигания.

19. Риски и меры

Риск Мера
Пользователь кратко видит старое состояние устройства Осознанно принято; actions используют live runtime, новый snapshot коммитится сразу после готовности
Shared signer cache смешает разные HA sessions Scope cache по connection/backend identity, очистка при смене authority
Последний полноценный кадр удерживает большой объём памяти Immutable render snapshot без полного hass; bounded LRU/TTL assets
Overlay скроет план слишком часто Строгие три условия из 8.7, delay и automated negative tests
Atomic commit усложнит Lit reactivity Controller хранит tokens/references, но не дублирует business model; внедрение по этапам
Browser всё равно пересоберёт GPU layer Не менять одновременно DOM/resource/blend; rAF sampler ловит render-state, CDP screencast — наблюдаемый пиксельный артефакт
Remote config конфликтует с локальной правкой Сохранить существующие flush/defer/conflict правила; candidate не имеет права обходить их
Static и full cards снова разойдутся Общие controller/cache и единый набор contract tests

20. Документация после реализации

Обновить:

  • docs/ARCHITECTURE.md — visual frame, shared asset cache, revision-aware revalidation;
  • docs/WARM-REMOUNT.md — связь существующего module-scope placement memo с continuity controller без второго warm-механизма;
  • docs/TESTING.md — lifecycle smoke, обязательный rAF sampler и CDP screencast профиль;
  • docs/STATUS.md — фактический статус и ограничения;
  • docs/CHANGELOG.md и docs/CHANGELOG.ru.md — пользовательский результат без внутренних деталей.

21. Зафиксированные решения

  1. Визуальная непрерывность важнее мгновенной актуальности live-состояний.
  2. Кратко устаревшее состояние устройства допустимо.
  3. Stale frame лучше loading/пустого/чёрного промежуточного кадра.
  4. Recovery overlay — редкий fallback, а не штатное поведение long resume.
  5. Текст про восстановление подключения показывается только при подтверждённой проблеме соединения.
  6. Модель House Plan не меняется.
  7. Реализация должна быть общей для обеих карточек.
  8. Критерий качества — отсутствие артефакта в последовательности кадров, а не только корректный конечный DOM.
  9. Config/layout сравниваются по паре revision + content fingerprint; при конфликте одинаковой revision содержимое не игнорируется.
  10. Покадровый CI-контракт доказывается in-page rAF sampler, а pixel-level контракт — CDP screencast, не циклом Playwright screenshots.
  11. Реализация начинается только после закрытия #67, #71, #72 и переутверждения golden baseline.
  12. Новый controller расширяет существующий warm/visibility lifecycle и не создаёт параллельную систему возврата.