28 KiB
ТЗ #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, запасная ветка:
<dialog role="dialog" aria-modal="true" aria-labelledby=${this._titleId} …>
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
показывает, что публичный API ha-dialog содержит свойства .ariaDescribedBy и
.type: "alert" | "standard"; первое передаётся внутреннему wa-dialog как
aria-describedby. Однако type только отражается на host и используется в
CSS: в render() нет ни this.type, ни передачи role внутреннему
wa-dialog. Поэтому привязка .type="alert" или role на внешнем
<ha-dialog> не считается доказательством роли настоящего диалога.
Практически: hp-dialog получает семантический признак alert/обычный и id
описания; hp-confirm передаёт alert-семантику для обоих своих kind и связывает
с ней .danger-confirm-body. Для обычного окна при зарегистрированном
ha-dialog сохраняется HA-ветка с .ariaLabelledBy и, когда описание задано,
.ariaDescribedBy. Для alert-подтверждения hp-dialog намеренно выбирает
нативный <dialog role="alertdialog"> даже при наличии 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, новый):
- Множество ключей минус литералы минус динамические префиксы — пусто (AC1, AC3).
Browser smoke (demo/smoke_danger_confirm_branches.mjs — дополнение; файл
уже создан #402 и держит свои фикстуры):
- Роль и
aria-describedbyу destructive- и warning-подтверждения безha-dialog(AC6, AC8). - Конформный стаб
ha-dialogпринимает публичные.ariaLabelledBy,.ariaDescribedByи.type: обычныйhp-dialogвыбирает HA-ветку, а оба alert-подтверждения — нативную; фокус, Esc и результат не расходятся (AC6–8).
Browser smoke (demo/smoke_area_relocation.mjs — дополнение):
- Устройство исчезло из
_devices, реестр авторитетен → запись убрана (AC9). - То же при
authoritative: false→ запись на месте (AC10).
Юниты:
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). Остальные три пункта — внутренние.- Скриншоты не меняются: диалог в статике не открыт.