Агентный слой
Запрос подтверждения
Определяющее взаимодействие агентных систем: агент останавливается и спрашивает разрешения. Всё остальное на экране можно проскроллить — это нельзя, поэтому блок единственный в ките, кто имеет право останавливать глаз.
terrain/- heightmap.ts — перезапись
- chunks.bin — удаление
Разметка
<div class="inst-approval" data-state="pending" role="group" aria-labelledby="ap1">
<div class="inst-approval-head" id="ap1">Требуется подтверждение</div>
<div class="inst-approval-what">Записать 4 файла в <code>terrain/</code></div>
<ul class="inst-approval-effects">
<li>heightmap.ts — перезапись</li>
<li data-tone="error">chunks.bin — удаление</li>
</ul>
<div class="inst-approval-actions">
<button class="inst-btn inst-btn--primary" type="button">Разрешить</button>
<button class="inst-btn" type="button">Отклонить</button>
</div>
</div>
Использование
Без любой из них компонент не выполняет свою работу и превращается в украшенное «вы уверены?».
| Что | Класс | Почему обязательно |
|---|---|---|
| Что именно будет сделано | inst-approval-what |
«Разрешить действие?» не является вопросом: пользователь не знает, на что отвечает |
| Что это затронет | inst-approval-effects |
Список последствий. Необратимые помечаются тоном error |
| Решение одним нажатием | inst-approval-actions |
Разрешить и отклонить рядом. Отсутствие «отклонить» превращает запрос в уведомление |
Когда использовать
| Используйте | Возьмите другое |
|---|---|
| Агент остановился и ждёт разрешения продолжить | Агент упал — блок отказа: там нужна причина и то, что уже пробовали |
| Решение принимается в потоке и остаётся в истории | Решение обязано быть принято немедленно — модалка: она блокирует всё остальное |
| Последствия перечислимы и их можно показать | Сообщение без выбора — баннер с тоном warn |
| — | Обычное подтверждение удаления в форме — модалка. Запрос подтверждения агента живёт в ленте прогона, а не поверх неё |
Устройство
<ul class="inst-approval-effects">
<li>heightmap.ts — перезапись</li>
<li data-tone="warn">config.json — изменение настроек</li>
<li data-tone="error">chunks.bin — удаление</li>
</ul>
Тон здесь означает необратимость. Удаление красное потому, что его нельзя отменить, — а не потому, что оно «плохое».
Состояния
Разметка
<div class="inst-approval" data-state="pending" role="group" aria-label="Ожидает">
<div class="inst-approval-head">Требуется подтверждение</div>
<div class="inst-approval-what">Записать 4 файла</div>
<div class="inst-approval-actions">
<button class="inst-btn inst-btn--primary" type="button">Разрешить</button>
<button class="inst-btn" type="button">Отклонить</button>
</div>
</div>
<div class="inst-approval" data-state="approved" role="group" aria-label="Разрешено">
<div class="inst-approval-head">Требуется подтверждение</div>
<div class="inst-approval-what">Записать 4 файла</div>
<div class="inst-approval-verdict">Разрешено в 14:32</div>
</div>
data-state |
Что происходит |
|---|---|
pending |
Ожидает ответа. Единственное, где показаны действия |
approved |
Разрешено. Блок отступает, действия скрыты |
denied |
Отклонено. Описание действия зачёркнуто |
После ответа блок не исчезает и не гаснет — он отступает. История решений остаётся читаемой: пользователь должен иметь возможность вернуться и увидеть, что именно он разрешил час назад. Исчезнувший запрос невозможно проверить.
JS
Подключите модуль один раз на страницу — инициализировать компоненты по отдельности не нужно, кит работает делегированием и видит узлы, пришедшие позже.
<script type="module" src="src/kit.js"></script>
Что делает кит
Ничего. Здесь он честно не при чём: согласие на действие агента — это решение, а не поведение виджета, и подделать его оформлением нельзя.
Что должно сделать приложение
- Отправить решение туда, где оно исполняется.
- Перевести блок в решённое состояние — иначе кнопки останутся живыми и человек нажмёт второй раз.
- Оставить видимым, что было решено: блок согласия — это след в истории, а не диалог, который закрылся.
block.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-decision]');
if (!btn) return;
for (const b of block.querySelectorAll('button')) b.disabled = true;
await api.decide(block.dataset.id, btn.dataset.decision);
block.dataset.decided = btn.dataset.decision;
});
Кнопки блокируются до запроса. Сеть занимает секунды, и за это время «Разрешить» успевают нажать дважды.
Сценарии
Запрос из настоящего прогона — с командой, списком последствий и пометкой необратимого.
Обратите внимание на порядок: что будет сделано, что это затронет, и только потом решение. Запрос, начинающийся с кнопок, требует ответа раньше, чем сообщает вопрос.
- Удалит каталог
build/целиком — 1 284 файла - Необратимо: содержимое не попадает в корзину
- Пересборка займёт около 40 с
Разметка
<div class="inst-approval" data-state="pending" role="group" aria-labelledby="ap2">
<div class="inst-approval-head" id="ap2">Агент просит разрешение</div>
<div class="inst-approval-what">rm -rf build/ && npm run build</div>
<ul class="inst-approval-effects">
<li>Удалит каталог <code>build/</code> целиком — 1 284 файла</li>
<li data-tone="warn">Необратимо: содержимое не попадает в корзину</li>
<li>Пересборка займёт около 40 с</li>
</ul>
<div class="inst-approval-actions">
<button class="inst-btn inst-btn--primary inst-btn--sm" type="button">Разрешить</button>
<button class="inst-btn inst-btn--sm" type="button">Отклонить</button>
<button class="inst-btn inst-btn--ghost inst-btn--sm" type="button">Разрешать всегда</button>
</div>
</div>
Правила
Так Последствия перечислены до кнопки
Человек соглашается с тем, что прочитал. Список последствий — содержание согласования, а не украшение.
Не так Согласование без отказа
«Отклонить» обязателен и обязан быть равноправной кнопкой. Согласование с одним выходом — это уведомление.
Так Разрушительное действие названо глаголом
«Удалить 4 файла», а не «Продолжить». Кнопка — последнее, что читают.
Не так Тихое согласование по таймауту
Молчание не является ответом. Истёкшее ожидание — отказ, и об этом надо сказать словом.
Доступность
| Группа | role="group" + aria-labelledby на заголовок. Иначе блок разваливается на несвязанные абзацы |
| Появление | Запрос возникает по инициативе машины, поэтому обязан попасть в живую область: aria-live="assertive" или перевод фокуса на блок. Тихо появившийся запрос будет ждать вечно |
| Порядок действий | «Разрешить» первым: главное действие идёт первым, и это единственный сигнал, какое из двух главное |
| Отклонение доступно всегда | Кнопка «Отклонить» не может быть спрятана в меню. Отказ должен стоить столько же нажатий, сколько согласие |
| Не только цвет | Необратимое последствие помечено тоном и словом («удаление»), а не одной краснотой |
| Фокус после ответа | Действия исчезают вместе с ответом — приложение обязано перевести фокус, иначе он провалится в <body> |
API
| Имя | Значение | Что делает |
|---|---|---|
| класс | ||
inst-approval | — | Блок |
inst-approval-head | — | Заголовок со значком |
inst-approval-what | — | Что будет сделано |
inst-approval-effects | — | Список последствий, на <ul> |
inst-approval-actions | — | Решение |
inst-approval-verdict | — | Что решили и когда. Показывается после ответа |
| атрибут | ||
data-state | pending · approved · denied | на inst-approval |
data-tone | warn · error | на <li> последствия |
| токен | ||
--pad-card | var(--space-6) | |
--radius-lg | 11px | |
--space-3 | 6px | |
--gap-inline | var(--space-4) | |
--text-sm | 0.8125rem | |
--text-xs | 0.75rem | |
--warn-bg | light-dark(var(--warn-1), color-mix(in oklab, var(--warn-4) 16%, transparent)) | |
--warn-text | light-dark(var(--warn-5), var(--warn-3)) | |
--err-text | light-dark(var(--err-5), var(--err-3)) | |
--text-muted | light-dark(var(--n-8), var(--n-6)) | |
Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument