Files
houseplan-card/docs/specs/068-help-affordance.md
T

385 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 | **Пилот неработоспособен в исходном виде.** Кнопка внутри `<fieldset disabled>` отключается браузером по спецификации HTML, а пилотный светоблок диалога маркера — это именно `<fieldset ?disabled=${glowControlsDisabled}>` (`houseplan-card.ts:15579`). Подсказка умирала бы ровно там, где она нужнее всего: «почему это серое?» | Нормативно: триггер рендерится **внутри первого `<legend>`** группы либо вне `fieldset`. `<legend>` — единственное исключение из 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`<hp-help .text=${text} .ariaLabel=${ariaLabel}></hp-help>`
```
Место вызова передаёт **только один литеральный ключ**. Ни пользовательских строк, ни
разметки, ни callback локализации, ни позиционирования на стороне конкретного диалога.
Смена языка родительской карточки обычным Lit-рендером обновляет уже открытый пузырь.
Две карточки с разными языками на одной странице не влияют друг на друга.
### 3.2 Триггеры
Все три, потому что продукт живёт на десктопе, планшете и телевизоре:
- **мышь**: `pointerenter` с `pointerType === 'mouse'` открывает с задержкой 300 мс;
уход и с триггера, и с пузыря закрывает с задержкой 150 мс. Вход в пузырь отменяет
таймер закрытия;
- **клавиатура**: триггер — настоящий `<button type="button">`, доступен по Tab. Фокус
автоматически открывает подсказку только когда кнопка соответствует `:focus-visible`;
`Enter`/`Space` через обычный `click` переключают состояние;
- **мышь или тач по кнопке**: обычный `click` переключает. Эмулированные после тапа
mouse-hover события игнорируются, потому что hover-ветка принимает только настоящий
mouse pointer.
Pointer-фокус перед `click` не должен давать цикл «открылась по focus → закрылась по
click». Все таймеры открытия/закрытия отменяются при противоположном действии и в
`disconnectedCallback`; отложенный callback проверяет, что компонент ещё подключён и
намерение пользователя не изменилось.
### 3.3 Disabled-группы (R1)
Триггер **обязан** оставаться доступным, когда группа настроек отключена: половина
ценности подсказки — объяснить, почему контрол недоступен.
Нормативно: вызов `_help()` внутри `<fieldset>` размещается **в первом `<legend>`**. По
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 Триггер и `<label>` (R6)
Help-trigger не размещается внутри `<label>`: клик по нему переключал бы связанный
контрол. Триггер ставится после закрывающего `</label>` либо в `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<I18nKey, `${string}.help`>`, поэтому произвольная строка не проходит 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 внутри пузыря.