# Задача: `GnPermissionBanner` — плашка запроса браузерного разрешения

**Статус:** не начата.
**Тип:** новый компонент (поведение + стили; vanilla и Vue-адаптер).
**Повод:** потребитель кита (navi) сделал у себя плашку запроса разрешения на
уведомления и просит перенести её в кит, чтобы у следующего сервиса она уже была.
Запись ведётся по правилу платформы: находка про общий ресурс живёт в доке ресурса,
у потребителя остаётся только рабочий обход и ссылка сюда.

**Источник:** navi webclient — `webclient/src/components/ui/NotificationPromptBanner.vue`
и `webclient/src/composables/useNotificationPrompt.js`; ui-kit 1.0.0 (коммит `ca418ad`).
**Автор находки:** ИИ-агент (сессия разработки navi). **Автор задачи:** владелец navi.
**Дата:** 2026-10-10. **Задача в трекере:** не заведена.

**Суть.** Разрешение на браузерные уведомления нельзя запросить «самим фактом
загрузки страницы»: Firefox с 72-й версии и Safari игнорируют вызов без
пользовательского действия, а Chrome показывает вместо окна «тихий» перечёркнутый
колокольчик. Нужна видимая кнопка, клик по которой и является жестом. Кит не даёт ни
такой плашки, ни обёртки над `Notification.requestPermission()`, поэтому каждый
потребитель изобретает их заново.

**Доказательства.** Поведение самих браузеров (требование user activation для
`Notification.requestPermission()`); в navi до этой плашки разрешение запрашивалось
только тумблером в настройках, автозапрос отсутствовал — то есть без кнопки запрос
попросту не работает. Рабочая реализация у потребителя: бар во всю ширину с
заголовком, строкой текста и двумя кнопками, клик по первой уходит в
`Notification.requestPermission()` синхронно (проверено в Chrome на сборке navi,
1440px и 390px: полоса 1060×65 и 390×127, текст не обрезан, горизонтальной
прокрутки нет).

**Предложение.** Отдать в ките `GnPermissionBanner` — презентационную плашку с
двумя ответами и **без политики**: когда спрашивать, что помнить об отказе и в каком
хранилище это держать, решает потребитель (см. «Границы задачи»).

## Проблема

`GnAlert` — единственное близкое к плашке в ките — инлайновый блок сообщения: у него
есть `variant` и `role`, но нет ни действий, ни закрытия, и он не проектировался как
призыв. Запроса разрешения кит не касается вовсе.

Из-за этого у каждого потребителя получается свой велосипед, и ошибки в нём одни и
те же:

- запрос уходит без жеста (в Firefox/Safari — молча ничего, в Chrome — «тихий»
  режим, из которого сайт уже не выйдет: после нескольких отказов браузер
  перестаёт показывать окно);
- между кликом и `requestPermission()` появляется `await` (загрузка ключа,
  запрос к API) — user activation к этому моменту истрачен, окно не открывается;
- «отказ» трактуется по-разному: кто-то считает отказом закрытие системного окна,
  и потом либо молчит навсегда зря, либо спрашивает при каждой загрузке.

## Целевое поведение

Компонент — плашка с призывом и двумя ответами:

1. **Ничего не запрашивает сам.** Ни на маунте, ни на показе. Единственный вызов
   `requestPermission()` — обработчик клика по кнопке согласия, и он же — первый
   `await` в цепочке.
2. **Показом управляет потребитель:** проп `open` (controlled) или `show()` в
   vanilla. Компонент не решает, уместно ли спрашивать.
3. **Два ответа:** «включить» (первичная кнопка) и «не сейчас» (вторичная). Второй
   ответ ничего не записывает — это забота потребителя.
4. **Пока ждём ответа системы**, плашка занята: обе кнопки disabled (в Vue —
   `busy`; в vanilla — `data-busy` на корне). Системное окно в это время открыто.
5. **Разметка — строка во всю ширину** контейнера: иконка, заголовок, текст,
   действия справа; на узком экране блок текста и кнопки переносятся на
   следующую строку, горизонтальной прокрутки не появляется.
6. **Доступность:** `role="region"`, `aria-live="polite"`, `aria-label` из
   заголовка; кнопки — настоящие `<button>`, чтобы жест достался им.

## Как это сделать в ките

### Vue — `src/vue/components/GnPermissionBanner.js`

По образцу `GnAlert.js`/`GnConfirmDialog.js`: `defineComponent`, `inheritAttrs: false`,
хелпер `cx()`, JSDoc с `@property`/`@slots`/`@emits`.

| Проп | Тип | По умолчанию | Смысл |
|---|---|---|---|
| `open` | Boolean | `false` | показывать ли плашку |
| `title` | String | `''` | заголовок; он же `aria-label` |
| `text` | String | `''` | строка объяснения |
| `acceptText` | String | `'Allow'` | подпись кнопки согласия |
| `dismissText` | String | `'Not now'` | подпись второго ответа |
| `busy` | Boolean | `false` | обе кнопки disabled |
| `icon` | String | `'ph-bell'` | Phosphor-иконка с префиксом `ph-` |
| `variant` | String | `'primary'` | цвет акцента, как у `GnAlert` |

Эмиты — `["accept", "dismiss"]`; слоты — `icon`, `default` (тело вместо `text`),
`actions` (свои кнопки вместо двух штатных). `v-model:open` **не** нужен:
обработчик согласия асинхронный, видимостью владеет потребитель, и закрытие плашки
после ответа — его решение.

Главное требование к реализации: `click` на кнопке согласия вызывает `emit('accept')`
**синхронно**, прямо из обработчика. Никаких таймеров, `nextTick`, промисов и
`await` до эмита — иначе браузер не увидит жест и окна не покажет. Это стоит
зафиксировать комментарием в коде и проверить юнит-тестом: после
`button.click()` (без `await`) `wrapper.emitted('accept')` уже непустой.

### Vanilla — `src/js/components/permission-banner.js`

Поведенческая часть, по образцу `modals.js`/`overlays.js`:

- `PermissionBanner.mount(options)` → `{ show(), hide(), destroy() }`,
  `options = { title, text, acceptText, dismissText, onAccept, onDismiss }`;
- разметка `<div class="permission-banner" data-permission-banner hidden>` с
  `[data-permission-banner-accept]` и `[data-permission-banner-dismiss]`; клик по
  кнопке согласия зовёт `onAccept` синхронно;
- регистрация в `src/js/index.js` — и в объект `api`, и в именованные экспорты
  (`GNexusUIKit.PermissionBanner`).

По желанию — `PermissionBanner.requestNotificationPermission()`: единственное место,
где кит вообще упоминает `Notification`, и вызывается оно только из потребительского
жеста. Если оставлять не хочется — не оставляйте, но тогда в доке напишите, что
вызов на стороне потребителя, чтобы его не увели в `await`.

### Стили

Новый `src/scss/components/_banners.scss` + `@use` в `src/scss/kit.scss` (рядом с
`_alerts.scss` — они соседи по смыслу). Опоры — существующие токены и миксины:
`@include hard_panel($accent-width: $border-width-accent)`, `@include focus_ring` на
`:focus-visible`, `@include hover_touch` на кнопках, `$motion-fast`/`$motion-base`,
заголовок — `$font-size-sm $font-weight-bold` в верхнем регистре, как у остальных
панелей кита, тело — `$font-size-xs` `$color-text-medium`. Цвет акцента — из
`variant` (`state_panel` уже умеет раскрашивать `.card-title`/`.toast-title`/
`.modal-title`; для нового класса добавьте `.permission-banner-title` в тот же
список).

### Референс

Значения проверены у потребителя на живом приложении (Chrome, 1440px и 390px):

```scss
.permission-banner {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: $space-sm $space-md;
  padding: $space-3 $space-4;
  background: rgba($color-secondary, 0.12);
  border-bottom: 1px solid rgba($color-secondary, 0.3);

  i { font-size: 18px; color: $color-secondary; flex-shrink: 0; }

  .permission-banner-body { flex: 1 1 240px; min-width: 0; }

  .permission-banner-title {
    font-size: $font-size-xs;
    font-weight: $font-weight-bold;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    color: $color-secondary;
  }

  .permission-banner-text {
    margin-top: 2px;
    font-size: $font-size-xs;
    color: $color-text-medium;
  }

  .permission-banner-actions {
    display: flex;
    align-items: center;
    gap: $space-sm;
    flex-shrink: 0;
  }
}
```

### Границы задачи

Это самая важная часть: **в ките нет политики.**

- **Не храните ничего.** Ни `localStorage`, ни `sessionStorage`, ни куки. «Помнить
  отказ», «не чаще раза в сессию», «не спрашивать, если уже разрешено» — решения
  потребителя, у каждого они свои.
- **Не решайте, когда показывать.** Ни проверки `Notification.permission` в
  компоненте, ни «показать после входа» внутри кита.
- **Никаких встроенных строк.** Все подписи — пропы; язык интерфейса киту неизвестен.
- **Никакого вызова на показе.** `requestPermission()` — только из жеста.
- **Не запоминайте отказ сами.** Даже «если `denied`, не показывать» — не в ките.
- Путь Android-WebView (мост через нативный интерфейс) остаётся у потребителя: у
  кита нет и не должно быть знания про конкретные оболочки.

Компонент — про жест, вид и контракт; всё остальное — надстройка потребителя.

## Что нужно сдать (приёмка по правилам репозитория)

1. Vue — `src/vue/components/GnPermissionBanner.js` + экспорт в `src/vue/index.js` и
   регистрация в `src/vue/plugin.js`; vanilla — `src/js/components/permission-banner.js`
   + `src/js/index.js`.
2. Стили — `src/scss/components/_banners.scss`, `@use` в `src/scss/kit.scss`;
   новые значения — токенами, не литералами.
3. Демо: секция `permission-banner` в `demo/partials/permission-banner.html` и в
   `demo/partials/vue/permission-banner.html` с одинаковыми `@@include` в
   `demo/index.html` и `demo/vue.html` (порядок секций проверяет
   `scripts/check-demo-sections.mjs`). Плашка в демо рендерится **статически
   открытой** — никакого `requestPermission()` при загрузке, иначе высоты секций
   разойдутся и `compare-heights.js` упадёт.
4. Документация: `docs/catalog.json` (строка по образцу ниже) + `npm run gen:catalog`;
   `docs/vue/component-api.md` (раздел Feedback, рядом с `GnConfirmDialog`);
   `docs/components/feedback.md` (подраздел «Permission Banner»);
   `docs/component-coverage.md`.
5. `npm run release:check` — полный гейт (юнит-тесты, каталог, синхронность демо,
   сборка Vue-адаптера, смоук пакета).
6. Юнит-тест `tests/unit/gn-permission-banner.spec.js`: `open: false` — нет разметки;
   `open: true` — заголовок, текст, две кнопки; `click()` **без `await`** на кнопке
   согласия — `accept` уже эмитирован; `busy: true` — обе кнопки disabled.

Строка каталога (её добавляет тот, кто делает компонент):

```json
{ "need": "Permission prompt banner", "component": "GnPermissionBanner",
  "props": ["open", "title", "text", "acceptText", "dismissText", "busy", "icon", "variant"],
  "useWhen": "Ask for a browser permission (notifications and the like) with a real click as the user gesture; when to ask stays in the app" }
```

## Как проверить глазами (сценарий)

Демо-страница, ширина 1440px и 390×844:

- плашка — строка во всю ширину секции, кнопки справа; на 390px блок текста и кнопки
  переносятся, горизонтальной прокрутки нет, текст не обрезан
  (`scrollHeight > clientHeight + 1` — признак обрезки);
- `busy` (кнопка состояния в демо) — обе кнопки серые и не нажимаются;
- Tab → фокус на кнопке согласия виден кольцом `focus_ring`;
- в обоих демо (vanilla и Vue) секция выглядит одинаково — `node compare-heights.js`
  при сервере демо на `localhost:3000` даёт 0 расхождений.

## Почему именно так (контекст от потребителя)

В navi политика поверх кита уже написана и работает — она же показывает, почему её
нельзя тащить в компонент:

- **Разрешение выдано, а подписка выключена.** Тумблер в настройках при выключении
  отписывает, но permission остаётся `granted`. Наивная проверка «не подписан →
  предложить» молча переподписала бы человека, который сам выключил уведомления.
- **Метки входа нет.** SSO-колбэк в navi возвращает на `return_to` без параметра, и
  отличить свежий вход от перезагрузки нельзя — отсюда «не чаще раза за сессию
  браузера» в `sessionStorage`, а не «раз на вход».
- **Отказ — только явный.** Закрытие системного окна (permission остаётся `default`)
  и «не сейчас» отказом не считаются. Только `denied` глушит плашку на устройстве —
  флаг в `localStorage`.
- **Android-WebView** идёт своим путём (нативный мост) и отдаёт лишь «да/нет»,
  поэтому там отказом считается любой «нет»: у потребителя это единственный способ
  вообще перестать спрашивать.

У потребителя лежат два ключа — `navi.notifications.promptBlocked` (localStorage) и
`navi.notifications.promptShown` (sessionStorage) — и это **пример, а не контракт
кита**: как только в ките появится `GnPermissionBanner`, в navi удаляются свой
компонент и его стили, а политика в `useNotificationPrompt.js` остаётся и начинает
рисовать китовую плашку. Файлы для сверки при реализации: `NotificationPromptBanner.vue`
(разметка и стили, ~90 строк) и `useNotificationPrompt.js` (политика, ~170 строк).
