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, но нет ни действий, ни закрытия, и он не проектировался как призыв. Запроса разрешения кит не касается вовсе.
Из-за этого у каждого потребителя получается свой велосипед, и ошибки в нём одни и те же:
requestPermission() появляется await (загрузка ключа, запрос к API) — user activation к этому моменту истрачен, окно не открывается;Компонент — плашка с призывом и двумя ответами:
requestPermission() — обработчик клика по кнопке согласия, и он же — первый await в цепочке.open (controlled) или show() в vanilla. Компонент не решает, уместно ли спрашивать.busy; в vanilla — data-busy на корне). Системное окно в это время открыто.role="region", aria-live="polite", aria-label из заголовка; кнопки — настоящие <button>, чтобы жест достался им.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') уже непустой.
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):
.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, не показывать» — не в ките.Компонент — про жест, вид и контракт; всё остальное — надстройка потребителя.
src/vue/components/GnPermissionBanner.js + экспорт в src/vue/index.js и регистрация в src/vue/plugin.js; vanilla — src/js/components/permission-banner.js
src/js/index.js.src/scss/components/_banners.scss, @use в src/scss/kit.scss; новые значения — токенами, не литералами.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 упадёт.docs/catalog.json (строка по образцу ниже) + npm run gen:catalog; docs/vue/component-api.md (раздел Feedback, рядом с GnConfirmDialog); docs/components/feedback.md (подраздел «Permission Banner»); docs/component-coverage.md.npm run release:check — полный гейт (юнит-тесты, каталог, синхронность демо, сборка Vue-адаптера, смоук пакета).tests/unit/gn-permission-banner.spec.js: open: false — нет разметки; open: true — заголовок, текст, две кнопки; click() без await на кнопке согласия — accept уже эмитирован; busy: true — обе кнопки disabled.Строка каталога (её добавляет тот, кто делает компонент):
{ "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:
scrollHeight > clientHeight + 1 — признак обрезки);busy (кнопка состояния в демо) — обе кнопки серые и не нажимаются;focus_ring;node compare-heights.js при сервере демо на localhost:3000 даёт 0 расхождений.В navi политика поверх кита уже написана и работает — она же показывает, почему её нельзя тащить в компонент:
granted. Наивная проверка «не подписан → предложить» молча переподписала бы человека, который сам выключил уведомления.return_to без параметра, и отличить свежий вход от перезагрузки нельзя — отсюда «не чаще раза за сессию браузера» в sessionStorage, а не «раз на вход».default) и «не сейчас» отказом не считаются. Только denied глушит плашку на устройстве — флаг в localStorage.У потребителя лежат два ключа — navi.notifications.promptBlocked (localStorage) и navi.notifications.promptShown (sessionStorage) — и это пример, а не контракт кита: как только в ките появится GnPermissionBanner, в navi удаляются свой компонент и его стили, а политика в useNotificationPrompt.js остаётся и начинает рисовать китовую плашку. Файлы для сверки при реализации: NotificationPromptBanner.vue (разметка и стили, ~90 строк) и useNotificationPrompt.js (политика, ~170 строк).