Newer
Older
gnexus-ui-kit / docs / tasks / gn-permission-banner.md
@Eugene Sukhodolskiy Eugene Sukhodolskiy 7 hours ago 17 KB Record the permission banner as a planned task

Задача: 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):

.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.

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

{ "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 строк).