Files
houseplan-card/docs/specs/406-beta2-polish.md
T
2026-09-01 16:32:25 +00:00

344 lines
28 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.
# ТЗ #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
<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`](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` на внешнем
`<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`, новый):
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). Остальные три пункта — внутренние.
- Скриншоты не меняются: диалог в статике не открыт.