# Визуальный редактор панели AGIL Calls (`apps/editor`)

Vite-приложение для настройки `TenantConfig` тенанта: бренд, тема, фичи, лейаут,
аудио/видео, fallback, безопасность, доступы, AI- и knowledge-агент, интеграции.
Слева — секции настроек, в центре — живое превью звонка в iframe, справа — JSON,
embed-сниппет, импорт/экспорт и сохранение в тенант.

Редактор **полностью работает офлайн**: при загрузке не делает ни одного сетевого
запроса, превью общается с iframe через `postMessage`, а сохранение в тенант — это
явное действие по кнопке.

Смежные документы: `docs/extensibility.md` (заменяемые провайдеры возможностей),
`docs/messenger.md` (звонки из корпоративного мессенджера, `mode`/`context` комнаты).

## Запуск и сборка

```bash
npm run dev -w @agil/editor        # http://localhost:5174/editor/ (dev, proxy /v1 → :8787)
npm run build -w @agil/editor      # dist/ → сервер раздаёт на /editor/
npm run typecheck -w @agil/editor
```

Прод-сборка кладётся в `apps/editor/dist`, сервер (`@agil/calls-server`) раздаёт её
по `/editor/` (env `AGIL_EDITOR_DIR`). База Vite — `/editor/`, поэтому все ассеты
подтягиваются с префиксом.

Dev-прокси: `/v1` → `http://localhost:8787`, так что «Сохранить в тенант» можно
проверять в dev-режиме без CORS-настроек.

## Колонки

| Колонка | Что внутри |
|---|---|
| Слева | навигация по секциям + сами настройки (zod-схемы `@agil/protocol` → контролы) |
| Центр | iframe-превью + статус подключения, кнопки «открыть в новой вкладке», «обновить», выбор схемы light/dark/auto, лог событий |
| Справа | закреплённая панель сохранения («Сохранить в тенант» + статус), живой JSON конфига, копирование/скачивание, embed-сниппет, импорт, сброс, настройки server URL / tenantId / API-ключ |

Правая колонка прокручивается независимо от страницы, поэтому основное действие
вынесено в **sticky save dock** — панель вверху правой колонки, которая остаётся
видимой при прокрутке (desktop) и закрепляется внизу viewport на `≤1200px`
(`position: fixed`). Высота дока измеряется `ResizeObserver` (`--save-dock-height`),
`.app-body` получает `padding-bottom = высота дока + 28px`, а `html` —
`scroll-padding-bottom`, поэтому последняя секция/поле прокручиваются **выше** дока
и Tab-фокус не уходит под него. На мобильных/планшетах док **скрыт** (`is-offscreen`),
пока правая колонка не видна во viewport: он не перекрывает «Бренд», превью и другие
секции, и появляется при прокрутке к настройкам сохранения. Кнопка сохранения есть и
в доке, и в блоке настроек сервера — обе используют один `performSave()` и общий
статус (включая `success`/`error` от PATCH). Ссылка **«К настройкам»** (skip-link)
всегда видна в левом нижнем углу viewport (на мобильных — над save dock) и является
первым Tab-фокусом (`outline` 2px, ведёт к `#fields`).

Шапка: переключатель светлой/тёмной темы редактора (сохраняется в
`localStorage`), индикатор черновика, tenant-pill и ссылки **API docs** →
`<base>/docs` (человеческий explorer), **OpenAPI JSON** →
`<base>/openapi.json` (машиночитаемая спецификация для Swagger/Postman/кодогенерации;
title это явно поясняет), **API guide** → `<base>/docs/api-guide.md`, **API.md** →
`<base>/docs/api.md` (все открываются в новой вкладке; `rel="noopener noreferrer"`).
`<base>`: на localhost — `AGIL_SERVER_URL`/`http://localhost:8787`; на проде, если явный
нелокальный server URL не задан — `window.location.origin` (сервер раздаёт `/docs` и
`/openapi.json` сам). Так ссылки не уводят на `localhost:8787` в проде (F-07).

На `≤1100px` шапка — **одна строка**: скрыты подзаголовок бренда, draft/tenant-pill,
meta-полоса с ссылками горизонтально прокручивается; переключатель темы вынесен из
неё и всегда виден справа (`#theme-toggle` 34×34). На `≤720px` ссылки показывают
короткие подписи из `data-short` (**API / OpenAPI / Guide / API.md**) — все четыре
видны на 390 без обрезки, полные названия остаются в `title`/ARIA. Высота шапки —
60px на 390/768/1440, горизонтального overflow страницы нет; бренд не сжимается
(логотип + `AGIL Calls`).

## Секции

1. **Бренд** — productName, organizationName, logoUrl/logoHeightPx, supportUrl,
   footerText, faviconUrl; бренд-цвет → `generatePalette` + предпросмотр палитры и
   кнопка «Применить к теме»; 5 пресетов (`light`, `dark`, `corporate`, `night`, `minimal`).
2. **Тема** — scheme, все 36 цветовых токенов с color-picker и бейджем контраста
   (`auditTheme`), панель accessibility, typography, shape, spacing, background,
   logo, motion.
3. **Фичи** — все флаги `features`; roadmap-флаги с бейджем «roadmap», бета — «beta»,
   счётчик включённого.
4. **Доступы** (§18) — режим `role-defaults`/`strict`, матрица «роль × функция»
   (host/moderator/guest), `deniedFeatures`, таблица `userOverrides` (userId + набор
   фич), живой расчёт effective через `resolveAccess` и доступность knowledge-агента
   через `resolveKnowledgeAgentAccess`. Блок **«Свои роли» (customRoles)**:
   таблица id/name/description/baseRole/features/canModerate/color, добавление и
   удаление строк, features — мультивыбор; live-проверка id (lowercase latin/digits/_/-),
   зарезервированных id (host/moderator/guest/anonymous/system/admin), дубликатов,
   лимита 30, пустых features и `baseRole=guest`+recording; подсказка «встроенные
   роли не удаляются, их права меняются в матрице»; всё в JSON-экспорте. Полное
   руководство: `docs/api-guide.md` §3.
5. **Лейаут** — mode/selfView/controlsPosition/chatPosition/chatDefaultOpen,
   show*-флаги, tileAspectRatio, gridMaxTiles, spotlightFollowSpeaker; §22 —
   `chrome` (full/compact/none), **`header`** (full/compact/hidden — плотность шапки
   звонка), **`videoFit`** (contain/cover — как видео заполняет плитку),
   **`immersiveVideo`** (медиа на весь контейнер, шапка/контролы поверх),
   `minimized` (bar/bubble, позиция, self-view,
   контролы), `persistAcrossPages` (+ `AGILCalls.resume()`), `inheritHostStyles`,
   а также «Чат и встраивание»: `session.chat.ui` (builtin/external/hidden),
   `externalProviderName`, `allowHostInjection`, `allowSystemMessages` с
   пояснениями («external — чат компании, виджет только передаёт события»,
   «none — без шапки, как часть страницы», «persistAcrossPages — resume после
   навигации»).
6. **Аудио/видео** — кодеки (мультивыбор + preferred), simulcast/SVC,
   resolution/fps/bitrate/degradationPreference/keyFrameInterval/contentHint;
   audio codecs/dtx/ptime/opusBitrate/jitterBuffer.
7. **Fallback** — lowBandwidth: enabled/strategy/пороги enter/restore/autoReconnect/iceRestart.
8. **Безопасность** — requireToken/waitingRoom/lock/moderatorJoinFirst, пароль
   (тумблер `passwordRequired` + подсказка, что пароль задаётся при создании
   комнаты и хранится только scrypt-хэшем), lockout, geo (allowed/blocked страны
   ISO-3166 alpha-2, VPN), media integrity, session binding, аудит.
9. **Knowledge-агент** (§18) — enabled/endpoint/apiKeyEnv/timeoutMs/allowedRoles/
   requirePermission/citations/maxAnswerChars/systemPrompt/denyMessage + предпросмотр
   «кто может спросить» по ролям.
10. **Сессия** — locale, blur/фоны, join-предпочтения, recording, transcription,
    fileShare, screenShare.
11. **AI-агент** — session.aiAgent + таблица `policyRules` (id/name/type/pattern/
    severity/action/enabled, добавить/удалить).
12. **Интеграции** (§19) — identity (mode token/oidc, issuer, audience, jwksUrl,
    claim-маппинг), allowedOrigins, embedPostMessage, вебхуки (url, events,
    secretEnv, retries, timeoutMs, enabled) + предупреждения (HTTPS, OIDC=501).
13. **Комната и лимиты** — room.name/welcomeMessage/waitingMessage/soundOnJoinLeave,
    limits.maxParticipants/maxDurationMin/maxVideoSubscriptions, а также блок
    «Тестовая комната (контекст мессенджера)»: `mode` (direct/group) и `context`
    (messenger/conversationId/directPeerId/title) — опционально, для тестовых
    комнат; поля не входят в `TenantConfig` (реальные комнаты создаются через
    `POST /v1/admin/rooms`), кнопка копирует готовый JSON-запрос. `direct` = 1:1
    звонок из мессенджера, `maxParticipants: 2`. Рядом — **«Тестовый токен (roleId)»**:
    выбор своей роли (customRoles из раздела «Доступы») и копируемый JSON
    `POST /v1/tokens` с `participant.roleId` (baseRole берётся из выбранной роли).
    Сценарий: `docs/messenger.md`, права ролей — `docs/api-guide.md` §3.
14. **Провайдеры** (§23) — 6 карточек заменяемых возможностей (chat/files/stt/
    notifications/recording/backgrounds): `mode` builtin/external/disabled,
    providerName, endpoint (валидация http(s) с ошибкой под полем), apiKeyEnv
    (только имя env-переменной), timeoutMs, fallback builtin/fail, options JSON
    (с валидацией). Подсказки «по умолчанию встроено; external — система
    компании»; сводка предупреждений (external без endpoint/apiKeyEnv, не-HTTPS,
    fallback=fail, disabled при включённой фиче). Сохраняется в
    `config.providers` через «Сохранить в тенант» (PATCH). Полное руководство:
    `docs/extensibility.md`.
15. **In-call ассистент** — `session.inCallAgent`: enabled, mode
    (local/external/hybrid), endpoint, apiKeyEnv, timeoutMs, systemPrompt, model,
    spotlight, maxActionsPerReply, audit, denyMessage; multiselect `allowedTools`
    (18 инструментов) с бейджами требуемых фич/ролей; пояснения «агент действует
    только с правами пользователя; local работает офлайн», «spotlight — подсветка
    кнопок», «external — LLM компании через HMAC». Конфиг сохраняется обычным
    PATCH; в MCP — `agil_get_assistant_tools` / `agil_explain_spotlight` /
    `agil_explain_feature`; сценарий: `docs/in-call-agent.md`,
    `docs/llm-integrations.md`.
16. **Масштабирование и стриминг** — `TenantConfig.scale`: mediaMode
    (mesh/sfu/broadcast), maxParticipants (2..50000), maxVideoSubscriptions;
    сигналинг (nodeId/redisUrl/pubsubChannel/stickySessions + таблица узлов);
    SFU (provider/url/apiKeyEnv/apiSecretEnv/region/maxParticipants); TURN-пул
    (таблица id/urls/username/credentialEnv/region/weight); балансировщик
    (strategy/healthPath/stickyCookie); вебинар (enabled/hostOnlyPublish/
    viewerLimit/chatMode/reactions/raiseHandQueue); стриминг (enabled/provider/
    rtmpUrl/hlsUrl/latencyMode) и CDN. Live-проверка предупреждений (mesh при
    больших лимитах, sfu без url, TURN без credentialEnv, webinar без broadcast,
    несколько узлов без Redis, streaming без provider, CDN без baseUrl);
    URL-валидация и правило «секреты — только имена env-переменных»; всё уходит
    в JSON-экспорт и сохраняется обычным PATCH. Гайд: `docs/scaling.md` §4–§9.

## Превью: iframe-контракт

Превью показывает **реальную комнату**, иначе вход в звонок давал «Room not found».
Перед монтированием iframe редактор создаёт одноразовую комнату через публичный API:

```http
POST {serverUrl}/v1/rooms
content-type: application/json

{ "tenantId": "<tenantId>", "name": "Превью редактора" }
```

→ `{ room: { id }, token, expiresAt }`; в iframe подставляется
`/embed.html?room=<id>&token=<token>&tenant=<tenantId>&_r=<cache-bust>`. Токен
живёт **только в URL same-origin iframe**: он не попадает в UI, лог событий, загрузки
или адресную строку «Новая вкладка» (там URL без токена). Сессия кэшируется до смены
tenant/server, пересоздаётся при истечении `expiresAt` (обновление за минуту до TTL) и
по кнопке «Обновить». Смена tenant/сервера пересоздаёт комнату и перезагружает iframe;
изменения конфига/темы применяются через `postMessage` без пересоздания комнаты.

Если `POST /v1/rooms` недоступен (анонимные комнаты выключены, 401/403/404/сеть),
iframe получает fallback `?room=preview&tenant=<tenantId>&previewMode=1`. Редактор
инжектит в same-origin iframe баннер **«Это превью темы, чтобы проверить звонок,
создайте комнату»** и блокирует вход: кнопки «Войти в звонок»/«Только слушать»
disabled, submit/клики перехватываются — ложного `Room not found` не возникает.
Если iframe cross-origin (dev-демо на другом порту), инжект невозможен — остаётся
параметр `previewMode=1` для нативной поддержки в embed.

Базовый embed URL: `VITE_PREVIEW_URL` (если задан), иначе dev
`http://localhost:5173/embed.html`, иначе same-origin `<location.origin>/embed.html`
(сервер раздаёт demo в корне, редактор — под `/editor/`). Те же `tenant`, `room`,
`token` уходят в «Новую вкладку» без токена.

Если iframe не отвечает (`agil:ready` не пришёл за ~3.2 с), показывается
offline-overlay «preview offline — JSON/export work» с подсказкой (dev: `npm run dev`;
prod: проверить, что сервер раздаёт `/embed.html`, или задать `VITE_PREVIEW_URL`).
На работающем проде overlay не появляется: iframe подключается и присылает
`agil:ready`.

На каждое изменение конфига (debounce 150 мс) редактор отправляет в iframe:

```js
iframe.contentWindow.postMessage({ type: 'agil:config', config }, '*');
// отдельно при смене темы/схемы:
iframe.contentWindow.postMessage({ type: 'agil:theme', theme }, '*');
iframe.contentWindow.postMessage({ type: 'agil:scheme', scheme }, '*');
```

Слушает сообщения из iframe (по контракту §7):

- `{type:'agil:ready', capabilities}` — статус «превью подключено»;
- `{type:'agil:event', event, payload}` — лог событий;
- `{type:'agil:state', state}` — участники/качество/connectionState (статус);
- `{type:'agil:chat', message}` — лог чата.

§22: если в конфиге `session.chat.ui = 'external'`, редактор показывает пояснение,
что чат компании получает события `agil:chat` и отвечает через `agil:chat-send` /
`sendChat()`; при `hidden` чат полностью выключен. Для `minimized`/`persistAcrossPages`
в экспортируемом сниппете используется `config.layout` и `AGILCalls.resume()`.

Если демо не запущено, редактор остаётся работоспособным: статус `preview offline`,
все настройки, JSON и экспорт работают локально.

Кнопки над превью: «Открыть в новой вкладке», «Обновить превью», селектор схемы
`light/dark/auto` (пишет `theme.scheme` в конфиг и отправляет `agil:scheme`).

Смена схемы не просто меняет флаг: редактор пересобирает палитру
(`src/theme-scheme.ts`, тот же алгоритм, что `themeForScheme` виджета) из пресета
целевой схемы, сохраняя бренд-primary/accent, типографику, shape/spacing, логотип и
фон. Без этого «светлая» схема оставляла тёмные токены — белый текст на светлом фоне
(naive QA 5.9). Нормализация выполняется и в store (экспорт/PATCH), и перед
`postMessage` в превью. Проверка контраста в iframe: в light и dark все текстовые
элементы join-screen имеют ≥4.5:1 (после фикса виджетной подсказки в
`@agil/calls-widget`); минимум — 5.00:1 (light), 6.38:1 (dark). См. `docs/qa-editor.md` §8.2.

## Сохранение в тенант

Правая панель, блок настроек сервера:

- **Server URL** — по умолчанию `http://localhost:8787` (`localStorage` ключ
  `agil.editor.serverUrl`);
- **tenantId** — по умолчанию id из конфига (`agil.editor.tenantId`);
- **API key** — по умолчанию `dev-key` (`agil.editor.apiKey`). Ключ хранится только
  в localStorage и никогда не логируется.

Кнопка «Сохранить в тенант»:

```http
PATCH {serverUrl}/v1/tenants/{tenantId}/config
content-type: application/json
x-api-key: {apiKey}

{ ...полный TenantConfig }
```

Ответ `{config, etag}` → сообщение «сохранено» с etag. Ошибки показываются
человеческим текстом: недоступный сервер (подсказка запустить `npm run dev`),
400 (сервер отклонил значения), 401/403 (проверьте ключ/тенант), 404, 5xx,
таймаут 8 с. Редактор валидирует конфиг локально перед отправкой.

## Маскот-помощник «Agil»

В редакторе есть тот же помощник, что и в панели (одинаковые ассеты, жесты и кнопки):
плавающая кнопка снизу справа (58px, на мобильных 44px, safe-area), перетаскиваемая
мышью/тачем (позиция в `localStorage`), с idle-анимацией `wave` и состояниями
`thinking` / `thumbsup` / `alert` / `angry` / `celebrate`. Пока панель ассистента
открыта, FAB **скрыт**; закрытие по «×» или **Escape** возвращает его. Имя по
умолчанию — **Agil**; старые конфиги/черновики с `branding.mascotName = "Аги"`
мигрируются в `Agil` при загрузке и импорте (пользовательские имена не перетираются).

- **Ассеты**: анимированные WebP `/branding/mascot/animated/mascot-<expr>.webp`
  с корня origin (как отдаёт сервер/demo); если их нет — резервные копии из
  `apps/editor/public/branding/mascot/**` (`/editor/branding/...`), затем статичные
  PNG и inline-инициалы. Всё работает и при выключенном сервере.
- **Роль по местоположению**: при переходе по разделам панель автоматически
  выбирает одну из 7 ролей и показывает её в свёрнутом заголовке «Настройки
  помощника и примеры» (можно сменить вручную — до следующей смены раздела):

| Раздел редактора | Роль |
|---|---|
| Бренд, Тема, Лейаут, Функции, Аудио/видео, Fallback | `config-guardian` |
| Доступы | `roles-admin` |
| Безопасность | `security-reviewer` |
| Интеграции, Провайдеры (вебхуки) | `api-helper` |
| Масштабирование | `scaling-advisor` |
| AI-агент, Knowledge-агент, In-call ассистент | `config-guardian` |
| Комната, Сессия | `onboarding` |

- **Чат**: `POST /v1/admin/assistant` с `x-api-key` (из настроек сохранения),
  `role` и `context` `{screen, tenantId, currentConfig, chatId, memory?}`. `chatId`
  — текущая сессия; `memory` — релевантные фрагменты **других** чатов (≤2000
  символов, поиск по словам с лёгким стеммингом), чтобы помощник помнил прошлые
  обсуждения и не выдумывал факты. Ответ показывает reply, при `fallback:true`
  (нет DeepSeek-ключа) — бейдж **«локальный режим»** и тег «локально». При
  404/401/сети редактор отвечает детерминированным локальным fallback по роли.

- **История чатов**: сессии хранятся в `localStorage`
  (`agil.editor.mascot.chats.v2`, старая `...history.v1` мигрирует в первую
  сессию). Кнопка «История» открывает drawer: авто-заголовок (по первому вопросу),
  дата, поиск по названию/тексту, открыть, переименовать, удалить и «Новый чат»;
  после перезагрузки восстанавливается активная сессия. Кнопка «Новый чат» есть и
  в шапке.
- **Компактность**: роль/стартеры/примеры упакованы в сворачиваемый блок
  «Настройки помощника и примеры» и автоматически сворачиваются после первого
  сообщения; suggestions показываются только в пустом чате; сообщения — основной
  скролл, composer закреплён внизу, увеличены line-height/контраст.
- **Guard перед сохранением**: кнопка «Сохранить в тенант» сначала вызывает
  `POST /v1/admin/guard/assess` `{kind:'config', next: config, intent}`; если
  `requiresConfirmation` — открывается блокирующая модалка с маскотом `alert`/
  `angry`, risk-бейджем, findings (`title`, `description`, `impact`, `affected`),
  рекомендациями и обязательным чекбоксом **«Я понимаю последствия»**. Кнопка
  «Всё равно сохранить» активна только после чекбокса; **«Отмена»/Escape не
  выполняют PATCH** (статус «Сохранение отменено»). Если guard-endpoint недоступен
  (404/401/сеть), строится консервативная локальная оценка (опасные изменения
  features, ограничивающие доступы, внешние провайдеры, http-вебхуки, выключенный
  маскот) с бейджем «проверка без сервера» и тем же чекбоксом.
- **Брендинг**: имя, включённость (`branding.mascotEnabled`), свой `mascotUrl` и
  набор `branding.mascotExpressions` читаются из конфига и обновляются на любое
  изменение бренда (в т.ч. после импорта/сохранения). `mascotEnabled=false`
  скрывает FAB и панель; битый URL уходит в inline-fallback.
- **Мобильный 390**: панель — full-screen overlay (`100dvh`), компактная шапка
  (высота ~49px), FAB скрыт при открытой панели и стоит над закреплённой панелью
  сохранения (bottom 128px + safe-area), не перекрывая «Сохранить в тенант»; при
  фокусе в любое поле ввода FAB скрывается.

## Тест-сценарий: дизайн-превью с N участниками

В тулбаре превью — панель **«Тест-сценарий»** (`details`): количество участников
**1–50** (number + slider, дефолт 2), раскладка `grid`/`spotlight`, стиль контролов
`bar`/`island`/`minimal`, кнопки «Применить»/«Сбросить». Настройки сохраняются в
`localStorage` **per tenant** (`agil.editor.sim.v1.<tenantId>`) и не попадают в
`TenantConfig`, JSON-экспорт/PATCH или скриншот-экспорт.

Там же — layout-переключатели **`header`** (full/compact/hidden), **`videoFit`**
(contain/cover) и чекбокс **«Видео на весь интерфейс (immersive)»**. Они меняют
реальные поля `TenantConfig.layout` (те же, что в секции «Лейаут»), сохраняются/
PATCH-ятся как обычный конфиг и мгновенно уходят в превью сообщением
`{type:'agil:config', config}` — без перезагрузки iframe. Стиль контролов
дополнительно пишется в `layout.controlsStyle`.

Сценарии 25/50 участников проверены с immersive: сетка заполняет весь контейнер
виджета (682×630 на 1440, 368×437 на 390), шапка `compact`/`hidden` — absolute
overlay (hidden: opacity 0 с появлением по hover/focus), контролы `island`/`minimal`
— absolute overlay поверх видео, horizontal overflow страницы/виджета отсутствует.
Скриншоты: `qa/editor/screens/sim-immersive-*.png`; метрики —
`qa/editor/artifacts/simulation-immersive-verify.json`.

После `agil:ready` редактор отправляет в same-origin iframe (URL всегда содержит
`preview=1`, без него виджет отвечает `reason:"preview-required"`):

```js
iframe.contentWindow.postMessage({
  type: 'agil:simulate-participants',
  count, layout, controls, requestId,
}, location.origin);
```

iframe отвечает `{type:'agil:simulate-participants-ack', ok:true, requestId, count,
layout, controls}`; изменения count/раскладки/контролов применяются **без
перезагрузки** (auto-send с debounce 250 мс + «Применить»). Если ack не пришёл за
1.5 с (старый сервер/виджет) — статус «сервер/виджет не поддерживает симуляцию»,
без создания реальных комнат/WS-ботов. Симуляция полностью синтетическая: участники
без peer connections/WS/комнат, счётчик и плитки обновляются в UI, реальные данные
не затрагиваются. При уже подключённом `self` виджет трактует `count` как **всего
участников сцены** (включая self) — плиток ровно N.

Под превью показывается подпись **«Тестовые участники: N (дизайн-превью, не реальные
люди)»**; при fallback/неподдержке она остаётся, а причина пишется в статус панели.
Проверено 1/2/9/25/50 на 1440/390: плитки=N, счётчик=N, participants panel N строк,
без overflow и лагов (ack ~0.4 с, DOM до ~2.4k узлов), `Reset` возвращает дефолт 2.
Скриншоты: `qa/editor/screens/mascot/sim-*.png`; метрики —
`qa/editor/artifacts/simulation-verify.json`.

## Дизайн-токены и типографика

Chrome редактора использует общую шкалу (полные правила — `docs/design-system.md`):

- `--agil-text-xs: 12px`, `--agil-text-sm: 13px`, `--agil-text-md: 14px`,
  `--agil-text-lg: 16px`; минимальный текст — **12px**, `--agil-line-height: 1.5`,
  `--agil-min-font: 12px`.
- Отступы: `--agil-space-1..6` = 4/8/12/16/24/32px.
- Контраст текста ≥4.5:1 в light/dark; текст на `--primary-soft` — только
  `--primary-strong` (chips/active nav/beta), на `--danger-soft` — `--danger`,
  на solid `--danger` — `--danger-fg` (белый в light, тёмный в dark).
- Мелкие подписи (hints, палитра/hex, индексы таблиц, бейджи, статусы, лог превью,
  подписи маскота) переведены с 9–11.5px на 12px; `--muted` затемнён до `#596477`.

Проверено на 16/16 разделах, в панели маскота и guard-модалке, light/dark,
390/1440: шрифтов <12px — 0, пар с контрастом <4.5 — 0
(`qa/editor/artifacts/typography-verify.json`).

## Экспорт / импорт

- **JSON** — живой pretty-JSON текущего конфига; «Копировать» и «Скачать JSON».- **Embed-сниппет** — HTML с `<script src="{serverUrl}/sdk/agil-calls.js">` и
  `AGILCalls.mount('#agil-call', {room, serverUrl, tenantId, theme:{scheme}})`; копируется
  одной кнопкой.
- **Импорт** — вставка JSON в textarea или выбор файла: парсинг, валидация
  `tenantConfigPatchSchema`/`tenantConfigSchema` и разрешение через `resolveTenantConfig`;
  при ошибке показывается список zod-issues (path + message), конфиг не меняется.
- **Сброс к дефолту** — `defaultConfig({tenantId})`.

Черновик автоматически сохраняется в `localStorage` (последние правки переживают
перезагрузку страницы); есть кнопка сброса.

## Безопасность

- Никаких внешних UI-библиотек и CDN: всё локально, включая иконки (inline SVG sprite).
- При загрузке страницы нет сетевых запросов; единственный `fetch` — по кнопке Save.
- API-ключ не попадает в DOM-лог, не пишется в консоль и не включается в JSON/сниппет.
- JSON-импорт и embed-сниппет экранируются; недоверенный JSON валидируется zod до
  применения к состоянию.
- `postMessage` отправляется в `'*'` (MVP-контракт §7); для продакшена ограничьте
  origin через `VITE_PREVIEW_URL`/сервер.
