# ТЗ #406 — Полиш v1.70.0-beta.2: мёртвые строки, роль диалога, непокрытая ветка, растущий снапшот - Issue: https://github.com/Matysh/houseplan-card/issues/406 - Приоритет: P3, polish; полный трек — три несвязанные поверхности (словари i18n, `hp-dialog`, снапшоты переезда area), критерий «одна поверхность» для `small` не выполняется — прецеденты #385, #400 - Ревизия: 4 (2026-09-01), после SPEC-REVIEW-406-r3 ## Сценарий Четыре мелочи, оставшиеся после крупной работы #32 и #126. Переводчик поддерживает строки, которых никто не показывает. Незрячий пользователь слышит заголовок диалога удаления, но не слышит, что именно исчезнет. Смоки проверяют запасную ветку диалога, а не ту, что работает у людей. Снапшот переезда копит записи об устройствах, которых давно нет. ## Что человек увидит до и после **До**: (1) 52 строки в четырёх словарях никому не показываются, но их переводят и поддерживают; (2) скринридер объявляет диалог удаления как обычный `dialog` и не читает текст последствий; (3) HA-ветка диалога не покрыта ни одним смоком — проверяется только запасная; (4) записи снапшота исчезнувших устройств живут вечно. **После**: словари содержат только используемое, и это проверяется гейтом; подтверждение опасного действия — удаления или разблокировки — объявляется как `alertdialog` вместе с последствиями и использует собственную нативную оболочку House Plan вместо HA-хрома; смок ходит по обеим веткам диалога; снапшот убирает записи устройств, которых достоверно нет. ## Проблема и контракты по пунктам ### (а) Мёртвые строки в словарях Сверка ключей `src/i18n/en.json` с литералами и производными ключами в `src/*.ts` (динамические шаблоны вида `` `furn.cat_${id}` `` и механически производные суффиксы учтены): | Ключей в словаре | Есть потребитель | Не используется никак | |---|---|---| | 1201 | 1188 | **13** | Из тринадцати семь — предмет issue (`confirm.delete_draft`, `confirm.delete_draft_segment`, `confirm.delete_plan`, `confirm.delete_room`, `confirm.delete_space`, `confirm.remove_marker`, `confirm.unlock`): это односоставные строки браузерного `confirm()`, которые #32 заменил парами `…_title` + `…_body`. Остальные шесть — тот же узор в других семействах: - `marker.display_hint`, `marker.display_hint_icon` — вытеснены `marker.display_hint_badge/_icon_ripple/_value/_static_icon` (`houseplan-card.ts:404-407`); - `history.delete_room` — вытеснен парой `history.delete_room_keep_walls` / `_with_walls` (`houseplan-editor-runtime.ts:5953`); - `markup.delete` — вытеснен `markup.delete_room` (`:11877`); - `title.markup`, `history.partition_add` — остатки прежнего инструмента стен. **Важное динамическое семейство, которое удалять нельзя**: все 19 ключей `*.help.aria` используются фабрикой `_help('literal.help')`. В `houseplan-editor-runtime.ts:1194` и onboarding-ветке доступный ключ выводится как `` `${key}.aria` ``; для каждого из 19 базовых `*.help` есть литеральный вызов `_help()`. Гейт обязан распознавать эту производную пару так же, как шаблоны `furn.cat_*`, а не требовать буквального появления полного aria-ключа. **Контракт**: словарь содержит только то, что кто-то показывает. Проверяется гейтом, который строит множество литералов, динамических префиксов и механически производных ключей из `src/` и объявляет остальное мёртвым. **Скоуп сознательно шире тела issue, и вот почему**: гейт нельзя ввести наполовину. Если он судит весь словарь — он покраснеет на всех тринадцати; если только на `confirm.*` — он не поймает следующий такой ключ, а именно за этим его и заводят. Поэтому в задаче удаляются все доказанно мёртвые ключи, а не только семь. Живые производные семейства, включая `*.help.aria`, описываются правилом извлечения, а не временным списком исключений. **Известная коллизия, которую обязана разрешить реализация**: `test/unified-wall-tool-source.test.mjs:32` **требует наличия** `history.partition_add` в трёх словарях (`assert.equal(typeof locale[key], 'string')`), хотя в `src/` он не используется. Одно из двух: либо ключ живой и реализация показывает потребителя, либо он удаляется вместе с этой строкой теста. Молча оставить нельзя — гейт и тест будут противоречить друг другу. `test/i18n.test.mjs` мёртвые ключи не ловит по устройству: он проверяет паритет словарей, непустоту значений и совпадение плейсхолдеров — то есть что словари одинаковы, а не что ключи нужны. ### (б) Роль диалога и текст последствий `src/hp-dialog.ts:459-462`, запасная ветка: ```html ``` `aria-describedby` в файле не встречается ни разу. Скринридер объявит заголовок и не объявит предложение, ради которого диалог и показывают («Устройство исчезнет с плана вместе с настройками»). **Контракт**: любой запрос `hp-confirm` — и `HpConfirmKind.destructive`, и `HpConfirmKind.warning` — объявляется как `alertdialog`, и его текст последствий связан с диалогом через `aria-describedby`. `warning` здесь не означает обычное информационное окно: единственный такой запрос — разблокировка двери (`houseplan-card.ts:13042-13046`) с последствиями из `confirm.unlock_body`, то есть санкционированная `SCOPE.md` поверхность опасного действия. Роль **выбирается, а не меняется глобально**: `hp-dialog` несёт и обычные диалоги (маркер, калибровка пылесоса), для которых `alertdialog` неверен — эта роль обязывает screen reader прервать чтение. Обычным считается диалог, который не представлен `hp-confirm`; внутри `hp-confirm` ветки «обычного» вида нет. Проверена ровно закреплённая проектом версия `home-assistant-frontend==20260729.7` из `tests_backend/requirements.txt`. [Её `src/components/ha-dialog.ts`](https://github.com/home-assistant/frontend/blob/20260729.7/src/components/ha-dialog.ts) показывает, что публичный API `ha-dialog` содержит свойства `.ariaDescribedBy` и `.type: "alert" | "standard"`; первое передаётся внутреннему `wa-dialog` как `aria-describedby`. Однако `type` только отражается на host и используется в CSS: в `render()` нет ни `this.type`, ни передачи `role` внутреннему `wa-dialog`. Поэтому привязка `.type="alert"` или `role` на внешнем `` **не считается** доказательством роли настоящего диалога. Практически: `hp-dialog` получает семантический признак alert/обычный и id описания; `hp-confirm` передаёт alert-семантику для обоих своих kind и связывает с ней `.danger-confirm-body`. Для обычного окна при зарегистрированном `ha-dialog` сохраняется HA-ветка с `.ariaLabelledBy` и, когда описание задано, `.ariaDescribedBy`. Для alert-подтверждения `hp-dialog` намеренно выбирает нативный `` даже при наличии `ha-dialog`: это единственный поддерживаемый из нашего дерева путь, который гарантирует роль и не лезет во внутренний shadow DOM стороннего компонента. При отсутствии `ha-dialog` используется та же нативная ветка. Внешность нативной ветки уже является поддерживаемой частью `hp-dialog`; отдельного CSS-дубля не появляется. ### (в) HA-ветка диалога не покрыта `hp-dialog.ts:240`: `this._useHaDialog = !!customElements.get('ha-dialog')`. Единственный смок во всём `demo/`, определяющий `ha-dialog`, — `smoke_free_walls.mjs:20-22`, и он же на `:16` глушит `_confirmDanger`, то есть диалог подтверждения там не рисуется. В смоках подтверждений (`smoke_danger_confirmation.mjs`, `smoke_danger_confirm_branches.mjs`) слова `ha-dialog` нет. Итог: покрыта только запасная ветка, существующая ради standalone-демо, а не та, что работает у пользователя. **Контракт**: смок с зарегистрированным `ha-dialog` проверяет обе реально достижимые развилки: обычный `hp-dialog` пользуется HA-компонентом, а alert-подтверждение остаётся нативным и сохраняет `alertdialog`, описание, начальный фокус на «Отмена» и Esc → `false`. Смок без `ha-dialog` проверяет те же инварианты нативного подтверждения. Прецедент стаба `ha-dialog` уже написан и лежит в соседнем файле; стаб обязан моделировать публичные свойства `.ariaLabelledBy`, `.ariaDescribedBy` и `.type`, а не произвольную роль во внутреннем shadow DOM. ### (г) Снапшот копит записи исчезнувших устройств `applyAreaRelocationResolution` (`device-area-relocation.ts`) итерирует только по `resolution.decisions`, а решения строятся по текущему списку устройств карточки. Устройство, пропавшее из Home Assistant и не имеющее маркера, в decisions не попадает — его запись `marker_area_snapshot[id]` не будет ни обновлена, ни удалена никогда. Единственная уборка — `removeMarkerAreaSnapshots` при явном редактировании или удалении маркера (`houseplan-editor-runtime.ts:8326, 8448`), то есть по действию человека. **Контракт**: запись снапшота живёт, пока живёт устройство или маркер, к которому она относится. Запись, не соответствующая ни одному из них, убирается — **но только когда реестр авторитетен**. Это условие не факультативное: во время перезапуска Home Assistant или до первой полной загрузки реестра устройства временно отсутствуют, и уборка «по факту отсутствия» стёрла бы законные записи — ровно тот класс потери, который чинит #403. **Побочный пункт того же места**: `markerAreaSnapshotOf` режет вход `.slice(0, MARKER_AREA_SNAPSHOT_LIMIT)` (20 000) при **чтении**. Порядок ключей — порядок вставки, поэтому при переполнении молча отбрасываются самые свежие записи, а не самые старые: мусор вытесняет живое. Правило переворачивается заодно — при обрезке сохраняются последние записи. ## Скоуп / не-скоуп **В скоупе**: удаление доказанно мёртвых ключей из четырёх словарей и гейт, который не даёт им вернуться; роль и `aria-describedby` в `hp-dialog` и `hp-confirm`; смок выбора HA/нативной ветки; уборка снапшотов при авторитетном реестре и порядок обрезки. **Не в скоупе**: изменение содержимого или оформления help-подсказок — их 19 пар `.help` + `.help.aria` уже подключены и сохраняются; содержимое и оформление диалога (#32); переезд area и его отказы (#403); порог свидетелей (#405 — соседняя строка того же файла, другой контракт); рост `known_devices` — тот же класс, но своя поверхность; история screenshot-acceptance — дефект уже исправлен в #409 коммитом `8119c523`, включая сохранение всего предыдущего блока и `lastWriteWasFingerprintOnly`, поэтому #406 не меняет и не дублирует этот контракт. ## UX Пользователь скринридера слышит подтверждение удаления или разблокировки как оповещение вместе с текстом последствий. Для всех пользователей эти восемь точек входа (семь `destructive` и одна `warning`) в реальном Home Assistant переходят с оболочки `ha-dialog` на уже поддерживаемую нативную оболочку `hp-dialog`: возможны небольшие отличия заголовка и анимации. Содержимое, порядок и подписи кнопок, цвет destructive/warning, запрет случайного закрытия, начальный фокус на «Отмена» и Esc → отмена не меняются. Обычные диалоги вне `hp-confirm` сохраняют HA-хром. Это осознанный видимый компромисс для гарантированной роли `alertdialog` на закреплённой версии HA, а не обещание pixel parity. Терминология берётся из `docs/USER-GUIDE.ru.md`, новых формулировок не вводится. ## Модель данных и миграция Формат `marker_area_snapshot` не меняется — меняется момент уборки записей. Миграции нет: запись, оставшаяся от исчезнувшего устройства, исчезает при первом авторитетном проходе. Удаление ключей словаря — не миграция конфига: ключи не хранятся в пользовательских данных. ## i18n Новых строк нет; тринадцать мёртвых ключей удаляются из каждого из четырёх словарей — 52 строки суммарно. Все 19 пар `*.help` + `*.help.aria` сохраняются. Паритет сохраняется — удаление синхронное во всех четырёх словарях. ## Критерии приёмки - **AC1**. Ни один ключ словаря не остаётся без потребителя: гейт строит множество литералов, динамических префиксов и производных ключей из `src/` и краснеет на любом ключе, не покрытом этими правилами. Доказательство: гейт зелёный после чистки. - **AC2**. **Отрицательный прогон обязателен**: возвращённый в словарь мёртвый ключ роняет гейт. Доказательство: мутант через штатный раннер. - **AC3**. Гейт не обвиняет динамические и производные ключи: `furn.cat_*`, `furn.sym_*`, `wall_model.reason.*`, `resize.disabled.*`, `junction.limit_*`, `decor.*`, а также все 19 `*.help.aria`, получаемые из литеральных `_help('*.help')`, остаются зелёными. Доказательство: гейт зелёный на текущем словаре после удаления ровно 13 доказанно мёртвых ключей. - **AC4**. Коллизия с `test/unified-wall-tool-source.test.mjs` разрешена явно: либо показан потребитель `history.partition_add`, либо ключ удалён вместе со строкой теста. Доказательство: оба гейта зелёные одновременно. - **AC5**. Паритет словарей сохранён: `test/i18n.test.mjs` зелёный без правок его утверждений, в четырёх словарях одинаковый набор ключей. - **AC6**. Оба вида `hp-confirm` — `destructive` (например, удаление) и `warning` (разблокировка двери) — объявляются как `alertdialog`, а их текст последствий связан через `aria-describedby`. Доказательство: смок читает атрибуты и доступное описание у нативного диалога для обоих kind, в том числе когда `ha-dialog` зарегистрирован. - **AC7**. Диалоги вне `hp-confirm` (маркер, калибровка) остались `dialog` и не получают alert-семантику. Доказательство: при зарегистрированном стабе обычный `hp-dialog` рендерит `ha-dialog`, передаёт ему `.ariaLabelledBy` и при наличии описания `.ariaDescribedBy`; нативного диалога в этой ветке нет. - **AC8**. Инварианты подтверждения проверены в обоих окружениях: с зарегистрированным `ha-dialog` и без него диалог показан, «Отмена» резолвит `false`, начальный фокус на «Отмена», Esc отменяет. Доказательство: смок подтверждений с конформным стабом `ha-dialog` (прецедент `smoke_free_walls.mjs:20-22`) плюс существующий прогон без стаба; в первом случае дополнительно доказано, что alert не уходит в HA-ветку. - **AC9**. Запись снапшота исчезнувшего устройства убирается при авторитетном реестре. Доказательство: смок — устройство пропадает из `_devices`, после прохода записи в `marker_area_snapshot` нет. - **AC10**. При неавторитетном реестре записи **не** трогаются. Доказательство: тот же смок, ветка `authoritative: false` — запись на месте. - **AC11**. При обрезке снапшота сверх лимита сохраняются последние записи, а не первые. Доказательство: юнит на `markerAreaSnapshotOf` с входом больше лимита. - **AC12**. Бюджет initial не растёт: удаление тринадцати ключей из словарей уменьшает его, роль и `aria-describedby` добавляют единицы байт. Доказательство: `npm run bundle:budget` до и после. ## План автотестов **Гейт** (`test/i18n-dead-keys.test.mjs`, новый): 1. Множество ключей минус литералы минус динамические префиксы — пусто (AC1, AC3). **Browser smoke** (`demo/smoke_danger_confirm_branches.mjs` — дополнение; файл уже создан #402 и держит свои фикстуры): 2. Роль и `aria-describedby` у destructive- и warning-подтверждения без `ha-dialog` (AC6, AC8). 3. Конформный стаб `ha-dialog` принимает публичные `.ariaLabelledBy`, `.ariaDescribedBy` и `.type`: обычный `hp-dialog` выбирает HA-ветку, а оба alert-подтверждения — нативную; фокус, Esc и результат не расходятся (AC6–8). **Browser smoke** (`demo/smoke_area_relocation.mjs` — дополнение): 4. Устройство исчезло из `_devices`, реестр авторитетен → запись убрана (AC9). 5. То же при `authoritative: false` → запись на месте (AC10). **Юниты**: 6. `markerAreaSnapshotOf` на входе больше лимита — сохранены последние (AC11). **Мутанты** (`scripts/mutation-gate.mjs`): - `i18n-dead-key-returns`: вернуть `confirm.unlock` в словари → гейт краснеет; - `confirm-dialog-loses-alertdialog`: вернуть `role="dialog"` подтверждению либо разрешить alert уйти в зарегистрированный `ha-dialog` → смок AC6 краснеет; - `area-snapshot-cleanup-ignores-authority`: убрать условие авторитетности → смок AC10 краснеет. Третий мутант важнее первых двух: он проверяет не наличие уборки, а её осторожность — то есть ровно то свойство, потеря которого стоит пользователю данных. ## Риски - **Гейт мёртвых ключей даст ложные обвинения на будущих динамических ключах.** Разработчик, написавший `` _t(`new_family.${x}`) ``, получит красный гейт на все ключи семейства. Смягчение: гейт извлекает префиксы из исходников автоматически (48 семейств уже покрыты), а сообщение об отказе называет ключ и подсказывает обе законные дороги — использовать или удалить. - **`alertdialog` меняет поведение скринридера сильнее, чем кажется**: он прерывает текущее чтение. Смягчение: роль назначается только двум видам `hp-confirm` (`destructive` и `warning`); обычные диалоги вне подтверждения проверяются отдельным AC. - **Будущая версия HA может исправить `ha-dialog.type="alert"`.** Текущий выбор нативной ветки останется корректным, только консервативным. Переход на HA alert в будущем — отдельное изменение после проверки новой закреплённой версии; #406 не патчит shadow DOM и не распознаёт приватную структуру `wa-dialog`. - **Уборка снапшотов может стереть законные записи.** Это ровно тот класс, что чинит #403. Смягчение: уборка только при авторитетном реестре, AC10 проверяет обратный случай напрямую, мутант закреплён. - **Гейт примет производный ключ за мёртвый.** Наиболее опасный текущий пример — 19 живых `*.help.aria`, полный ключ которых собирается в `_help()`. Смягчение: производное правило `.help` → `.help.aria` является частью AC3 и проверяется всем текущим набором, а не ручным исключением отдельных имён. ## Откат Четыре правки независимы и откатываются по отдельности: удаление ключей — обратным коммитом, выбор нативной alert-ветки и `aria-describedby` — одним локальным изменением `hp-dialog`/`hp-confirm`, HA-smoke — удалением сценария, уборка снапшотов — снятием условия. ## Release-артефакты - `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: пункт о том, что подтверждение удаления или разблокировки теперь использует нативную оболочку House Plan и объявляется скринридером как `alertdialog` вместе с последствиями (User-Visible: yes). Остальные три пункта — внутренние. - Скриншоты не меняются: диалог в статике не открыт.