# 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 мс. Вход в пузырь отменяет
таймер закрытия;
- **клавиатура**: триггер — настоящий ``, доступен по Tab. Фокус
автоматически открывает подсказку только когда кнопка соответствует `:focus-visible`;
`Enter`/`Space` через обычный `click` переключают состояние;
- **мышь или тач по кнопке**: обычный `click` переключает. Эмулированные после тапа
mouse-hover события игнорируются, потому что hover-ветка принимает только настоящий
mouse pointer.
Pointer-фокус перед `click` не должен давать цикл «открылась по focus → закрылась по
click». Все таймеры открытия/закрытия отменяются при противоположном действии и в
`disconnectedCallback`; отложенный callback проверяет, что компонент ещё подключён и
намерение пользователя не изменилось.
### 3.3 Disabled-группы (R1)
Триггер **обязан** оставаться доступным, когда группа настроек отключена: половина
ценности подсказки — объяснить, почему контрол недоступен.
Нормативно: вызов `_help()` внутри `` размещается **в первом ``**. По
HTML-стандарту `disabled` у `fieldset` не распространяется на содержимое первого
`legend`, поэтому кнопка остаётся живой без единого обходного приёма. Если у группы нет
`legend`, триггер выносится за пределы `fieldset`.
Запрещено: help-trigger в теле disabled-группы, отключение триггера вместе с контролом,
дублирование подсказки в `aria-describedby` отключённой группы.
### 3.4 Позиционирование (R2)
Диалог обрезает содержимое: `overflow: hidden` и на `.surface` (`hp-dialog.ts:138`), и на
`ha-dialog::part(dialog)` (`:55`). Абсолютно спозиционированный пузырь будет срезан у
края.
Нормативно: **тот же механизм, что у `hp-color-opacity`** — native Popover API
(`showPopover()`/`hidePopover()`, состояние `:popover-open`) плюс `position: fixed` и
пересчёт координат при открытии. Всплывающий слой оказывается в top layer, поэтому:
- новый z-слой не вводится и шкала `tip 60 < tray 70 < menu 80 < toast 120` не меняется;
- второй механизм всплытия в проекте не появляется.
Логика переворота и сдвига у края (flip/shift) выносится из `hp-color-opacity` в общий
**чистый** helper и используется обоими компонентами. Helper получает rect якоря, rect
поверхности, gap, безопасный отступ и границы visual viewport; возвращает координаты и
выбранную сторону. Он не владеет DOM, слушателями или политикой закрытия. Дублировать
геометрию копипастой запрещено.
Обязательные граничные случаи helper:
- обычное размещение снизу;
- flip наверх, если снизу не помещается;
- horizontal shift у обоих краёв;
- поверхность шире/выше доступной области: координата прижимается к safe edge, а сама
поверхность получает ограничение `max-inline-size`/`max-block-size`;
- `visualViewport.offsetLeft/offsetTop/width/height`, если API доступен; иначе
`window.innerWidth/innerHeight`.
Popover API — предпочтительная ветка, но не безусловное допущение. Общий контроллер
делает feature detection. Ветка без `showPopover()` обязана остаться видимой и не
обрезаться обеими ветками `hp-dialog`; конкретный способ fallback выбирает реализация,
но «оставить элемент внутри clipping ancestor и назвать это fallback» недостаточно.
Fallback имеет отдельный принудительный смок.
При `resize` окна/visual viewport и смене ориентации открытая поверхность либо один раз
перепозиционируется через rAF, либо закрывается — выбранная политика одинакова для
одинаковых причин и покрыта тестом. Исчезновение/отключение якоря всегда закрывает
поверхность. Scroll-политика подсказки отдельно зафиксирована в §3.8.
### 3.5 Escape и порядок закрытия (R3)
`hp-dialog` получает публичный реестр открытых внутри него transient-поверхностей. Один
вызов регистрации имеет следующий смысл (точное имя TypeScript API может отличаться,
семантика — нет):
```ts
registerOverlay({ owner, close, group: 'transient' }): () => void
```
- регистрация происходит **только после фактического открытия** поверхности;
- возвращённый disposer идемпотентен и вызывается при любом закрытии и обязательно в
`disconnectedCallback` владельца;
- повторная регистрация того же owner не создаёт дубль, а переносит его наверх LIFO;
- перед использованием реестр удаляет записи с `!owner.isConnected`;
- открытие новой поверхности в exclusive-группе `transient` сначала закрывает прежнюю
из этой группы. Поэтому одновременно не остаются help-bubble и color picker;
- callback `close('escape')` синхронно переводит owner в закрытое состояние и не сам
генерирует второй Escape.
На Escape диалог сначала закрывает верхнюю зарегистрированную поверхность и **не
закрывает себя**; если реестр пуст — закрывается сам. Событие по-прежнему получает
`preventDefault()` и `stopImmediatePropagation()` ровно один раз в capture-handler
диалога. Существующее поведение диалога без поверхностей не меняется.
`hp-color-opacity` переводится на тот же реестр в рамках этой задачи: сейчас его пикер и
диалог конкурируют за Escape. Сам `hp-color-opacity` и `hp-help` не ищут реестр глобально:
они регистрируются в ближайшем `hp-dialog`; вне диалога сохраняют собственное закрытие по
Escape и outside-pointer для изолированного demo/test применения.
Обычный toast не может перекрыть native top-layer числовым `z-index`. Поэтому перед
показом toast карточка закрывает все transient-поверхности **только своего render root**;
сам диалог остаётся открыт. Это сохраняет существующую визуальную иерархию «toast важнее
подсказки/пикера» без переноса toast в новый механизм.
### 3.6 Тач и указатель одновременно (R6)
События hover на тач-устройствах эмулируются, поэтому тап иначе открывал бы и тут же
закрывал пузырь. Нормативно: обработчики наведения игнорируются при
`event.pointerType !== 'mouse'`; activation живёт в обычном `click`, а не в параллельном
`touchend`. На hybrid-устройстве реальная мышь сохраняет hover, реальный touch — toggle.
### 3.7 Одна подсказка за раз (R6)
Открытие любого `hp-help` в диалоге закрывает предыдущую transient-поверхность этого
диалога, включая открытый `hp-color-opacity`, и наоборот. Разные карточки/диалоги не
делят глобальное состояние и не закрывают поверхности друг друга.
### 3.8 Прокрутка (R6)
Пузырь спозиционирован фиксированно и за прокруткой тела диалога не следует.
Нормативно: прокрутка любого предка закрывает пузырь. Пересчёт координат на каждый кадр
прокрутки запрещён. Scroll внутри другой независимой карточки не закрывает подсказку;
контролируется event path/принадлежность к тому же document+dialog, а не глобальный
`document.scroll` без фильтра.
Открытие/закрытие подсказки не меняет `clientHeight`, `scrollHeight` или `scrollTop`
тела диалога. Вспомогательный текст для `aria-describedby` постоянно остаётся вне
scroll-flow; его доступность переключается ARIA-состоянием, а не добавлением
абсолютно спозиционированного узла в overflow-геометрию. Требование одинаково для
native Popover и portal fallback.
### 3.9 Триггер и `` (R6)
Help-trigger не размещается внутри ``: клик по нему переключал бы связанный
контрол. Триггер ставится после закрывающего ` ` либо в `legend`.
### 3.10 Содержимое
Первая версия — **только короткий текст**, только из i18n, без HTML и без интерактивных
элементов. Поверхность имеет `role="tooltip"` и никогда не принимает фокус.
Ссылка на документацию в эту версию не входит: интерактивная ссылка несовместима с
семантикой tooltip и потребовала бы отдельного non-modal-dialog/focus-контракта. Если
такая ценность подтвердится, это отдельное расширение компонента, а не необязательный
ключ, меняющий семантику молча.
Ширина ограничена, текст переносится. Контент из #86 — одно предложение, максимум два;
при 200 % масштабе и ширине viewport 390 CSS px он должен помещаться без горизонтальной
прокрутки. Защитный `max-block-size` допускается, но штатный пилотный текст не должен
требовать внутренней прокрутки, недоступной визуальному keyboard-only пользователю.
### 3.11 Оформление и движение
Токены поверхности диалога, обе темы. При `prefers-reduced-motion` анимация появления и
исчезновения отключается. Видимый знак вопроса — 16–18 px, но зона настоящей кнопки не
меньше 32×32 CSS px на fine pointer и 40×40 px при `pointer: coarse`. Wrapper подписи
может честно увеличить высоту строки; отрицательные margins и перекрытие соседних targets
запрещены. На 200 % подпись и help-trigger могут перенестись вместе на следующую строку,
но не обрезаются.
### 3.12 Shadow DOM и focus trap (R12)
`hp-help` инкапсулирует настоящую кнопку в своём Shadow DOM. Нативная fallback-ветка
`hp-dialog` обязана включать focusable descendants открытых shadow roots в composed
порядке при initial-focus и Tab wrap. Обход не заходит в закрытые shadow roots и не
добавляет host отдельным tab-stop, если фокусным является его внутренний control.
Это исправление проверяется не только на `hp-help`, но и на существующем
`hp-color-opacity`: оба trigger доступны Tab, а переход после последнего focusable
возвращает на close/первый control ровно один раз. HA-ветка продолжает использовать trap
`ha-dialog`; ручная логика не должна создавать второй trap поверх него.
## 4. Доступность
- триггер — кнопка с `aria-label` в виде одной цельной строки i18n
(«Подсказка: радиус свечения»), без склейки из кусков;
- у кнопки есть `aria-expanded`; открытое состояние подключает
`aria-describedby` к стабильному уникальному id. В закрытом состоянии связь
отсутствует, чтобы screen reader не повторял объяснение при каждом фокусе;
- пузырь имеет `role="tooltip"`, не содержит focusable-элементов и сам не получает
фокус; фокус всё время остаётся на trigger;
- Escape закрывает пузырь; если фокус уже был на trigger (keyboard/click path), он там
остаётся. Hover-подсказка не крадёт текущий фокус и при закрытии его не переносит;
- screen reader получает и назначение кнопки, и открытое объяснение; одна и та же фраза
не объявляется дважды из-за параллельного live-region;
- читаемо при 200 % масштабе браузера.
## 5. Соглашение о ключах
На один вызов нужны два механически связанных ключа:
- `<ключ опции>.help` — текст пузыря: `marker.light_role.help`;
- `<ключ опции>.help.aria` — **цельная** доступная подпись кнопки, например
«Подсказка: является ли устройство источником света». Это не конкатенация общего
«Подсказка» с label во время выполнения.
В `_help()` передаётся первый ключ; `.aria` выводится из него механически. Тип фабрики —
`Extract`, поэтому произвольная строка не проходит typecheck.
Для опции без обоих ключей trigger не добавляется.
## 6. Проверка ключей в CI (R7)
Существующий тест паритета i18n расширяется: он сканирует исходники на вызовы
`_help('…')`, собирает help-ключи и для каждого требует сам ключ и производный `.aria`
**в обоих** словарях. Отсутствующий/пустой ключ валит CI, а не рендерит ключ или пустой
пузырь.
Нормативно: аргумент `_help()` — строковый литерал в разметке. Вычисляемые ключи запрещены, иначе
статический сканер их не видит и проверка становится фиктивной. Единственное
исключение — внутреннее механическое добавление `.aria` внутри самой фабрики.
## 7. Тесты
- **юниты**: pure flip/shift helper (низ, верх, оба horizontal edge, oversized surface,
visual viewport offset); разрешение help + `.aria`, обнаружение отсутствующего ключа,
стабильная связка aria;
- **паритет i18n**: см. §6;
- **смок**: открытие по mouse-hover, по `:focus-visible` с клавиатуры, кликом мыши и
touch tap; pointer-focus+click не закрывает только что открытый пузырь; закрытие тапом
мимо и по Escape **без закрытия диалога**; второй Escape закрывает уже диалог;
открытие help закрывает color picker и наоборот; закрытие при прокрутке своего dialog,
но не чужой карточки; доступность триггера в disabled-группе; bounding box пузыря
внутри visual viewport для триггера в правом нижнем углу диалога на экране 390 px;
показ toast закрывает help/color picker своей карточки, но не dialog и не поверхности
другой карточки;
отдельный прогон с принудительно отключённой Popover API веткой; явный `config.language`
отличается от `hass.locale` и определяет текст; две карточки с разными языками не
заражают друг друга;
- **focus smoke**: в нативной fallback-ветке Tab доходит до внутренних кнопок `hp-help`
и `hp-color-opacity` в composed порядке, Shift+Tab идёт обратно, wrap происходит один
раз; в HA-ветке не появляется второй trap;
- **golden**: одна сцена с открытым пузырём, тёмная и светлая, с семантическим ассертом
«в области пузыря есть непрозрачные пиксели текста» — по образцу `warmPixelRegion`
(`demo/golden/run.mjs`);
- **мутант по #85** (обязателен): удаление логики flip/shift у края. Смок обязан
покраснеть на ассерте «пузырь внутри вьюпорта». Без объявленного мутанта задача не
считается выполненной.
## 8. Пилот (R5)
Конвертируется **один** блок — светоблок диалога маркера. В нём ровно три help-trigger:
1. в legend «Является источником света» — общий смысл Auto/Always/Never;
2. в первом legend disabled-fieldset «Цвет и яркость свечения» — различие трёх режимов,
включая правило «минимум 1 %, выключение через Never»;
3. рядом с подписью «Радиус свечения» — единицы и наследование общего значения.
Радиус визуально остаётся частью светоблока, но его row выводится **за пределы**
disabled-fieldset цвета/яркости. При блокировке disabled получает сам input; help-trigger
остаётся доступным. Input и fieldset ссылаются через `aria-describedby` на существующую
динамическую status-note с фактической причиной блокировки.
Переносятся и затем удаляются неиспользуемые старые ключи/строки:
`marker.light_role_tip`, `marker.glow_color_tip`, `marker.glow_brightness_hint` и
`marker.glow_radius_hint`. Три динамических `marker.glow_disabled_*` **не превращаются**
в общую подсказку и остаются видимой status-note: это не документация опции, а текущее
состояние конкретного маркера.
Контрол/подпись не может нести одновременно `title=`/старую `.markerlighttip` и новый
`_help()` — это проверяется сканером исходника. Основание пилота: это самый трудный случай
в продукте (disabled-группа, условные controls, color picker и динамическая причина), и
на нём проверяются сразу §3.3–§3.5 и §3.12.
## 9. Что дальше (контент)
Написание остальных текстов — отдельная задача [#86](https://github.com/Matysh/houseplan-card/issues/86).
Правило для неё: одна небольшая правка на диалог, пары `.help` + `.help.aria` пишутся
сразу в обоих словарях. Упоминание необязательной clickable-ссылки в #86 заменяется
правилом §3.10: ссылка требует отдельного расширения механизма.
## 10. Вне области
- Написание подсказок для всех опций.
- Массовая миграция 56 `title=`. Правило на будущее: новые объяснения делаются только
через `_help()`, старые `title=` мигрируют по мере касания диалога.
- Clickable-ссылки, HTML/Markdown и произвольный rich content внутри пузыря.