docs: specify the beta.2 polish batch (#406)

User-Visible: no
Issue: #406
This commit is contained in:
Codex
2026-09-01 16:32:25 +00:00
committed by claude[bot]
parent 8ac8f0105a
commit 15ff625964
+304
View File
@@ -0,0 +1,304 @@
# ТЗ #406 — Полиш v1.70.0-beta.2: мёртвые строки, роль диалога, непокрытая ветка, растущий снапшот
- Issue: https://github.com/Matysh/houseplan-card/issues/406
- Приоритет: P3, polish; полный трек — четыре несвязанные поверхности (словари
i18n, `hp-dialog`, снапшоты переезда area, приёмка скриншотов), критерий
«одна поверхность» для `small` не выполняется — прецеденты #385, #400
- Ревизия: 1 (2026-08-31)
## Сценарий
Пять мелочей, оставшихся после крупной работы #32 и #126. Переводчик
поддерживает строки, которых никто не показывает. Незрячий пользователь слышит
заголовок диалога удаления, но не слышит, что именно исчезнет. Смоки проверяют
запасную ветку диалога, а не ту, что работает у людей. Снапшот переезда копит
записи об устройствах, которых давно нет. Повторная приёмка скриншотов стирает
след того, зачем их перерисовывали.
## Что человек увидит до и после
**До**: (1) 28 строк в четырёх словарях никому не показываются, но их переводят
и поддерживают; (2) скринридер объявляет диалог удаления как обычный `dialog` и
не читает текст последствий; (3) HA-ветка диалога не покрыта ни одним смоком —
проверяется только запасная; (4) записи снапшота исчезнувших устройств живут
вечно; (5) повторная приёмка кадров затирает список «что меняли».
**После**: словари содержат только используемое, и это проверяется гейтом;
подтверждение разрушающего действия объявляется как `alertdialog` вместе с
последствиями; смок ходит по обеим веткам диалога; снапшот убирает записи
устройств, которых достоверно нет; след приёмки не теряется.
## Проблема и контракты по пунктам
### (а) Мёртвые строки в словарях
Сверка ключей `src/i18n/en.json` с литералами в `src/*.ts` (динамические
шаблоны вида `` `furn.cat_${id}` `` учтены — их 48 семейств):
| Ключей в словаре | Использовано литералом | Не используется никак |
|---|---|---|
| 1201 | 937 | **30** |
Из тридцати семь — предмет 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`.
Остальные двадцать три — тот же узор в других семействах:
- 13 ключей `*.help.aria` (`marker.*`, `space.*`, `gs.*`, `device_inbox.*`) —
задел, не подключённый ни одним потребителем;
- `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` — остатки прежнего инструмента стен.
**Контракт**: словарь содержит только то, что кто-то показывает. Проверяется
гейтом, который строит множество литералов и динамических префиксов из `src/`
и объявляет остальное мёртвым.
**Скоуп сознательно шире тела issue, и вот почему**: гейт нельзя ввести
наполовину. Если он судит весь словарь — он покраснеет на всех тридцати; если
только на `confirm.*` — он не поймает следующий такой ключ, а именно за этим
его и заводят. Поэтому в задаче удаляются все доказанно мёртвые ключи, а не
только семь. Временный список исключений не заводится: это долг, который никто
не разберёт.
**Известная коллизия, которую обязана разрешить реализация**:
`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` в файле не встречается ни разу. Скринридер объявит заголовок
и не объявит предложение, ради которого диалог и показывают («Устройство
исчезнет с плана вместе с настройками»).
**Контракт**: диалог, требующий решения о разрушающем действии, объявляется как
`alertdialog`, и его текст последствий связан с диалогом через
`aria-describedby`. Роль **выбирается, а не меняется глобально**: `hp-dialog`
несёт и обычные диалоги (маркер, калибровка пылесоса), для которых `alertdialog`
неверен — эта роль обязывает screen reader прервать чтение.
Практически: `hp-dialog` получает признак вида (destructive / обычный), который
проставляет `hp-confirm` для своих запросов; обе ветки — HA и запасная —
объявляют роль одинаково.
### (в) 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-демо, а не
та, что работает у пользователя.
**Контракт**: инварианты подтверждения (диалог показан, «Отмена» резолвит
`false`, начальный фокус на «Отмена», Esc отменяет) проверяются на **обеих**
ветках. Прецедент стаба `ha-dialog` уже написан и лежит в соседнем файле.
### (г) Снапшот копит записи исчезнувших устройств
`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) при **чтении**. Порядок ключей
— порядок вставки, поэтому при переполнении молча отбрасываются самые свежие
записи, а не самые старые: мусор вытесняет живое. Правило переворачивается
заодно — при обрезке сохраняются последние записи.
### (д) Затирание следа приёмки
`scripts/docs-accept.mjs:145` пишет `declared: [...decision.replace]`. Модельный
прогон повторной приёмки неизменённого набора:
```
отказ: НЕТ | replace=[] | witnesses=10 | floor=1
→ в манифест уйдёт acceptance.declared = []
```
Безобидный повтор `docs:accept` проходит успешно и стирает единственную запись
о том, зачем кадры перерисовывались.
**Контракт**: приёмка, ничего не заменившая, не переписывает историю
предыдущей. Свежие числа (`witnesses`, `floor`) обновляются, список
`declared` при пустом наборе замен сохраняется.
## Скоуп / не-скоуп
**В скоупе**: удаление доказанно мёртвых ключей из четырёх словарей и гейт,
который не даёт им вернуться; роль и `aria-describedby` в `hp-dialog` и
`hp-confirm`; смок HA-ветки подтверждения; уборка снапшотов при авторитетном
реестре и порядок обрезки; сохранение `acceptance.declared`.
**Не в скоупе**: подключение `*.help.aria` к интерфейсу (если задел нужен —
отдельный issue; строки восстанавливаются из истории); содержимое и оформление
диалога (#32); переезд area и его отказы (#403); порог свидетелей (#405 —
соседняя строка того же файла, другой контракт); рост `known_devices` — тот же
класс, но своя поверхность.
## UX
Оформление не меняется. Меняется то, что слышит пользователь скринридера:
подтверждение разрушающего действия объявляется как оповещение и читается
вместе с последствиями. Терминология берётся из `docs/USER-GUIDE.ru.md`, новых
формулировок не вводится.
## Модель данных и миграция
Формат `marker_area_snapshot` не меняется — меняется момент уборки записей.
Миграции нет: запись, оставшаяся от исчезнувшего устройства, исчезает при
первом авторитетном проходе.
Удаление ключей словаря — не миграция конфига: ключи не хранятся в
пользовательских данных.
## i18n
Новых строк нет; тридцать (по числу мёртвых ключей) удаляются из каждого из
четырёх словарей. Паритет сохраняется — удаление синхронное во всех четырёх.
## Критерии приёмки
- **AC1**. Ни один ключ словаря не остаётся без потребителя: гейт строит
множество литералов и динамических префиксов из `src/` и краснеет на любом
ключе, не покрытом ни тем, ни другим. Доказательство: гейт зелёный после
чистки.
- **AC2**. **Отрицательный прогон обязателен**: возвращённый в словарь мёртвый
ключ роняет гейт. Доказательство: мутант через штатный раннер.
- **AC3**. Гейт не обвиняет динамические ключи: `furn.cat_*`, `furn.sym_*`,
`wall_model.reason.*`, `resize.disabled.*`, `junction.limit_*`, `decor.*` и
прочие 48 семейств остаются зелёными. Доказательство: гейт зелёный на текущем
словаре после удаления только доказанно мёртвых.
- **AC4**. Коллизия с `test/unified-wall-tool-source.test.mjs` разрешена явно:
либо показан потребитель `history.partition_add`, либо ключ удалён вместе со
строкой теста. Доказательство: оба гейта зелёные одновременно.
- **AC5**. Паритет словарей сохранён: `test/i18n.test.mjs` зелёный без правок
его утверждений, в четырёх словарях одинаковый набор ключей.
- **AC6**. Подтверждение разрушающего действия объявляется как `alertdialog`, а
текст последствий связан через `aria-describedby`. Доказательство: смок
читает атрибуты у настоящего диалога.
- **AC7**. Обычные диалоги (маркер, калибровка) остались `dialog`.
Доказательство: тот же смок, вторая проверка.
- **AC8**. Инварианты подтверждения проверены на **обеих** ветках: диалог
показан, «Отмена» резолвит `false`, начальный фокус на «Отмена», Esc
отменяет. Доказательство: смок подтверждений со стабом `ha-dialog` (прецедент
`smoke_free_walls.mjs:20-22`) плюс существующий прогон запасной ветки.
- **AC9**. Запись снапшота исчезнувшего устройства убирается при авторитетном
реестре. Доказательство: смок — устройство пропадает из `_devices`, после
прохода записи в `marker_area_snapshot` нет.
- **AC10**. При неавторитетном реестре записи **не** трогаются. Доказательство:
тот же смок, ветка `authoritative: false` — запись на месте.
- **AC11**. При обрезке снапшота сверх лимита сохраняются последние записи, а
не первые. Доказательство: юнит на `markerAreaSnapshotOf` с входом больше
лимита.
- **AC12**. Повторная приёмка неизменённого набора не затирает
`acceptance.declared`. Доказательство: тест на функции приёмки —
`replace: []` оставляет прежний список.
- **AC13**. Штатная приёмка изменённого кадра по-прежнему записывает свой
список. Доказательство: существующие тесты `test/docs-acceptance.test.mjs`
зелёные без правок их утверждений.
- **AC14**. Бюджет 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` у подтверждения (AC6), роль у обычного диалога
(AC7).
3. Те же инварианты со стабом `ha-dialog` (AC8).
**Browser smoke** (`demo/smoke_area_relocation.mjs` — дополнение):
4. Устройство исчезло из `_devices`, реестр авторитетен → запись убрана (AC9).
5. То же при `authoritative: false` → запись на месте (AC10).
**Юниты**:
6. `markerAreaSnapshotOf` на входе больше лимита — сохранены последние (AC11).
7. Приёмка с `replace: []` — прежний `declared` сохранён (AC12).
**Мутанты** (`scripts/mutation-gate.mjs`):
- `i18n-dead-key-returns`: вернуть `confirm.unlock` в словари → гейт краснеет;
- `confirm-dialog-loses-alertdialog`: вернуть `role="dialog"` подтверждению →
смок AC6 краснеет;
- `area-snapshot-cleanup-ignores-authority`: убрать условие авторитетности →
смок AC10 краснеет.
Третий мутант важнее первых двух: он проверяет не наличие уборки, а её
осторожность — то есть ровно то свойство, потеря которого стоит пользователю
данных.
## Риски
- **Гейт мёртвых ключей даст ложные обвинения на будущих динамических ключах.**
Разработчик, написавший `` _t(`new_family.${x}`) ``, получит красный гейт на
все ключи семейства. Смягчение: гейт извлекает префиксы из исходников
автоматически (48 семейств уже покрыты), а сообщение об отказе называет
ключ и подсказывает обе законные дороги — использовать или удалить.
- **`alertdialog` меняет поведение скринридера сильнее, чем кажется**: он
прерывает текущее чтение. Смягчение: роль назначается только подтверждениям
разрушающих действий, обычные диалоги проверяются отдельным AC.
- **Уборка снапшотов может стереть законные записи.** Это ровно тот класс, что
чинит #403. Смягчение: уборка только при авторитетном реестре, AC10
проверяет обратный случай напрямую, мутант закреплён.
- **Удаление 13 ключей `*.help.aria` окажется преждевременным**, если задел
планировался к подключению. Смягчение: восстановление из истории тривиально,
а поддерживать непоказываемый перевод в четырёх языках дороже, чем вернуть
строки в тот день, когда для них появится потребитель.
## Откат
Пять правок независимы и откатываются по отдельности: удаление ключей —
обратным коммитом, роль и `aria-describedby` — одной строкой каждая, уборка
снапшотов — снятием условия, сохранение `declared` — возвратом прежнего
выражения.
## Release-артефакты
- `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: пункт о том, что подтверждение
разрушающего действия объявляется скринридером вместе с последствиями
(User-Visible: yes). Остальные четыре пункта — внутренние.
- Скриншоты не меняются: диалог в статике не открыт.