Files
houseplan-card/docs/specs/493-summary-panel-hardening.md
T
2026-09-09 02:53:36 +03:00

482 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# #493 — Ограниченный picker и надёжный lifecycle сводной панели
Issue: [#493](https://github.com/Matysh/houseplan-card/issues/493).
Связанные задачи: [#437](https://github.com/Matysh/houseplan-card/issues/437),
[#490](https://github.com/Matysh/houseplan-card/issues/490).
Ветка: `issue/493-summary-panel-hardening`; база спецификации:
`dev@71aeb860`.
Редакция: 2026-09-09. Полный трек, P2/bug.
Канонический статус — метка issue, не заголовок этого документа.
## 1. Сценарий
Администратор дома открывает настройки сводной панели в карточке House Plan,
где уже может быть до 200 показателей и тысячи доступных HA-сущностей. Он
выбирает источник показателя, меняет локальный размер значков/текста, повторно
открывает или пересоздаёт карточку, оптимизирует/копирует/импортирует план.
Домочадец либо пользователь киоска меняет только локальный показ и размеры.
На телефоне любой доступный ему вариант формы должен помещаться в экран.
Это продолжение утверждённого исключения #437 из `docs/SCOPE.md`: надёжный
read-only overview (J1) и сохранение настройки дома при его изменении (J6), а
не расширение House Plan до произвольного dashboard framework.
## 2. Что человек увидит до и после
**До:** большая форма может на секунды зависать, потому что полный список
сущностей повторяется в каждой строке; повторный `setConfig` визуально сбрасывает
сохранённые размеры; после ухода со страницы или смены пользователя может
остаться старый черновик; на узком русском интерфейсе проценты и действия
обрезаются; Optimize имеет отличающийся серверный контракт сохранения панели.
**После:** у каждой строки остаётся понятный выбор источника, но открыт и
отрисован только один ограниченный поисковый список; локальные размеры не
скачут; новая сессия/пользователь/набор прав не наследует чужой диалог; форма
полностью доступна на узком экране; все штатные способы изменения плана
сохраняют либо явно заменяют панель по одной документированной матрице.
## 3. Проблема и подтверждённые причины
Аудит и повторное чтение текущего `dev` подтверждают пять независимых пробелов:
1. `summary-panel-editor.ts` сортирует весь `hass.states` при каждом render и
создаёт полный набор `<option>` внутри каждой value-строки. Диагностический
случай 100 строк × 1012 states создал 101510 option-узлов; открытие заняло
около 2982 ms, один ввод — около 829 ms. Эти числа не являются CI budget,
но доказывают архитектурный рост `rows × states`.
2. `houseplan-card.setConfig()` повторно записывает `_kioskScale` из старого
общего `houseplan_card_kiosk_v1`, а `LoadedSummaryPanelRuntime.loadLocal()`
пропускает уже известный preference key. Поэтому local 200%/150% фактически
становятся 100%/100%, хотя правильная запись остаётся в localStorage.
3. `_leaveCardRoute()` не завершает summary dialog, а `disconnect()` снимает
только clock timer. Диалог, draft и результаты незавершённой lazy/async
операции не привязаны к generation пользователя/соединения.
4. `.summary-local-sizes` всегда требует три колонки
`max-content + minmax(140px,1fr) + 48px`. При доступной ширине 346 px русская
форма требует около 389 px, и значение процента выходит за экран.
5. `config/set` вызывает `preserve_summary_panel_namespace()` и
`validate_summary_panel_references()`, а `plan/optimize` — нет. Остальные
полные/частичные writers используют разные механизмы, но единая проверяемая
матрица для namespace и семантики отсутствует.
#490 закрывает только authoritative lost-ACK recovery и live dependency
invalidations. Она намеренно не исправляет перечисленные пути и не дублирует
эту задачу.
## 4. Область изменения
В scope:
1. один активный ограниченный picker источника с общим поисковым индексом;
2. сохранение фактически применённых local scales при повторном same-key
`setConfig`, remount и reload;
3. завершение dialog/draft/active picker и инвалидирование закрытого runtime
state при route leave, disconnect/reconnect, смене HA user или прав;
4. адаптивная полная и local-only форма на 320/390 px, RU/EN и увеличенном
системном тексте;
5. единый backend helper/contract для обычной записи `config/set` и Optimize;
6. явная writer matrix для config/set, Optimize, full/space/plan-only import,
Optimize/Import Undo, создание/копирование/удаление пространства;
7. автоматические unit/backend/browser/performance доказательства регрессий.
Не в scope:
- новые типы значений, формулы, действия, произвольные карточки или больше
лимитов 10 блоков × 20 строк;
- изменение содержимого или компоновки read-only overlay;
- перенос local preferences между браузерами, пользователями или карточками;
- новая настройка, ручная миграция либо изменение server config/store version;
- изменение правил доступа HA и возможность редактировать shared panel в kiosk;
- исправления lost-ACK/live invalidation, уже принятые в #490;
- переработка всех entity-picker в House Plan;
- смягчение schema, geometry, marker-link или revision guards других writers.
## 5. Термины и обязательные инварианты
- **index** — не-DOM массив доступных текущему `hass` entity id и friendly name,
нормализованный для поиска и отсортированный детерминированно;
- **active picker** — единственная открытая поверхность выбора источника во всей
summary form;
- **bounded results** — ограниченное число строк результата, независимо от
общего числа HA states;
- **local identity** — stable preference key плюс HA user/host/route/card slot;
- **lifecycle generation** — токен текущего соединённого пользователя и набора
прав; завершение старой async-операции не может менять новую generation;
- **ordinary writer** — конфигурационная запись поверх текущего документа;
- **authoritative restore** — явная полная замена config из full archive либо
точное восстановление ранее записанного backup.
Инварианты:
1. число DOM-строк picker не зависит от `rows × states`;
2. через поиск доступна каждая читаемая `hass.states` entity, в том числе не
размещённая на плане; одинаковые friendly names различаются по entity id;
3. выбранный старый broken source остаётся видимым и round-trip-ится, пока
пользователь явно не заменит его;
4. local scale после разрешения identity имеет одного владельца — summary local
preference; старый kiosk key только одноразовый seed при отсутствии записи;
5. dialog/draft принадлежат ровно одной lifecycle generation;
6. omission namespace обычным writer не означает удаление; явный `blocks: []`
остаётся единственным пустым version-1 составом;
7. ни один writer не реконструирует весь `settings` из известного allowlist и
не теряет независимые известные/будущие поля.
## 6. UX ограниченного выбора источника
### 6.1 Закрытая строка
В каждой value-строке вместо постоянно наполненного native select находится
одно поле выбора, показывающее текущий источник: системное имя либо
`friendly name — entity_id`. Пустой и старый broken source используют уже
принятые тексты/предупреждения #437. Изменение остаётся частью общего draft и
фиксируется только кнопкой «Сохранить» всей формы.
### 6.2 Открытие и поиск
Click/tap/Enter/Space открывает picker только этой строки. Другой открытый
picker закрывается. Внутри находятся:
1. поле поиска по friendly name и entity id;
2. три системных показателя;
3. не более 100 первых совпадений entity в стабильном порядке;
4. текущий broken source отдельной строкой, даже если его нет в index;
5. подсказка уточнить поиск, если совпадений больше лимита, либо принятое
сообщение «ничего не найдено».
Все 10 000 entity достижимы через уточнение поиска; ограничение относится к
одновременно созданным DOM-узлам, а не к доступному набору. Выбор закрывает
picker, переносит source в draft и возвращает фокус в поле строки. Escape и
click/tap снаружи закрывают picker без изменения; Tab не запирается внутри
вложенной поверхности и сохраняет нормальный modal focus order. На touch
каждая интерактивная строка не меньше 44×44 px.
Active row определяется stable block/value ids, не индексом массива. Если во
время draft-операции строка удалена, picker закрывается; reorder не направляет
выбор в другую строку. Глобальный поиск над всеми native selects удаляется:
поиск живёт рядом с единственным активным списком и не выглядит как фильтр
скрытых строк.
### 6.3 Индекс и обновления
Index строится один раз на состав/пары `entity_id + friendly_name` текущего
доступного `hass.states`, а не на каждое state value update и не для каждой
строки. Новая/удалённая/переименованная entity инвалидирует index; обычное
изменение state не сортирует и не перестраивает его. Кеш не переносится между
пользователями или соединениями и очищается при lifecycle reset.
## 7. Local scales и repeated setConfig
После определения local identity значения `icon_scale` и `font_scale` из
`houseplan_summary_local_v1:*` являются authority для View, kiosk и открытой
формы. `setConfig` не имеет права перезаписать их старым kiosk key, если runtime
уже загрузил тот же key.
Контракт переходов:
| Событие | Ожидаемое значение |
|---|---|
| Первый запуск, новой записи нет | Валидные legacy icon/font один раз как seed, иначе 100% |
| Повторный `setConfig`, local identity та же | Текущее фактически применённое local значение без скачка |
| Reload/remount, identity та же | Значение из localStorage этого key |
| Смена user/route/card slot | Значение нового key; ничего из прежнего key не переносится |
| Storage недоступен | Session-only значение действует, показывается принятое предупреждение |
Старый ключ не удаляется и не переписывается. Диапазон 50…300%, шаг 5%, Reset
100% и отсутствие влияния на редакторскую геометрию остаются как в #437.
## 8. Lifecycle, права и async-операции
Runtime хранит snapshot identity/capabilities: user id (fallback name только по
действующему контракту), route/host/slot, kiosk и право shared edit. На каждом
`updated/connect` сравнивается snapshot, а не только storage key.
Следующие события закрывают active picker и summary dialog, отбрасывают draft,
снимают timer/listener/observer и инвалидируют index/metrics/live-dependency
snapshot: реальный route leave, disconnect, reconnect после disconnect, смена
user, смена admin/write capability, переход в/из kiosk. Local preferences на
диске при этом не удаляются.
Каждая lazy import/save/reload/confirmation операция получает generation token.
Поздний результат старой generation может завершить уже отправленную серверную
запись, но не может снова открыть dialog, применить старый localShow/local scale,
показать сообщение в новой пользовательской сессии или вернуть старые entity
dependencies. Новый контекст перечитывает authoritative config и собственные
local preferences обычным путём.
Обычная смена размера без disconnect и без смены identity не закрывает draft.
Page visibility hidden только останавливает clock по прежнему контракту и сама
по себе не считается уходом со страницы.
## 9. Адаптивная форма
Полная admin-форма и local-only форма используют одну responsive основу.
На ширине dialog body, где три колонки не помещаются, каждый размер становится
двухстрочным блоком: подпись и процент в первой строке, полноширинный slider во
второй; Reset переносится отдельной доступной строкой. На широкой форме
допустима существующая компактная компоновка.
Обязательные свойства на 320 и 390 CSS px, light/dark, RU/EN, admin/household/
kiosk и браузерном увеличении текста 200%:
- нет горизонтального overflow у body/footer;
- подписи, проценты 50/100/300%, предупреждения и Save/Cancel/Reset полностью
видимы либо достижимы вертикальным scroll;
- footer не перекрывает последний control, safe-area учитывается;
- hit target не меньше 44 px, focus outline не обрезан;
- форма не уменьшает текст для вмещения и не меняет план/overlay layout.
## 10. Единый контракт обычной записи панели
В backend выделяется единая функция подготовки обычного config candidate. Она
до `CONFIG_SCHEMA` сохраняет прежний `settings.summary_panel` при omission, а
после schema выполняет change-aware reference validation относительно exact
stored previous и множества entity, читаемых текущим пользователем.
Функция вызывается одинаково из `config/set` и `plan/optimize`. Optimize получает
readable entity ids по той же permission-политике, что config/set; вычисление
не переносится на event loop и остаётся внутри существующего executor/write
lock порядка. Идущие следом wall/marker/opening/junction guards не ослабляются.
Reference validation сохраняет правила #437:
- неизменённая старая broken entity/space разрешена;
- смена только label/title/order не превращает старую ссылку в новую;
- новый/заменённый entity id обязан присутствовать в readable hass states;
- новый/заменённый space scope обязан ссылаться на существующее пространство;
- future version сохраняется lossless и inert, без применения v1 semantics.
## 11. Writer matrix
| Операция | Authority для `settings.summary_panel` | Ссылки и независимые namespaces |
|---|---|---|
| `config/set` | Candidate; omission сохраняет stored, `blocks: []` — явная пустота | Change-aware v1 refs; остальные settings/unknown fields lossless |
| `plan/optimize` | Тот же ordinary contract, что `config/set` | Те же refs/permissions; Optimize не становится обходом validation |
| Full import/restore | Архив authoritative, включая отсутствие namespace | Structural schema; перенос в другую HA может оставить refs broken; остальные imported namespaces заменяются архивом |
| Space/plan-only import | Exact namespaces текущей установки | Source global settings игнорируются; remap касается только импортируемого space graph |
| Optimize Undo | Exact config из optimize backup | Не переоценивает восстановленную старую ссылку как новый выбор |
| Full Import Undo | Exact config до импорта | Возвращает все прежние namespaces вместе, без preserve-current merge |
| Создание/копирование пространства | Candidate строится поверх exact current config | Панель и все независимые settings сохраняются; copy не переназначает её scope |
| Удаление пространства | Ordinary config candidate | Неизменённый scope удалённого space становится старой broken-ссылкой/warning, а не скрытым сбросом панели |
Для каждой строки тест использует минимум три sentinel namespace: текущий
`summary_panel`, другое известное setting и неизвестное расширяющее поле. Там,
где операция authoritative, проверяется их полная замена/восстановление; там,
где операция частичная, exact сохранение. Полный импорт остаётся единственным
исключением preserve-on-omission обычной записи.
## 12. Модель данных и совместимость
Новых persisted-полей нет; `settings.summary_panel.version` остаётся 1. Store и
plan model version не повышаются. Local key/shape и legacy seed не меняются.
Старый frontend продолжает игнорировать и round-trip-ить namespace через
backend preservation. Новый frontend со старым backend сохраняет действующий
local-only режим и не отправляет shared write. Future summary version остаётся
lossless/inert. Full archive поведение, privacy projection и support package из
#437 не меняются.
Нельзя чинить Optimize удалением неизвестных fields, принудительным добавлением
derived defaults или превращением отсутствующего namespace в persisted default.
## 13. i18n
Новые пользовательские строки ограничены active picker: доступное имя поиска,
подсказка уточнить запрос при >100 совпадениях и доступное действие закрытия,
если оно не выражается существующим shell. Они добавляются с полным паритетом
в lazy `summary-panel-i18n.ts` для RU/EN/DE/FR.
Существующие названия системных показателей, warning broken source,
Save/Cancel/Reset, storage warning и проценты не переименовываются. Friendly
name, entity id и state values не переводятся House Plan.
## 14. Производительность
Канонический witness: 3 карточки, 10 блоков × 20 строк (200 rows) в каждой и
10 000 `hass.states`. Измерение выполняется на одном runner до/после, после
preload lazy editor chunk, не по одиночному ручному Windows timing.
Обязательные границы:
- одновременно не более одного active picker на форму;
- не более 100 entity result rows + 3 system rows + одна current-broken row в
DOM одного picker; закрытые 200 строк не содержат entity option-list;
- обычный state-value update даёт 0 index rebuild; hot add/remove/rename — ровно
один rebuild на карточку/generation;
- после трёх warmups и не менее 20 samples: открытие уже загруженной формы
p95 ≤250 ms, ввод в уже открытом picker p95 ≤50 ms;
- state burst не добавляет long task >50 ms из-за закрытой формы/picker;
- existing initial View gzip budget и lazy boundary #437 не ослабляются.
Если абсолютный timing нестабилен на runner, структурные DOM/rebuild assertions
всё равно блокирующие; budget не удаляется, а расследуется/калибруется отдельным
решением владельца до S7.
## 15. Критерии приёмки
**AC1 — ограниченный picker сохраняет полный выбор.** На fixture 200 rows /
10 000 states каждая читаемая entity находится по friendly name или entity id,
системные и текущий broken source доступны, но DOM bounds из §14 соблюдены.
Доказательство: pure search/index unit + browser smoke реальными pointer,
keyboard и touch actions; mutation возврата `entities.map()` в каждую строку
делает DOM assertion красным.
**AC2 — draft и stable row ownership корректны.** Выбор меняет только active
value по stable ids, reorder не перенаправляет его, delete закрывает picker;
Escape/click-out не меняют draft, общий Cancel отбрасывает изменения.
Доказательство: frontend unit + browser smoke; index-based active-row mutation
красная.
**AC3 — индекс не пересобирается от state value.** Обычное обновление значения
не сортирует index, hot add/remove/friendly-name change отражаются один раз,
user/reconnect не использует прежний index.
Доказательство: instrumented unit/smoke counters; unconditional rebuild mutation
красная.
**AC4 — local scales переживают repeated same-key setConfig.** Значения
200%/150% до и после нескольких `setConfig` фактически и в форме остаются теми
же; reload/remount читает их из своего key, новая identity не наследует.
Доказательство: runtime unit + two-card/two-user browser smoke; legacy overwrite
mutation красная.
**AC5 — lifecycle не переносит старый UI state.** Route leave, disconnect →
reconnect, user/permission/kiosk change закрывают modal/active picker, удаляют
draft/timers/listeners и старые dependencies. Late lazy/save/reload completion
не меняет новую generation.
Доказательство: fake-timer/promise unit и reuse browser smoke; снятие generation
guard краснит late-completion case.
**AC6 — mobile/local-only формы доступны.** RU/EN, 320/390 px, light/dark,
200% text, admin/household/kiosk показывают доступные роли поля, проценты и
действия без горизонтального overflow и с hit targets ≥44 px.
Доказательство: bounding-box smoke + reviewed fresh screenshots/golden;
возврат фиксированной трёхколоночной grid красный.
**AC7 — config/set и Optimize имеют один ordinary contract.** Omission
сохраняет v1/future panel exact; `blocks: []` сохраняется как пустая; новые
unreadable entity/missing space отклоняются одинаково; старые broken refs и
независимые settings round-trip-ятся.
Доказательство: pure backend matrix + HA websocket tests обоих endpoints;
удаление helper call из Optimize красное через обязательный mutation.
**AC8 — import/copy/undo следуют writer matrix.** Full import authoritative,
space/plan-only import и copy сохраняют target namespaces, оба Undo exact
восстанавливают backup, delete space не стирает панель.
Доказательство: backend import/undo harness + frontend space-copy candidate
unit; rebuild-settings mutation красная.
**AC9 — performance witness проходит.** DOM/rebuild bounds и p95/long-task
границы §14 выполнены на точном SHA без ослабления других budgets.
Доказательство: новый профиль/артефакт performance harness и checked-in budget;
code review фиксирует команду, runner и before/after.
**AC10 — совместимость, lazy boundary и docs сохранены.** Нет schema/model bump,
старый/new frontend/backend и future version ведут себя по §12; initial View не
загружает editor/picker; docs описывают фактический контракт.
Доказательство: compatibility/lazy manifest tests, `npm run bundle:budget`,
`node scripts/check-docs.mjs`, ревью diff schemas и документации.
## 16. План реализации и автотестов
1. Вынести pure индекс/поиск/лимит active source picker; сначала добавить
unit-тесты доступности всех entities и structural DOM bounds.
2. Перевести editor на stable-id active picker и удалить global rows filter;
добавить клавиатуру/touch/focus smoke.
3. Сделать summary runtime владельцем local scales после identity load;
связать async callbacks с lifecycle generation и вызвать reset из route
lifecycle карточки.
4. Перестроить responsive local-size grid; снять bounding boxes и fresh visual
evidence по матрице AC6.
5. Обобщить backend ordinary summary guard для config/set/Optimize, не меняя
порядок остальных validators; добавить readable entity snapshot Optimize.
6. Добавить writer-matrix tests для import/undo/copy/delete и sentinels
независимых namespaces.
7. Добавить performance fixture 200/10k/3, checked-in budget/result schema и
diff-aware harness routing; перед beta прогнать полный performance/golden/
smoke по runbook.
Ожидаемые файлы/модули:
- `src/summary-panel-editor.ts`, `src/summary-panel-runtime-loaded.ts`,
`src/summary-panel-style.ts`, `src/summary-panel-host.ts`,
`src/houseplan-card.ts`, новый pure picker helper при необходимости;
- `src/summary-panel-i18n.ts` (RU/EN/DE/FR);
- `custom_components/houseplan/validation.py`,
`custom_components/houseplan/websocket_api.py`, import/export только если
matrix test выявит фактическое расхождение;
- `test/summary-panel.test.mjs`, browser smoke/performance fixture и budgets,
`tests_backend/test_summary_panel.py`, websocket/import tests;
- `docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`, `docs/TESTING.md`,
user guide RU/EN и оба changelog.
## 17. Риски и меры
- **Лимит списка делает entity недостижимой.** Лимит только на DOM, поиск идёт
по полному index; exact id всегда находится.
- **Stale picker пишет не в ту строку.** Active owner — stable ids; отсутствие
owner закрывает поверхность без mutation.
- **Смена user во время save.** Generation guard отделяет server outcome от
local/UI adoption; новый контекст перечитывает authority.
- **Optimize расходится с config/set.** Один helper и paired endpoint matrix,
а не две похожие последовательности.
- **Full restore ошибочно сохраняет текущую панель.** Authoritative операции
проверяются противоположными sentinels к ordinary omission.
- **Мобильный фикс ломает desktop.** Responsive breakpoint выбирается по
доступной ширине dialog body; wide и narrow golden обязательны.
- **Perf-тест ложно зелёный.** Структурный count блокирует независимо от timing,
timing сравнивается на одном runner и mutation возвращает N×M.
## 18. Откат
Реализацию можно откатить одним issue-коммитом: persisted schema и local key не
меняются, миграции данных нет. Откат возвращает медленный picker, scale/lifecycle
и Optimize-пробелы, но не делает сохранённый namespace нечитаемым. Нельзя
откатывать только backend guard либо только generation token, оставляя тесты и
документацию от нового контракта.
## 19. Release-артефакты
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: быстрый выбор источника,
сохранение размеров, mobile/lifecycle и безопасный Optimize;
- `docs/USER-GUIDE.md` и `.ru.md`: active picker и local settings без новых
пользовательских настроек;
- `docs/ARCHITECTURE.md`: index/lifecycle generation и единый writer guard;
- `docs/CONFIG-COMPATIBILITY.md`: полная writer matrix;
- `docs/TESTING.md`: команды, fixtures, DOM/p95 budgets и negative witnesses;
- свежие reviewed screenshots/golden wide/narrow light/dark и RU/EN enlarged;
- performance artifact 200 rows/10k states/3 cards на точном SHA;
- security report не требуется: права не расширяются, но permission matrix
входит в backend/browser tests;
- перед beta обязательны golden, smoke, performance и Linux CI HA harness по
runbook; в цикле реализации — typecheck, unit и build.
## 20. Принятые технические предположения
Эти пункты не меняют видимый продуктовый контракт и могут быть скорректированы
ревьюером при сохранении AC:
1. Bounded result limit — 100 entity rows; index может быть per-runtime либо
безопасно разделён между карточками по immutable composition signature.
2. Active picker реализуется внутри lazy summary editor без зависимости от
нестабильных внутренних HA components; конкретный DOM primitive свободен.
3. Friendly-name index инвалидируется детерминированной signature, а state
values не входят в неё; допускается более дешёвый эквивалентный механизм.
4. Lifecycle generation увеличивается на disconnect и identity/capability
change; route leave вызывает явный summary reset до warm snapshot.
5. Ordinary backend helper располагается в `validation.py` либо рядом с WS
writer; важно единственное исполнение контракта, не имя функции.
6. Full import missing refs остаются допустимыми transfer broken refs и
обнаруживаются формой по правилам #437; новый import-warning UI не вводится.
7. Точные имена новых tests/scripts/budget files можно выбрать при реализации;
witness, отрицательные мутации и границы AC менять нельзя без нового решения.