# QA виджета и демо — тотальная проверка (widget/demo)

Дата: 2026-11-13. Зона: `packages/widget`, `apps/demo`. Инструменты: headless
Chrome (puppeteer-core), собранный `packages/server/dist`, реальные комнаты/токены,
`scripts/live-room.mjs` для 60 синтетических участников. Скриншоты и машинные
метрики: `qa/widget/**` (см. `qa/widget/metrics.json`).

## Как воспроизвести

```bash
npm run build -w @agil/protocol && npm run build -w @agil/security && npm run build -w @agil/ai
npm run build -w @agil/calls-server
npm run build -w @agil/calls-widget && npm run build -w @agil/calls-sdk && npm run build -w @agil/demo

# сервер (пример):
PORT=8924 AGIL_ALLOW_ANONYMOUS=1 AGIL_API_KEYS=dev-key:demo \
  AGIL_STATIC_DIR=apps/demo/dist AGIL_SDK_DIR=packages/sdk/dist \
  AGIL_WIDGET_DIR=packages/widget/dist AGIL_JOIN_RATE_LIMIT_PER_MIN=1000 \
  node packages/server/dist/index.js

# много пользователей:
AGIL_ADMIN_KEY=dev-key BASE_URL=http://127.0.0.1:8924 \
  node scripts/live-room.mjs --users=60 --duration=150
```

Проверки выполнялись puppeteer-сценариями (кнопки/шорткаты/CLS/эмодзи/aspect) и
`live-room`; сценарии намеренно не коммитились в `scripts/` (это чужая зона) —
ключевые шаги описаны ниже и в `qa/widget/metrics.json`.

## Исправленные баги

| # | Приоритет | Баг | Фикс |
|---|---|---|---|
| 1 | P0 | Эмодзи реакций «пиксельные»: нативные emoji-шрифты масштабировались | Все реакции теперь inline **SVG** (`packages/widget/src/ui/emoji.ts`): sharp на 16/18/20/24/32px, DPR1/2; никаких `transform: scale`, только целые px. Пикер реакций, всплывающая реакция, aria-label/название. Для произвольного emoji-текста в чате/тостах/ассистенте — emoji font stack + `font-variant-emoji: emoji`; у текст-эмодзи нет transform. |
| 2 | P1 | Esc не закрывал боковые панели | Общий обработчик шорткатов: `Esc` закрывает панель → мини-окно → fullscreen (по приоритету). |
| 3 | P1 | Уведомления могли смещать/перекрывать контролы | Banner вынесен в абсолютный overlay над футером (позиция не участвует в layout); toasts — absolute, прижаты к верху, не пересекаются с панелью управления. CLS тоста = 0.049 (<0.1 «good»), bbox контролов не меняется, пересечения нет. |
| 4 | P2 | Логотип мог растягиваться под фиксированный бокс | Логотип рендерится с `height` из токена, `width:auto`, `max-width:160px`, `object-fit:contain` — пропорции сохраняются (проверено data-URI 200×60). |
| 5 | P2 | Риск растяжения видео/фонов/селф-вью | У видео `object-fit:cover` (кроп без искажения), у фонов/превью — `cover`, аватары — инициалы (без image-stretch). Проверено computed-style. |

Все интерактивные элементы прогнаны кликами: «мёртвых», двойных действий,
обрезанных/разноразмерных иконок не найдено (см. метрики ниже).

## Метрики (headless, `qa/widget/metrics.json`)

### Кнопки и шорткаты

- Все видимые контролы (`mic, camera, screen, blur, chat, hand, reactions,
  assistant, recording, captions, settings, minimize`) кликаются без pageerror.
- `mic`/`camera`: ровно одно изменение состояния на клик (нет двойных действий).
- Размеры иконок тулбара: 1 вариант (унифицированы).
- Шорткаты `m/v/b/h/c` меняют соответствующее состояние; `Esc` закрывает панель.
- Island: «⋯» popover открывается, содержит скрытые/доп. контролы (blur, hand,
  reactions, participants, notes, transcript, …), клик по пункту выполняет
  действие и закрывает popover; `hiddenControls:['chat','recording']` скрывают
  кнопки визуально (API работает). Скриншот: `controls-island-more.png`.

### Уведомления / CLS

- Тосты: bbox контролов до/после — без изменений (shift 0), пересечения нет.
- CLS = **0.049** (порог «good» < 0.1); баннер качества — overlay, контролы не
  сдвигаются (top 686.5 → 686.5 до и после показа баннера).
- Toasts расположены сверху, поэтому не закрывают «Настройки»/«Выйти».

### Эмодзи (вектор)

- `emoji-light-dpr1.png`, `emoji-dark-dpr2.png` — сетка 6 реакций ×
  16/18/20/24/32px на DPR 1 и 2, light/dark.
- Метрики: 30 SVG, ширины `[16,18,20,24,32]` (целые), `transform: none` у всех,
  `image-rendering: auto`, `shape-rendering: geometricPrecision`.

### Медиа и пропорции

- Видео: `object-fit: cover` (искажений нет, кроп по центру).
- Логотип: data-URI 200×60 при height 36 → rendered ratio совпадает с natural,
  `object-fit: contain`.
- Guardify/лендинг: фоновые изображения/превью — `cover`; аватары — инициалы,
  без растяжения.

### 60 участников (live-room)

- `scripts/live-room.mjs --users=60`: 60 WS-участников + наблюдатель = 61.
- В браузере: `data-agil-tile` = **50** (self + 49), плитка
  **«+11 зрителей»** (overflow), счётчик «Участников: 61».
- DOM ~31.5k узлов, JS heap 4→5 MB за 13 с наблюдения, pageerror = 0,
  зависаний нет.
- Скриншот: `many-users-60.png`.

## Ограничения и остаточные замечания

1. **Серверный join rate-limit.** По умолчанию публичный `POST /v1/rooms/:id/join`
   лимитируется (10–120/мин на IP+комнату), поэтому 60 синтетических входов с
   одного IP упираются в 429. Для нагрузочного теста сервер запускался с
   `AGIL_JOIN_RATE_LIMIT_PER_MIN=1000`. Это серверная политика (чужая зона) —
   для реального вебинара лимит должен учитывать NAT/офисный egress.
2. **Native emoji в пользовательском тексте.** Векторными сделаны реакции.
   Произвольные emoji в сообщениях чата/именах рендерятся системным шрифтом
   (emoji font stack, без transform); полная векторизация произвольного текста
   потребовала бы Twemoji-пайплайна (self-hosted) — зафиксировано как P2.
3. **60 участников — signaling/presence**, без публикации медиа: mesh-потолок
   виджета 8 пиров, реальный broadcast/SFU — отдельный серверный этап
   (`docs/scaling.md`). CPU в headless-метриках недоступен; контролировали heap,
   DOM-узлы, pageerror, отзывчивость (нет зависаний, счётчики живые).
4. **Editor/dashboard** вне зоны этого отчёта (контрастные фиксы их бейджей —
   отдельная зона/задача).

## Скриншоты

`qa/widget/`: `controls-island-more.png`, `emoji-light-dpr1.png`,
`emoji-dark-dpr2.png`, `many-users-60.png`, `metrics.json`.

Ранее снятые скриншоты демо/виджета: `apps/demo/screenshots/` (default light/dark,
mobile, guardify, widget dark/mobile, contrast minimal-dark и др.).

## Проверки сборки

`npm run typecheck -w @agil/calls-widget`, `npm run build -w @agil/calls-widget`,
`npm run typecheck -w @agil/calls-sdk`, `npm run build -w @agil/calls-sdk`,
`npm run typecheck -w @agil/demo`, `npm run build -w @agil/demo` — все зелёные.

## Финальные QA-фиксы (F-12, F-14, F-15)

| # | Приоритет | Замечание | Статус | Проверка |
|---|---|---|---|---|
| F-12 | P2 | При 60 участниках 4 наложенных уведомления/баннера перекрывали панель участников | **исправлено** | Максимум 2 тоста (`ToastManager.MAX_VISIBLE=2`), одинаковые сообщения коалесятся в один тост со счётчиком `×N`; reconnect-шторм больше не спамит тостами — показывается персистентный banner «Переподключение…» (автоскрытие при восстановлении); тосты вынесены в левый верх (не пересекаются с controls/panel, z-index ниже панели). Live-room 60: `toastCount=2`, `toastsOverControls=false`, `toastsOverPanel=false`, `banner="Переподключение…"`, 50 плиток + «+11 зрителей», pageerror=0. Скриншот: `qa/widget/many-users-60.png`. |
| F-14 | P3 | `.gf-btn-primary` на `/guardify.html`: белый на `#8576ff` = 3.48:1 | **исправлено** | Фон затемнён до брендового `#5f4fd8` (hover `#6f5fe8`), белый текст: computed contrast **5.84:1** (≥4.5). |
| F-15 | P3 | 6 подписей 10.5–10.92px в join/settings/record/spotlight | **исправлено** | Явные целые размеры: подписи/бейджи/служебный текст — 11px, вторичный текст/пустые состояния — 12px, REC-индикатор — 12px. Headless: в join-экране и в панели настроек нет ни одного текстового элемента <11px (`joinSmall=[]`, `settingsSmall=[]`). |

Данные проверки: `qa/widget/metrics.json` (`metrics.guardifyPrimary`,
`metrics.joinSmallLabels`, `metrics.settingsSmallLabels`, `metrics.notifications`).
`dashboard`/`editor` не затрагивались.

## AI-QA фиксы (BUG-SPOT-FOCUS, BUG-KNOW-GUEST-BTN, BUG-KNOW-RAW-ERROR, BUG-ASSIST-STOP-REC)

| # | Приоритет | Статус | Проверка |
|---|---|---|---|
| BUG-SPOT-FOCUS | P2 | **исправлено** | Spotlight-карточка — `role="dialog"`, `aria-live="polite"`, `tabindex="-1"`; при показе `focus({preventScroll:true})`, подсвеченный target получает `tabindex="-1"` (восстанавливается при закрытии), после Esc/Понятно фокус возвращается на предыдущий элемент. Headless: `activeIsCard=true`, `cardTabIndex=-1`, `role=dialog`, `targetTabIndex=-1`; Esc → `data-agil-spotlight=false`, фокус вне карточки. 390px: карточка в bounds (left 8, right 368, vw 390) — `qa/widget/spotlight-390.png`, `spotlight-focus.png`. |
| BUG-KNOW-GUEST-BTN | P3 | **исправлено** | Кнопка ассистента: если knowledge включён, но прав нет (`state.assistant.available=false`), кнопка **видима, disabled**, tooltip «Нет доступа к ассистенту знаний»; клик не открывает пустую панель (дополнительно toast при программном вызове). Guest-проверка: `disabled=true`, `title="Нет доступа к ассистенту знаний"`, панель закрыта — `qa/widget/knowledge-guest-denied.png`. |
| BUG-KNOW-RAW-ERROR | P3 | **исправлено** | Сырые коды (`internal_error`, `not_configured`, `provider_failed`, `forbidden`, `rate_limited`, `timeout`) маппятся в человеческие ru/en тексты; для не-forbidden ошибок показывается кнопка «Повторить» (повторный запрос). Headless knowledge-запрос: в панели «Ассистент недоступен для вашей роли» (не raw-код); маппинг покрывает остальные коды — `qa/widget/knowledge-error.png`. |
| BUG-ASSIST-STOP-REC | P3 | **исправлено/подтверждено** | `executeAssistantAction('stop_recording')` останавливает запись через `toggleRecording()` и обновляет состояние: `recording.active=true → false`, REC-индикатор виден → скрыт, `aria-pressed` кнопки записи true→false. Headless-сценарий «включи запись» → «останови запись»: PASS — `qa/widget/assistant-stop-recording.png`. |

## Финальные P0/P1 (наивный QA)

| # | Приоритет | Статус | Проверка |
|---|---|---|---|
| P0 — утечка JWT в тосте | P0 | **исправлено** | `openInNewTab()` больше никогда не выводит call URL/токен в UI: при блокировке popup показывается «Браузер заблокировал новое окно. Разрешите всплывающие окна для {host}» + кнопки **«Скопировать ссылку»** (clipboard, ссылка не попадает в DOM/textContent) и **«Повторить»**. Демо-сниппет тоже больше не подставляет реальный токен (только placeholder). Headless: `hasToken=false`, `hasUrl=false`, `rootHtmlHasToken=false`, кнопки на месте; clipboard содержит `embed.html?...token=`, DOM — нет. Скриншот: `qa/widget/popup-blocked-toast.png`. |
| P1 — мобильная шапка 390 | P1 | **исправлено** | Компактная шапка на ≤720/≤480: имя комнаты (ellipsis), таймер, badge участников и статус соединения видимы; второстепенные бейджи/иконки скрыты; island/контролы не ломаются, нет горизонтального overflow. Headless 390: `roomVisible=true`, `textOverflow=ellipsis`, `timerVisible=true`, `countVisible=true`, `docScroll=390`, `headerScroll=390`. Скриншот: `qa/widget/mobile-header-390.png`. |

## P1/P3 от наивного QA (docs/qa-naive.md)

| # | Приоритет | Статус | Проверка |
|---|---|---|---|
| «Подключение…» не заканчивается | P1 | **исправлено** | После join в комнате без удалённых участников status = `connected` (engine отдаёт `idle` при 0 peers, enterJoinedState ставит `connected`, header fallback для одиночного звонка); при наличии peers статус берётся из engine (mesh/качество/reconnect), «Подключение…» не остаётся вечным. Headless: `data-connection=connected`, текст «Подключено», таймер и счётчик идут. Скриншот: `qa/widget/status-connected.png`. |
| Лендинг: пустая чёрная область ~640px | P1 | **исправлено** | `[hidden]{display:none!important}` (авторский `display:flex` больше не перебивает `hidden`), блок звонка скрыт до старта; внутри добавлен empty-state «Здесь появится звонок» с инструкцией (тёмная/светлая тема — токены). Headless: `#call-section` display=none, height=0. Скриншот: `qa/widget/landing-empty-fixed.png`. |
| Названия панелей не соответствуют содержимому | P1 | **исправлено** | «Заметки» → **«AI-анализ звонка»**, empty-state «формируются автоматически из расшифровки (AI), ручной ввод не поддерживается»; «Транскрипт» → **«Расшифровка»**, empty-state зависит от STT/субтитров («Расшифровка появится после включения субтитров» / «Субтитры выключены…» / «Распознавание выключено…»); «Ассистент» → **«Команды помощника»**, кнопка **«Выполнить»**, подсказка «это не чат по содержанию звонка, а командный пульт». Headless подтверждено. Скриншот: `qa/widget/panels-labels.png`. |
| 14 кнопок в 3 ряда на мобильном | P3 | **исправлено** | На ≤480px классический bar автоматически коллапсирует в island (ключевые кнопки + «⋯»), независимо от `layout.controlsStyle='bar'`; resize пересобирает лэйаут, hiddenControls/order сохраняются. Headless 390: style=island, 5 кнопок в main + «⋯», высота 54px, нет overflow. Скриншот: `qa/widget/mobile-island-390.png`. |

## Guardify: layout/статус + контраст join-подсказки

### Guardify 563×545 / 390×844 / 1440×900

**Было:** виджет-хост занимал content-height (tile 16:9), под ним оставалась чёрная область: 1440 — контейнер 380px / root 303px (105px чёрного), 563 — 300/290, 390 — 300/198 (182px); у старого деплоя вдобавок плитка/остров выглядели «на половину ширины».

**Исправлено** (`apps/demo/src/guardify.css`): `.gf-call-container` — flex + `position:relative`, виджет-хост `position:absolute; inset:0` (не зависит от процентных высот flex), `.agil-app/.agil-body/.agil-main { height:100% }`, grid `[data-count='1']` — `grid-template-rows:minmax(0,1fr)`, tile `aspect-ratio:auto; height:100%` → одна плитка занимает весь контейнер (cover-кроп), для 2+ — обычная сетка. На ≤640px контейнер 300px, кнопки формы `flex` и футер переносятся.

**Статус:** `#gf-status` больше не висит на «Подключение…»: начально «Проверка сервера…» → «Сервер доступен»; события виджета дают «В звонке» / «Переподключение…» / «Звонок завершён`; `data-agil-connection` после join = «Подключено».

**Headless (после):** 1440 cont/root/tile = 538×380, 563 = 516×300, 390 = 352×300 — полное совпадение (full=true), overflow нет, island-контролы внутри контейнера (controlsInside=true), кнопки формы в viewport, чат скрыт. Скриншоты: `qa/widget/guardify-final-1440.png`, `guardify-final-563.png`, `guardify-final-390.png`; метрики — `metrics.guardifyLayout`.

### Контраст join-подсказки (P1 от mcp-editor)

- `.agil-join-preview-hint`: непрозрачная `--agil-color-surface` + border tile-border, текст `--agil-color-text-secondary`. Светлая 7.58:1, тёмная 11.72:1 (было 2.36:1 light).
- `.agil-join-placeholder`: surface-подложка + `text-secondary`. Светлая 6.44:1, тёмная 11.88:1.
- Скриншоты: `qa/widget/join-contrast-light.png`, `join-contrast-dark.png`; метрики — `metrics.joinContrast`.

## Ассистент: Enter/raise_hand + меню видео (фидбек заказчика)

| # | Приоритет | Статус | Проверка |
|---|---|---|---|
| Enter не отправлял команду | P0 | **исправлено** | `AssistantPanel`: **Enter отправляет**, Shift+Enter — новая строка; IME/кириллица безопасна (`compositionstart/end`, `isComposing`, keyCode 229); подсказка «Enter — выполнить · Shift+Enter — новая строка» (knowledge — «Enter — отправить…»). Поведение общее для in-call и knowledge-панели. Headless: после Enter — одно сообщение пользователя и spotlight; Shift+Enter → `"строка1\nстрока2"` без отправки. Скриншоты: `qa/widget/video-menu-*.png`. |
| Команда «подними руку» → «Не понял» | P1 | **исправлено/подтверждено** | Intent `raise_hand` исполняется через `toggleHand` (raise ставит true, `lower_hand` — false); headless: «подними руку» → `handRaised=true`, «опусти руку» → `false`. EN-варианты поддерживаются протоколом. |
| Группировка видео-настроек | P1 | **исправлено** | Рядом с камерой — chevron `[data-agil-action="video-menu"]` (aria-haspopup/expanded), popover «Видео»: выбор камеры (enumerateDevices), фон/блюр/изображение, зеркалирование селфи, качество (auto), статистика; отдельная кнопка blur убрана из основного island/bar, но действие доступно в меню (`data-agil-video-bg="blur"`). На ≤480px — bottom-sheet (`left=0, right=390` при vw 390); Esc закрывает и возвращает фокус. API/шорткат `b` продолжает работать. Headless: меню содержит camera/bg×3/mirror/quality/stats, standalone blur отсутствует. Скриншоты: `qa/widget/video-menu-desktop.png`, `video-menu-mobile-390.png`. |
| Дублирование «Выполнить»/Enter | P2 | **исправлено** | `submit()` защищён busy-guard: при `busy`/`asking` повторная отправка игнорируется, кнопка disabled + `is-loading`; Enter и клик используют один путь. |

## Typography / дизайн-токены и локализация ошибок

- В `.agil-root` добавлены шкала `--agil-text-xs:12px … lg:16px`, отступы `--agil-space-1..6`, базовый `line-height:1.5`; все подписи/бейджи/таймстемпы/REC переведены на `12px` (в т.ч. `settings-section-title`, `brand-org`, `header-timer`, `join-offline-text`). Минимум 12px проверяется headless на 390/1440 light/dark.
- Мелкие тексты используют `--agil-color-text-secondary`; `ensureReadableTheme` гарантирует ≥4.5 на фоне/панелях (light/dark). Скриншоты: `qa/widget/typography-light-1440.png`, `typography-dark-1440.png`, `typography-light-390.png`, `typography-dark-390.png`.
- Иконки: `stroke-width: 2`, 20/24px.
- Ошибки join: `room_not_found` → «Комната не найдена. Возможно, она удалена или ссылка устарела. Создайте новую комнату.»; также `room_locked`, `room_full`, `password_required/invalid`, `geo_blocked`, `rate_limited`, `forbidden`, `network_error` — человеческие ru/en тексты с действием; fallback никогда не печатает raw code/английский. Headless: на несуществующую комнату — русский текст, без «Room not found» (`join-error-ru-1440.png`).
- Ассистент/knowledge: нераспознанные коды → «Сервис ассистента вернул ошибку. Попробуйте ещё раз.» (без raw).

## Guardify join screen: без внутреннего скролла, симметрия

**Было:** `.agil-join { overflow:auto }`, карточка 2 колонки при ширине ~518px и высоте 340–480px → вертикальный скролл внутри «Защищённого звонка», превью и форма разной высоты.

**Исправлено:**
- `.agil-join`: `overflow:hidden`, padding 16, `container-type:size` (container queries по ширине и высоте).
- `@container agil-join (max-width:760px)` — одна колонка, компактные 16px отступы; `(max-height:520px)` — превью скрывается, форма по центру; `(max-height:380px)` — скрываются subtitle/shortcuts/server.
- `@container (min-width:701px) and (min-height:521px)` — две равные колонки, preview/form stretch (одинаковые ширина/высота, diff ≤1px).
- Guardify: `.gf-call-container` min-height 480 (≤1040 — 440, ≤640 — 340), карточка `overflow:hidden`; кнопки «Войти/Только слушать» min-height 44 и равная ширина.
- Viewport fallback по высоте для браузеров без size container queries.

**Headless (после):** join/card `scrollHeight === clientHeight` на 563×545, 390×844, 768×1024, 1440×900; page scrollWidth = viewport; 2-col (embed 1440/768) preview/form bbox равны (sym=true); guardify 563/390 — компактный режим без превью. Скриншоты: `qa/widget/guardify-join-563.png`, `guardify-join-390.png`, `embed-join-1440.png`, `embed-join-768.png`, `embed-join-390.png`.

## Preview-симуляция участников (дизайн-тесты)

- Публичный API виджета: `simulateParticipants(count 1–50, { names?, layout: 'grid'|'spotlight', controls: 'bar'|'island'|'minimal' })`, `clearSimulatedParticipants()` — синтетические участники без WebRTC/WS/комнат: камера off, инициалы, корректные счётчики/панель/сетка, виртуализация ≤50 плиток.
- Embed (same-origin parent, только `?preview=1`): `{type:'agil:simulate-participants', count, layout, controls, requestId}` → ответ `{type:'agil:simulate-participants-ack', ok:true, requestId, count, layout, controls}`; без preview — `{ok:false, reason:'preview-required'}`; `agil:clear-simulated` очищает. Реальный звонок/WS не затрагиваются.
- SDK: `AGILCalls.previewSimulate(iframe, count, options)` / `AGILCalls.previewClear(iframe)`; instance-методы доступны и напрямую.
- Headless: 1/2/9/25/50 на 1440 и 390 — плитки = count (≤50), счётчик «Участников: N», phase joined, DOM 464→1641, без overflow; clear возвращает join-screen; preview ack ok=true/count=9. Скриншоты: `qa/widget/sim-1-1440.png`, `sim-50-1440.png`, `sim-1-390.png`, `sim-50-390.png`.

**Семантика count (уточнение):** `simulateParticipants(N)` = **всего участников в сцене, включая self**. Если виджет уже joined (реальный self) — синтетических `N-1`; если self нет — создаётся self + `N-1`. Headless joined: N=1/2/9/25/50 → tiles=N, счётчик «Участников: N» на 1440/390; clear → снова реальный self (1 плитка). Скриншоты: `qa/widget/sim-joined-{1,50}-{1440,390}.png`.

## Pin / active speaker / smoothness (P1)

- Pin в grid-режиме больше не меняет grid-колонки и порядок: убраны `grid-pinned`-перестройки для grid, spotlight-сортировка только при `layoutMode='spotlight'`; закреплённая плитка получает только outline (`is-pinned`), геометрия остальных не меняется. Unpin — без CLS.
- Детекция речи: существующий WebAudio Analyser + **гистерезис 320 мс на старт / 420 мс на стоп**, выбор самого громкого (`RMS`), `data-agil-spotlight-follow` на root; speaking-ring/пульсация отключается при `prefers-reduced-motion`; в списке участников — строка `is-speaking` + подпись «говорит»; без AudioContext/аудио — graceful (analyser не создаётся).
- Плавность: transitions 180 мс на панели/тосты/баннер/шапку/контролы; bottom-sheet панели и safe-area на ≤720px; layout фиксируется абсолютными overlay (шапка/контролы в immersive), без width/height-анимаций.
- Ограничение прогона: финальные headless-скриншоты pin/speaker и bbox-CLS не сняты в этом слоте (код собран и typecheck зелёный); при следующем QA-прогоне добавить `pin-*.png`, `speaker-*.png`.

## Guardify first-run + lower hand + coachmark (P1/P2/P3)

- Guardify при загрузке без токена: `ensureDemoRoom()` сначала пробует join `guardify-demo`; на 404 создаёт комнату `POST /v1/rooms {tenantId:'demo',name:'Guardify demo'}`, обновляет room input/currentRoom/token, автоматически входит и вызывает `scrollIntoView` карточки звонка + focus root. Создание/вход через нижнюю форму также скроллят к карточке.
- Ассистент: `lower_hand` обрабатывается явно (handRaised=false), `raise_hand` — только поднятие; raw protocol switch принимает unknown tools.
- Spotlight-coachmark: авто-закрытие через 12 с, закрытие по клику на backdrop, Esc; focus возвращается.
- Guardify: все `gf-*` подписи ≥12px (`font-size:max(12px,1em)`), примеры/подсказки с отступом; toast-actions: flex-wrap, gap 8, min-height 34, padding 6/12.
- Сборка: protocol/widget/sdk/demo typecheck+build — 0. Headless-прогон fresh guardify (404→создание→звонок) и lower-hand в этом слоте не снят; требуется финальный QA-прогон/скриншоты.

## Call-design P0/P1 fixes (по docs/qa-call-design.md)

- Grid: `align-content:start`, `grid-auto-rows:minmax(0,1fr)`, tile `height:100%/aspect-ratio:auto`, фиксированные раскладки 1/2/3-4/5-6/7-9 (3×3), 25 (5×5), 50 (8×7); `scrollTop=0` при старте, верхние ряды не уходят за origin/не перекрываются.
- Leave: 44×44, фон #DC2626, всегда видима в island.
- Stage fill: grid/tile 100% высоты сцены, video object-fit:cover; 1 участник — на всю сцену.
- Header: 48px desktop / 44px mobile, скрыты дубли бренда/mesh/quality/security/notes/transcript/invite/theme, participants доступна; больше не обрезается на 768.
- Split camera: chevron 30×44 перед main 44×44, gap 0, радиусы 22/0 + 0/22, разделитель rgba(255,255,255,.14), единый 74px контрол.
- Copy fallback: при ошибке clipboard тост «Не удалось скопировать…» без URL/JWT; toast max-width 340 + word-break.
- Spotlight «подними руку»: target `[data-agil-action="hand"]`, scrollIntoView перед позиционированием; карточка 390 — шириной viewport-32, текст переносится.
- Hand polish: 4px ring, glow 40px .60, vignette .40, тёмный внутренний контур.
Сборка widget/sdk/demo — build 0, typecheck widget 0. Headless-прогон 1/9/25/50 на 390/563/768/1440 и скриншоты call-fix-* в этом слоте не сняты — требуется финальный QA.

## Waiting room (lobby) + polls в виджете (2026-11-13)

### Что реализовано

- **API-клиент** (`packages/widget/src/api.ts`): `POST /v1/rooms/:id/join` теперь типизированно возвращает `JoinRoomResponse | LobbyJoinResponse` (202 — штатный `pending`, без исключения); добавлены `getLobbyStatus` (public capability poll), `listLobbyRequests`, `admitLobbyRequest`, `rejectLobbyRequest` и host-only `cancelLobbyRequest` (DELETE). Токен впущенного гостя нигде не рендерится и не логируется.
- **Гость**: полноэкранный wait-screen вместо join-экрана (`ui/lobby-screen.ts`) — название/ID комнаты, «Вы в очереди — организатор скоро впустит», elapsed + TTL, статусы `pending / admitted / rejected / expired / timeout / error`, кнопки «Отменить заявку», «Попробовать снова», «Вернуться», `role=status` + `aria-live=polite`, фокус на карточке, Esc отменяет заявку. Polling каждые 2 с (`pollAfterMs`), экспоненциальный backoff до 15 с после ошибок; 404 трактуется как expired/обработано; при `admitted` токен сохраняется только в памяти и виджет автоматически подключается к WS/звонку (без повторного `/join`, self/room/config приходят в `welcome`).
- **Хост/модератор**: кнопка **«Ожидают»** с бейджем-счётчиком в шапке (desktop — с подписью, mobile — icon-only), панель `ui/lobby-panel.ts` со списком заявок (инициалы, имя, страна, «ждёт», TTL), кнопками «Впустить», «Отклонить», «Впустить всех» (последовательно, т.к. bulk-endpoint отсутствует), ручным обновлением. WS `lobby.queue/lobby.pending/lobby.admitted/lobby.rejected` + polling fallback каждые 5 с, пока панель открыта; тост о новой заявке не перекрывает controls (top-left, max 2). Ошибки 401/403/404/409 маппятся в человеческие ru/en тексты. Фокус в панель при открытии, Esc закрывает, статус очищается при повторном открытии.
- **Мобильный 390**: guest-карточка и host-панель — bottom-sheet над control bar (`data-agil-lobby-sheet`), safe-area, кнопки 44px, controls остаются видимыми и кликабельными (проверяется `elementFromPoint`).
- **i18n**: все строки добавлены в `ru` и `en` (тип `Dict` гарантирует полноту словаря).
- **Polls**: заодно починен реальный вызов панели (`onTogglePolls` не был подключён к ControlsBar) и маппинг протокола `RoomPoll → PollView` (`status open/closed`, `totalVotes`, `options[].selected`, `options: [{text}]` при создании); `poll.updated` приходит в открытую панель.

### Как воспроизвести

```bash
npm run typecheck -w @agil/calls-widget
npm run build -w @agil/calls-widget
npm run build -w @agil/calls-sdk
npm run build -w @agil/demo
node qa/widget/lobby-checks.mjs
```

Скрипт сам поднимает собранный сервер на `127.0.0.1:8907` (временный data-dir, `AGIL_LOBBY_TTL_SEC=30` для проверки expiry), раздаёт `apps/demo/dist` и гоняет Chrome с fake media. Отчёт: `qa/widget/lobby-qa.json`.

### Результаты (headless, 38/38 pass)

- 202 → wait-screen; текст «Вы в очереди — организатор скоро впустит»; комната (имя/ID fallback); elapsed/TTL; отмена; **нет JWT в DOM** (L-01…L-05, L-11, L-25).
- Хост видит badge по WS `lobby.pending`; панель показывает имя/страну/ожидание/TTL; «Впустить» → гость автоматически в звонке, счётчик 2 (L-06…L-10).
- Rejected: терминальный экран + «Попробовать снова»/«Вернуться»; «Вернуться» возвращает join (L-12…L-14).
- Expired по серверному TTL (30 с) + retry/back (L-15…L-16).
- 390: карточка в viewport, нет горизонтального overflow, cancel 44px, host-sheet выше footer, mic кликабелен, admit 44px, гость входит (L-17…L-22).
- EN-локаль, `aria-live=polite`/`role=status`, фокус на карточке, Esc отменяет (L-23…L-26).
- Polls: host создаёт опрос, гость видит его, голосует, host видит live results по `poll.updated`, опрос закрывается; 1440 и 390 (P-01…P-10).
- JWT в console/pageerror: нет (S-01…S-02).

### Скриншоты (`qa/widget/`)

`lobby-wait-1440.png`, `lobby-host-queue-1440.png`, `lobby-admitted-1440.png`, `lobby-rejected-1440.png`, `lobby-expired-1440.png`, `lobby-wait-390.png`, `lobby-host-queue-390.png`, `lobby-admitted-390.png`;
`polls-host-1440.png`, `polls-vote-1440.png`, `polls-closed-1440.png`, `polls-host-390.png`, `polls-vote-390.png`.

### Backend/protocol: замечания (не менялись)

- `LobbyJoinResponse` (202) не содержит имени комнаты/конфига; `GET /v1/rooms/:id` требует admin key. До admit виджет показывает `config.room.name` либо ID комнаты; настоящее имя появляется после `welcome`. Чтобы показывать имя сразу, нужен room name в 202-ответе (или public read-only room info).
- `POST .../admit` фактически отвечает `JoinRoomResponse` (так в OpenAPI и сервере), а не `LobbyDecisionResponse`; клиент типизирован под реальный ответ, токен гостя игнорируется.
- Публичной отмены заявки нет: `DELETE /v1/rooms/:id/lobby/:requestId` требует host/moderator. Кнопка «Отменить заявку» гостя останавливает polling и возвращает на join; серверная заявка истечёт по TTL.
- WS-сообщения `lobby.expired` нет в `@agil/protocol`; истечение определяется по `expiresAt`/ответу poll (`status: expired`), запись остаётся в audit-log.

## Подкомнаты (breakout rooms) в виджете (2026-11-13)

### Что реализовано

- **API-клиент** (`packages/widget/src/api.ts`): `listBreakouts` (host room Bearer), `startBreakout`, `closeBreakout`, `returnFromBreakout`. Все вызовы типизированы (`BreakoutPlan`, `BreakoutListResponse`), принимают явный `token` для управления **основной** комнатой из подкомнаты; сервер может отвечать как `plan`, так и `{plan}` — оба варианта нормализуются. Ошибки маппятся в человеческие ru/en тексты: 403 (только host/moderator), 409 `breakout_active` / `not_enough_participants`, 404/405/501 → fallback «Подкомнаты появятся после обновления сервера», 401/network/timeout. `POST .../return` использует **main**-токен (child-токен не подходит).
- **Хост/модератор**: кнопка **«Подкомнаты»** в шапке (desktop — с подписью, mobile — icon) и в overflow-меню контролов на ≤720; панель `ui/breakout-panel.ts` — режим random/manual (MVP manual = число групп), группы 2–10, длительность 5–120, `allowReturn`, «Запустить», «Закрыть все», живой таймер «До конца mm:ss», список групп с именами участников и пометкой «ваша», статусы «Активны/Завершены», invite-hint после запуска. Имена участников кэшируются в token-free `state.breakout.names`, поэтому группы из других подкомнат не превращаются в «участник».
- **Участник**: на WS `breakout.assigned` сохраняет main room/token/self/chat/joinedAt в приватных полях (токенов нет в store/DOM/console), показывает баннер «Вы в подкомнате N · До конца mm:ss · Вернуться» (кнопка только при `allowReturn`), рвёт старый WebSocket и RTCPeerConnections, сбрасывает remote peers/analysers и подключается к child-комнате с её токеном. На `breakout.closed` или `bye` закрытой child-комнаты (и при исчерпании reconnect) автоматически возвращается в main по сохранённому токену; main-чат восстанавливается (merge истории), child-чат отдельный. Ошибки переключения — toast с «Повторить» и откатом в main.
- **Чистота reconnect**: один экземпляр `SignalingClient`/`WebRtcEngine` на активную комнату, `engine.close()` закрывает все RTCPeerConnections, `capture`/таймеры не дублируются, duration-таймер сохраняет время звонка через `pendingJoinedAt`, `sessionEpoch` отменяет in-flight переключение при `leave()`/`destroy()`, handoff-сессия не перезаписывается child-токеном. Локальная камера/микрофон не перезапрашиваются; screen-share/blur re-apply на новый engine.
- **Мобильный 390**: панель — bottom-sheet над control bar (`data-agil-breakout-sheet`), safe-area (`env(safe-area-inset-bottom)`), кнопки 44px, controls остаются кликабельными (`elementFromPoint`); баннер над контролами, «Вернуться» — 38px touch target. `aria-live=polite`/`role=status`, фокус в панель, Esc закрывает. Fallback на 404/405/501 скрывает форму.
- **i18n**: все строки добавлены в `ru` и `en`; полный словарь проверен типом `Dict`.

### Как воспроизвести

```bash
npm run build -w @agil/protocol
npm run build -w @agil/calls-server
npm run typecheck -w @agil/calls-widget
npm run build -w @agil/calls-widget
npm run build -w @agil/calls-sdk
npm run build -w @agil/demo
node qa/widget/breakout-checks.mjs
```

Скрипт поднимает собранный сервер на `127.0.0.1:8912` (временный data-dir, fake media), создаёт комнату с host-токеном, подключает двух гостей через `POST /join`, запускает 2 группы по 5 минут и проходит полный цикл host/participant. Отчёт: `qa/widget/breakout-qa.json`; лог прогона: `qa/widget/breakout-qa-run.log`.

### Результаты (headless, 31/31 pass)

- Панель открывается из шапки, содержит режим/группы 2–10/длительность 5–120/allowReturn; «Запустить» создаёт план, host и оба гостя получают `breakout.assigned` и переходят в child-комнаты (B-01…B-09).
- Баннер «Вы в подкомнате 1 · До конца 05:00 · Вернуться»; заголовок комнаты меняется на «Подкомната 1»; child-чат не содержит main-сообщений; JWT в DOM отсутствует (B-10…B-13).
- 390: панель — bottom-sheet над контролами (panel.bottom ≤ controls.top), mic кликабелен, start 44px, нет горизонтального overflow; баннер над контролами, «Вернуться» кликабелен, 38px, safe-area CSS (B-05…B-07, B-14).
- Гость возвращается по кнопке: баннер скрыт, main-заголовок восстановлен, main-чат (оба сообщения до breakout) на месте; host и второй гость остаются в подкомнатах (B-15…B-18).
- Host открывает панель из child-комнаты (управление main по main-токену), видит обе группы с именами, «ваша», live-countdown; «Закрыть все» возвращает всех в main, счётчик участников снова 3, main-чат host сохранён, план «Завершены» (B-19…B-25).
- Manual-режим с 3 группами создаёт 3 подкомнаты и корректно закрывается (B-30…B-31).
- EN-локаль полностью переведена; 404 fallback показывает «Breakout rooms will be available after the server is updated»; в console/pageerror нет JWT/ошибок (B-26…B-29).

### Скриншоты (`qa/widget/`)

`breakout-host-panel-1440.png`, `breakout-host-panel-390.png`, `breakout-host-active-1440.png`, `breakout-host-groups-1440.png`, `breakout-participant-390.png`, `breakout-closed-1440.png`, `breakout-manual-groups-1440.png`, `breakout-host-panel-en-1440.png`, `breakout-unavailable-1440.png`.

### Пробелы и ограничения

- **Lobby/polls/assistant в подкомнате.** Панели polls/assistant/lobby используют текущий room/token. В child-комнате host увидит пустые polls и не сможет управлять main-очередью ожидания (403); после возврата в main всё восстанавливается. Для управления lobby из подкомнаты нужен `token`-override в lobby-методах API (не входит в эту задачу).
- **Запись.** При переходе в подкомнату активная запись останавливается (тост «Запись остановлена при переходе в подкомнату»), автоматического возобновления нет: recorder захватывает потоки старого mesh-плоскости. Локальные камера/микрофон продолжают работать.
- **Нет REST-fallback для assignment.** Токен child-комнаты приходит только в WS `breakout.assigned`; участник не может получить его через `GET /breakouts` (endpoint host-only). Если main-WS потерялся до обработки assignment, участник останется в main до закрытия/повторного запуска — серверный gap.
- **План у участника после close.** Участники в child получают `bye` от закрытой child-комнаты, а не `breakout.closed` из main; локальный `plan` может остаться `active` (баннер уже скрыт, UI-эффекта нет). Для консистентности серверу стоит отправлять `breakout.closed` и в child-комнаты, либо возвращать `plan` в `POST .../return`.
- **Manual MVP.** UI выбирает только число групп; явное распределение участников (`rooms[].participantIds`) бэкенд поддерживает, но в интерфейсе его нет.
- **Не проверено live:** `allowReturn=false` (кнопка скрыта, 403-маппинг), 409 `breakout_active`/`not_enough_participants`, 405/501 (проверен 404-перехват), безопасные зоны на реальном notched-устройстве (headless `env(safe-area-inset-bottom)=0`), длительность >5 минут/автозакрытие по `endsAt` (сервер закрывает и присылает `breakout.closed`; таймер проверен по live-countdown).
- **Protocol gap:** `publicPaths` не содержит `roomBreakoutReturn`; путь `.../breakouts/return` собран в `api.ts` из `roomBreakouts`. `POST start/close` реально отвечают планом, а не `{plan}` — клиент поддерживает оба варианта.
- **Room TTL.** При `AGIL_ROOM_TTL_SEC > 0` main-комната может истечь, пока все участники в подкомнатах (main пуст); при дефолтном `0` не воспроизводится. Серверная зона, не менялась.
