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

28 KiB
Raw Permalink Blame History

ТЗ #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, новый):

  1. Множество ключей минус литералы минус динамические префиксы — пусто (AC1, AC3).

Browser smoke (demo/smoke_danger_confirm_branches.mjs — дополнение; файл уже создан #402 и держит свои фикстуры):

  1. Роль и aria-describedby у destructive- и warning-подтверждения без ha-dialog (AC6, AC8).
  2. Конформный стаб ha-dialog принимает публичные .ariaLabelledBy, .ariaDescribedBy и .type: обычный hp-dialog выбирает HA-ветку, а оба alert-подтверждения — нативную; фокус, Esc и результат не расходятся (AC6–8).

Browser smoke (demo/smoke_area_relocation.mjs — дополнение):

  1. Устройство исчезло из _devices, реестр авторитетен → запись убрана (AC9).
  2. То же при authoritative: false → запись на месте (AC10).

Юниты:

  1. 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). Остальные три пункта — внутренние.
  • Скриншоты не меняются: диалог в статике не открыт.