# 068 — Подсказки к настройкам: переиспользуемая кнопка помощи в любом диалоге - Issue: **#68** - Приоритет: P2, polish - Статус ТЗ: **выпущено в v1.62.0-beta.1; локально усилено после ревью** (ревизия 5) - Связано: #30 (информационная архитектура диалогов), #31 (доступность), #62 (инфраструктура i18n), #85 (тесты должны уметь падать) - Область: общий компонент `hp-help`, фабрика вызова Houseplan Card, `hp-dialog`, `hp-color-opacity`, ключи i18n - Touch editor: **поддерживается для самого affordance** — подсказку можно открыть и закрыть тапом; это явная гарантия данной задачи поверх общего best-effort правила редакторов из `docs/TOUCH-SUPPORT.md` Задача поставляет **механизм** и три пилотных текста. Остальные подсказки пишутся отдельно — см. issue про контент, первая партия описана в §9. Ревизия 4 по code review локальной реализации добавляет единый DOM/lifecycle- контроллер fallback-поверхности для `hp-help` и `hp-color-opacity`, включает dialog-owned portal в composed focus traversal и запрещает Escape переносить фокус на hover-trigger. `aria-describedby` подключается только у открытой подсказки: закрытая кнопка объявляет назначение, но не повторяет весь текст при каждом попадании фокуса. Статический scanner дополнительно запрещает сочетать `title=` и `_help()` на одном host-контроле. Ревизия 5 по полному code review запрещает «мёртвый» affordance: кнопка помощи не существует в DOM, если отсутствует или пуста хотя бы одна из двух обязательных локализованных строк — текст подсказки либо полная ARIA-подпись. Фабрика проверяет пару до создания host, а сам `hp-help` повторяет защиту для прямого и динамического использования. Визуальный знак — MDI `help-circle-outline` («вопрос в кружке»), а не зависящий от системного шрифта символ `?`. --- ## 0. Что изменено ревью 2026-08-11 | # | Находка | Решение | |---|---|---| | R1 | **Пилот неработоспособен в исходном виде.** Кнопка внутри `
` отключается браузером по спецификации HTML, а пилотный светоблок диалога маркера — это именно `
` (`houseplan-card.ts:15579`). Подсказка умирала бы ровно там, где она нужнее всего: «почему это серое?» | Нормативно: триггер рендерится **внутри первого ``** группы либо вне `fieldset`. `` — единственное исключение из disabled-наследования по стандарту, поэтому решение не требует новых хаков (§3.3) | | R2 | **Изобретался второй механизм всплытия.** ТЗ требовало «слой, не обрезаемый диалогом» и своё место в шкале z-индексов, хотя в проекте уже есть решённый тот же случай: `hp-color-opacity` использует native Popover API + `position: fixed` и покрыт golden-сценой `decor-color-popover-mobile-ru` | Нормативно: переиспользовать тот же подход, вынеся его в общий helper. Никакого нового z-слоя не вводится — top layer выше всего по определению (§3.4) | | R3 | **Escape не дошёл бы до пузыря.** `hp-dialog._onKeyDown` (`hp-dialog.ts:293`) на Escape делает `stopImmediatePropagation()` и закрывает диалог. Требование «Escape закрывает сначала подсказку» не выполнялось бы само собой | Введён явный реестр открытых поверхностей у `hp-dialog`; Escape отдаётся верхней из них (§3.5), и это отдельный ассерт смока | | R4 | **Цифры устарели.** В теле issue: 57 `title=`, 38 hint-ключей | На v1.61.0: **56** `title=`, **34** ключа вида `*_tip`/`*_hint`/`*.hint` из 721 (§1) | | R5 | **Обоснование пилота устарело.** «Светоблок всё равно переделывается по #65–#67» — они вышли в v1.61.0 | Пилот сохранён, но по другой причине: это самый трудный случай (disabled-группа + три контрола, которые пришлось объяснять текстом) (§8) | | R6 | Не было правил для тача, повторного открытия, прокрутки и клика по label | §3.6–§3.9 | | R7 | Проверка паритета ключей не могла работать: ключ мог быть вычисляемым | Нормативно: только строковый литерал в разметке (§6) | | R8 | Тесты не проверялись на способность падать | Добавлен обязательный мутант по контракту #85 (§7) | | R9 | **Компоненту неоткуда было взять правильный язык.** `hass.locale` недостаточно: карточка поддерживает явный `config.language`, а автономный `hp-help key="…"` этого контекста не видит. При двух карточках с разным языком глобальный localizer также недопустим | `hp-help` становится presentation-only, а Houseplan Card даёт единую фабрику `_help('literal.help')`, которая на каждом рендере передаёт уже локализованные `text` и `ariaLabel`. Вызов по-прежнему занимает одну строку и содержит один литеральный ключ (§3.1, §5) | | R10 | Открытие «по фокусу» конфликтовало с кликом: pointer сначала фокусирует кнопку, затем `click` немедленно переключает уже открытый пузырь обратно | Автооткрытие по focus разрешено только при `:focus-visible`; pointer-hover обслуживается только `pointerType === 'mouse'`, а `click` остаётся единым toggle для мыши, тача и `Enter`/`Space` (§3.2) | | R11 | Необязательная ссылка противоречила семантике tooltip: `role="tooltip"` не может содержать интерактивный элемент, а non-modal dialog потребовал бы другой фокусный контракт | В первой версии пузырь строго текстовый и не получает фокус. Ссылки вынесены из механизма #68; их можно добавить отдельным расширением с отдельной семантикой. #86 остаётся задачей на короткий самостоятельный текст (§3.10, §10) | | R12 | Настоящая кнопка внутри Shadow DOM нового компонента могла выпасть из ручного focus trap нативной ветки `hp-dialog`: текущий `_focusableElements()` видит только light DOM | Нативная ветка `hp-dialog` получает обход focusable-элементов по composed tree/open shadow roots; порядок Tab и wrap проверяются отдельным смоком (§3.12, §7) | | R13 | `registerOverlay(close)` не определял порядок, очистку и конкуренцию с пикером цвета | Зафиксирован token/disposer-контракт, LIFO, идемпотентная очистка, pruning отключённых владельцев и одна exclusive transient-поверхность на диалог. `hp-help` и `hp-color-opacity` используют один контракт (§3.5) | | R14 | В пилоте подсказка радиуса оставалась бы внутри тела disabled-fieldset и снова отключалась, несмотря на исправление для legend | Радиус вынесен из disabled-fieldset цвета/яркости; disabled назначается самому input, а живой help-trigger остаётся рядом с подписью. Динамическая причина блокировки сохраняется как отдельная status-note (§8) | | R15 | Не был определён fallback при отсутствии Popover API и жизненный цикл при resize/orientation; комментарий fallback в `hp-color-opacity` сам по себе этого контракта не гарантирует | Общий floating helper обязан иметь проверяемую ветку без `showPopover`, учитывать visual viewport и закрывать либо безопасно перепозиционировать поверхность при resize. Fallback тестируется принудительно (§3.4, §7) | | R16 | Исходное требование «ниже toast» несовместимо с выбранным native top layer: обычный `.toast { z-index: 120 }` всё равно окажется под открытым popover | Появление toast сначала закрывает transient-поверхности текущей карточки. Так toast остаётся единственным верхним сообщением без отдельной миграции toast в top layer (§3.5, §7) | --- ## 1. Зачем механизм, а не ещё один `title=` Объяснения сейчас живут в трёх несовместимых формах (замеры на v1.61.0): - **56** нативных `title=${this._t(...)}` в `houseplan-card.ts` — невидимы на тач-экране, задержка не управляется, оформление не управляется. То есть не работают ровно на планшетах и настенных панелях, где и живёт основная аудитория киоска; - **16** блоков `.rhint` под контролом — видны всем и всегда, включая тех, кто уже разобрался, и навсегда увеличивают высоту диалога; - у большинства опций объяснения нет вообще. При этом **34** ключа i18n уже содержат подсказочный текст (`*_tip`, `*_hint`, `*.hint`) из 721 ключа. Контент существует кусками, но не имеет единой подачи. ## 2. Что видит пользователь После подписи опции — **кружок со знаком вопроса** того же размера, что подпись, в приглушённом цвете. По наведению, фокусу с клавиатуры или тапу рядом появляется компактный пузырь с объяснением этой опции. Пузырь закрывается уводом курсора, тапом мимо, повторным нажатием или Escape — и **не закрывает при этом сам диалог**. Опции без подсказки выглядят ровно как сейчас: кружок не рендерится вовсе. ## 3. Механизм ### 3.1 Компонент `hp-help` — presentation-only компонент. Он не импортирует словари, не читает `hass.locale`, не ищет приватные поля host-карточки и не держит глобальный язык. На вход он получает уже разрешённые строки `text` и `ariaLabel`. Houseplan Card предоставляет одну фабрику, которая остаётся единственной точкой вызова: ```ts ${this._help('marker.light_role.help')} ``` Фабрика локализует тело и полную доступную подпись через текущий `_t()` карточки, поэтому учитывает как язык профиля HA, так и явный `config.language`, и рендерит: ```ts html`` ``` Место вызова передаёт **только один литеральный ключ**. Ни пользовательских строк, ни разметки, ни callback локализации, ни позиционирования на стороне конкретного диалога. Смена языка родительской карточки обычным Lit-рендером обновляет уже открытый пузырь. Две карточки с разными языками на одной странице не влияют друг на друга. ### 3.2 Триггеры Все три, потому что продукт живёт на десктопе, планшете и телевизоре: - **мышь**: `pointerenter` с `pointerType === 'mouse'` открывает с задержкой 300 мс; уход и с триггера, и с пузыря закрывает с задержкой 150 мс. Вход в пузырь отменяет таймер закрытия; - **клавиатура**: триггер — настоящий `