mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
344 lines
28 KiB
Markdown
344 lines
28 KiB
Markdown
# ТЗ #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). Остальные три пункта — внутренние.
|
||
- Скриншоты не меняются: диалог в статике не открыт.
|