diff --git a/brandbook/README.md b/brandbook/README.md
index eb9229d..c862953 100644
--- a/brandbook/README.md
+++ b/brandbook/README.md
@@ -1,12 +1,12 @@
# Вечерний детектив — брендбук
-Единый рабочий брендбук из трёх блоков: «Логотип», «Шрифты», «Цвета». Блок логотипа занимает две страницы, шрифты и цвета — по одной. Страницы белые; в основном документе оставлены только примеры и краткие правила. Технические сведения и источники сохранены в этом описании. В страницах оставлены логотипы, шрифты и палитра. Образцы диплома, бланков и фактур временно убраны из документа; исходные материалы сохранены в комплекте. Используется обновлённый логотип — детектив в шляпе с золотой круглой рамкой и прозрачным внешним фоном. Основная палитра: чёрный #111111, белый #FFFFFF и золотой #FCBF02. Бронза исключена, серый используется только как служебный цвет интерфейса; типографика закреплена за тремя гарнитурами с кириллицей и SIL OFL 1.1: Oswald 600 для логотипа и заголовков, Podkova 600 для других фирменных надписей, Noto Sans 400/700 для основного текста и выделений. Пробковая доска развивает оформление афиш и обложек. Сайт и приложения не изменены.
+Единый рабочий брендбук из четырёх блоков: «Логотип», «Шрифты», «Цвета», «Тон общения». Документ открывает отдельная обложка. Блок логотипа занимает две страницы, шрифты, цвета и тон общения — по одной. Страницы белые; в основном документе оставлены только примеры и краткие правила. Технические сведения и источники сохранены в этом описании. На страницах представлены логотипы, шрифты, палитра и краткие правила общения с примерами. Образцы диплома, бланков и фактур временно убраны из документа; исходные материалы сохранены в комплекте. Используется обновлённый логотип — детектив в шляпе с золотой круглой рамкой и прозрачным внешним фоном. Основная палитра: чёрный #111111, белый #FFFFFF и золотой #FCBF02. Бронза исключена, серый используется только как служебный цвет интерфейса; типографика закреплена за тремя гарнитурами с кириллицей и SIL OFL 1.1: Oswald 600 для логотипа и заголовков, Podkova 600 для других фирменных надписей, Noto Sans 400/700 для основного текста и выделений. Пробковая доска развивает оформление афиш и обложек. Сайт и приложения не изменены.
Все изменения вносятся в этот комплект. PDF и `brandbook.zip` перезаписываются; старые версии и отдельные концепции не сохраняются.
## Комплект
-- `brandbook.pdf` — 4 страницы A4 для просмотра и передачи.
+- `brandbook.pdf` — 6 страниц A4 для просмотра и передачи.
- `index.html`, `brandbook.css` — редактируемый исходник без сборки и сетевых ресурсов.
- `assets/logos/logo.png` — обновлённая на основе исходной эмблемы растровая версия, 1254 × 1254 px, RGBA. Внешний фон прозрачный, белые области внутри знака сохранены. Изображение обновлено с помощью imagegen.
- `assets/fonts/Podkova.ttf` — фирменный вариативный шрифт, насыщенности 400–800; `Podkova-OFL.txt` — его лицензия SIL OFL 1.1. Исходный файл Google Fonts скопирован без изменения байтов, с простым локальным именем.
@@ -36,11 +36,11 @@ pdfinfo brandbook.pdf
pdftotext -layout brandbook.pdf -
```
-При отсутствии соседнего проекта используйте ручную печать в браузере. Просмотр не требует Electron. Экспортёр проверяет загрузку шрифтов и изображений, отсутствие сетевых запросов, 4 HTML-страницы и отсутствие выхода содержимого за поля и границы примеров. После изменений нужен визуальный просмотр PDF.
+При отсутствии соседнего проекта используйте ручную печать в браузере. Просмотр не требует Electron. Экспортёр проверяет загрузку шрифтов и изображений, отсутствие сетевых запросов, 6 HTML-страниц и отсутствие выхода содержимого за поля и границы примеров. После изменений нужен визуальный просмотр PDF.
## Проверки и ограничения
-Проверены три блока и 4 страницы A4, локальные ресурсы и прозрачность PNG эмблемы. Новая основная палитра закреплена в брендбуке; её перенос в `src/assets/base.css` пока не выполнен; текст правил очной игры — с актуальным `GAME_RULES_OFFLINE.md`. Шрифты встроены в PDF.
+Проверены четыре блока и 6 страниц A4, локальные ресурсы и прозрачность PNG эмблемы. Новая основная палитра закреплена в брендбуке; её перенос в `src/assets/base.css` пока не выполнен; текст правил очной игры — с актуальным `GAME_RULES_OFFLINE.md`. Шрифты встроены в PDF.
Oswald, Podkova и Noto Sans разрешено использовать, встраивать и распространять по SIL OFL 1.1. При распространении шрифтов сохраняйте соответствующие лицензии и сведения авторства. При передаче файла шрифта сохраняйте `Podkova-OFL.txt` с copyright. Источник: https://github.com/google/fonts/tree/main/ofl/podkova ; файл: https://raw.githubusercontent.com/google/fonts/main/ofl/podkova/Podkova%5Bwght%5D.ttf . Файл `assets/fonts/LICENSE.txt` относится к Noto. Отдельные файлы a_OldTyper и его NOTICE удалены из комплекта.
diff --git a/brandbook/brandbook.css b/brandbook/brandbook.css
index 0210009..b583d3a 100644
--- a/brandbook/brandbook.css
+++ b/brandbook/brandbook.css
@@ -50,3 +50,9 @@ th{font-size:10px;letter-spacing:1px;text-transform:uppercase}
@page{size:A4;margin:0}
@media print{body{background:transparent}.page{margin:0}*{-webkit-print-color-adjust:exact;print-color-adjust:exact}}
@media screen and (max-width:820px){body{overflow-x:auto}.page{margin:0 0 16px}}
+
+.cover{text-align:center}
+.cover-logo{display:block;width:480px;height:570px;object-fit:contain;margin:48px auto 24px}
+.cover h1{font-size:64px;margin-bottom:14px}
+.cover-caption{font-size:13px;letter-spacing:2px}
+.cover .folio{text-align:left}
diff --git a/brandbook/brandbook.pdf b/brandbook/brandbook.pdf
index 5343215..47ad742 100644
Binary files a/brandbook/brandbook.pdf and b/brandbook/brandbook.pdf differ
diff --git a/brandbook/brandbook.zip b/brandbook/brandbook.zip
index 78832d5..7bbfff2 100644
Binary files a/brandbook/brandbook.zip and b/brandbook/brandbook.zip differ
diff --git a/brandbook/export.cjs b/brandbook/export.cjs
index dec803b..d47beaa 100644
--- a/brandbook/export.cjs
+++ b/brandbook/export.cjs
@@ -32,8 +32,8 @@ app.whenReady().then(async () => {
return { blocks: pages.map(page => page.id), pages: pages.length, overflow, familiesLoaded: ['Podkova', 'Oswald', 'Noto'].every(family => [...document.fonts].some(face => face.family === family && face.status === 'loaded')), fonts: ['14px Noto', '700 14px Noto', '600 48px Oswald', '600 32px Podkova'].every(font => document.fonts.check(font)),
images: [...document.images].length, unloaded: [...document.images].filter(image => !image.naturalWidth).length };
})()`);
- assert.equal(result.pages, 4, 'Expected four pages across three brandbook blocks');
- assert.deepEqual(result.blocks, ['logo', 'logo-mono', 'fonts', 'colors']);
+ assert.equal(result.pages, 6, 'Expected cover and five content pages');
+ assert.deepEqual(result.blocks, ['cover', 'logo', 'logo-mono', 'fonts', 'colors', 'voice']);
assert.deepEqual(result.overflow, [], 'Content reaches the page footer');
assert.equal(result.fonts, true, 'Local fonts must load');
assert.equal(result.familiesLoaded, true, 'All three bundled font families must load');
diff --git a/brandbook/index.html b/brandbook/index.html
index 908e586..a77b302 100644
--- a/brandbook/index.html
+++ b/brandbook/index.html
@@ -2,13 +2,20 @@
Вечерний детектив — Брендбук
+
+
Вечерний детективФирменный стиль
+
+
Брендбук
+
Логотип · Шрифты · Цвета · Тон общения
+
+
Вечерний детектив / Брендбук01 / Логотип
-
Логотип и название
+
Логотип и название
Три композиции: знак, название справа и название снизу.
Знак отдельно · светлый фонЗнак отдельно · тёмный фонНазвание справа · светлый фонНазвание справа · тёмный фонНазвание снизу · светлый фонНазвание снизу · тёмный фон
Светлый фон
Чёрное название, золотые акценты.
Тёмный фон
Белое название, золотые акценты.
Используйте готовые композиции из комплекта.
-
+
Вечерний детектив / Брендбук01 / Логотип · продолжение
Монохром и размещение
@@ -16,7 +23,7 @@
Знак отдельно · светлый фонЗнак отдельно · тёмный фонНазвание справа · светлый фонНазвание справа · тёмный фонНазвание снизу · светлый фонНазвание снизу · тёмный фон
Свободное поле
Не меньше ¼ диаметра знака. В готовых файлах поле уже включено.
Размер и контраст
Знак — от 48 px. С названием — от 240 px по ширине. Фон спокойный, без пёстрого рисунка.
Не менять пропорции, цвета и расположение элементов. Не добавлять тени.
Приглашаем в расследование: дружелюбно, с интригой и уважением к игроку.
+
+
На равных
Обращаемся на «вы», со строчной буквы. Поддерживаем команду, не оцениваем способности игроков.
+
Интрига без спойлеров
Даём завязку и повод строить версии. Не раскрываем решения и ключевые улики.
+
Ясно и по делу
Короткие фразы и конкретные действия. Время, место, цена и правила — без загадок.
+
Детективный характер
«Дело», «улика», «версия», «расследование» — к месту. Без пафоса, давления и избытка восклицаний.
+
+
Ситуация
Как говорим
+
Анонс
У каждого свидетеля своя версия. Соберите команду и выясните, что произошло.
+
Призыв
Выберите игру и зарегистрируйте команду.
+
Подсказка
Сравните время в показаниях. Все ли детали сходятся?
+
Ошибка
Не удалось отправить ответ. Попробуйте ещё раз.
+
Финал
Дело закрыто. Спасибо за ваши версии и командную работу!
+
+
Избегаем
«Только гений раскроет это дело!» «Вы опять ошиблись». «Срочно! Последний шанс!!!»
Вместо этого
«Внимание к деталям поможет найти ответ». «Попробуйте проверить другую версию». Указываем реальный срок регистрации.
+
В анонсах создаём атмосферу. В правилах, оплате и поддержке прежде всего помогаем разобраться.
+
diff --git a/docs/agent/COMPLETED_TASKS.md b/docs/agent/COMPLETED_TASKS.md
index 035d1c5..13af0b0 100644
--- a/docs/agent/COMPLETED_TASKS.md
+++ b/docs/agent/COMPLETED_TASKS.md
@@ -2,6 +2,97 @@
Архив задач, переведённых в статус `DONE`.
+### ED-058 — Добавить карту и лист оплаты в материалы ведущего
+
+- Статус: `DONE`
+- Цель: добавить два согласованных исходных PNG в `Для ведущего/` каждого печатного ZIP.
+- Решение: встроенные ассеты бэкенда, исходные имена и байты; существующие проверки отмены и размера архива.
+- Критерии: оба файла есть в ZIP, в том числе без команд, без преобразования в PDF.
+- Проверка: тесты наличия и точного совпадения байтов, `gofmt`, `go test ./...`, `git diff --check`, ревью Go-стандарта.
+- Ограничения: без обработки изображений, изменений API/UI, физической печати, commit/push.
+- Результат: оба исходных PNG добавляются в `Для ведущего/` с исходными именами для каждой игры. Тесты подтверждают наличие и точные байты файлов, включая игру без команд; копии ассетов сверены с исходниками. `gofmt`, `go test ./...` и `git diff --check` выполнены успешно; diff проверен по Go-стандарту.
+- Ограничения проверки: физическая печать и рабочий сервер не проверялись. Commit/push не выполнялись.
+
+
+### ED-057 — Заменить дипломы универсальным бланком
+
+- Статус: `DONE`
+- Цель: заменить три диплома одним пустым бланком из согласованного PNG.
+- Решение: одностраничный A4 с исходной картинкой целиком, без обрезки и надписей; `Для ведущего/Дипломы/Диплом.pdf`.
+- Критерии: один диплом в ZIP, включая игру без команд; все поля заполняются вручную.
+- Проверка: тесты PDF и архива, визуальная проверка, `go test ./...`, Go-стандарт и `git diff --check`.
+- Ограничения: без автозаполнения, новых зависимостей, изменений API/UI, физической печати, commit/push.
+- Результат: три прежних диплома заменены одним пустым бланком; исходный PNG встроен без изменений. PDF визуально проверен: рамка целиком, поля пустые. `gofmt`, `go test ./...` и `git diff --check` выполнены успешно; diff проверен по Go-стандарту.
+- Ограничения проверки: физическая печать и рабочий сервер не проверялись; исходник около 128 dpi на A4. Предпросмотр: `/tmp/ed057-diploma.pdf`. Commit/push не выполнялись.
+
+
+### ED-056 — Редактор и офлайн-воспроизведение групп в screen
+
+- Статус: `DONE`
+- Репозиторий: `evening_detective_screen`.
+- Цель: редактирование групп только в режиме редактирования, запуск групп в обычном режиме и полное воспроизведение подготовленных групп без сервера.
+- Результат: в редактор добавлены создание, переименование, удаление без удаления треков, режим завершения, перестановка групп и треков внутри группы, перенос треков. Операции используют API ED-055; учитываются запреты активной/приостановленной группы. В обычном режиме — названия и режимы групп, текущая группа, «Играть группу» и возврат к целому плейлисту. Полей и кнопок изменения групп вне редактирования нет.
+- Результат: черновики сохраняются при ошибках, потере связи, внешнем изменении и удалении группы; закрытие с изменениями требует явной отмены. Подтверждённое удаление собственной группы отменяет её черновик. Смена сервера сбрасывает контекст редактора.
+- Результат: локальный пакет, каталог и IPC содержат группы и принадлежность треков; состояние канала хранит groupId. Формат пакета остаётся 1; отсутствующие новые поля не меняют старую форму данных и отпечатки. Метаданные групп входят в новые отпечатки и обнаружение обновлений материалов. Руководство пользователя обновлено.
+- Результат: единая функция переходов используется контроллером, подготовкой следующего файла, командами и офлайн-продолжением. Поддержаны повтор одного/нескольких треков, остановка, следующая непустая группа и остановка в конце списка. При отсутствии файла или неподготовленной группе музыка безопасно останавливается с ошибкой; выход в общий список не выполняется. После восстановления связи применяется серверное состояние даже с прежней версией; офлайн-команды не отправляются задним числом.
+- Результат: обработчики загрузки и завершения защищены от старых операций и сессий. Ожидающий ответ по старой группе не блокирует окончание новой короткой группы. Пауза, продолжение, громкость и ручной выбор сохраняют корректную группу и позицию.
+- Проверено: `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm test` (все 241 тест), `npm run build`, `git diff --check`; после сохранения исходной проверки отмены загрузки дополнительно пройден финальный online.test.js. Проверены парсер, переходы, сохранение/чтение пакетов, отпечатки, API редактора, предзагрузка, ошибки файлов, восстановление связи, устаревшие ошибки и конкурентные окончания.
+- Проверено: Electron — редактор на 1280/375 px, отсутствие элементов редактирования в обычном режиме, запуск, запреты активной группы, режимы, порядок, ошибка создания и повтор, перенос трека, удаление группы с сохранением треков, сохранение черновика офлайн и при удалении извне, подтверждение отмены, офлайн-запуск и отсутствие горизонтального переполнения. Реальный короткий WAV через Controller + MediaPlayer без сервера: повтор, остановка, переход через пустую группу и остановка последней группы. Стенды и снимки находятся в `/tmp/ed056-ui`, аудиостенд — `/tmp/ed056-audio.cjs`.
+- Проверено: ручное ревью целевого diff по существующим паттернам screen, границам IPC/API, сохранению данных и совместимости; независимое статическое ревью ядра, редактора и обработчиков ошибок. Обнаруженные гонки исправлены и покрыты тестами; блокирующих замечаний не осталось.
+- Ограничения: редактирование требует сервера с API ED-055; офлайн используются подготовленные группы и файлы. Новые зависимости, backend API и миграции не добавлялись.
+- Не проверено: звук на физическом оборудовании площадки и установленный релизный пакет. Развёртывание, commit и push не выполнялись; для использования изменений требуется обновить screen.
+
+### ED-055 — Группы музыкального плейлиста
+
+- Статус: `DONE`
+- Репозитории: `evening_detective_server`, `evening_detective_frontend`; совместимость `evening_detective_screen`.
+- Цель: именованные группы внутри плейлиста с порядком групп и треков, режимами повтор / остановка / следующая группа.
+- Результат: группы создаются, переименовываются, переставляются и удаляются без удаления их треков; треки назначаются группе или остаются без группы. Новая группа повторяется по умолчанию. «Играть группу» сразу запускает первый трек; переход пропускает пустые группы и останавливается в конце списка. Старое воспроизведение всего плейлиста сохранено.
+- Результат: сервер защищает активные и приостановленные группы во всех использующих их играх. Перестановка групп заблокирована до остановки плейлиста; переименование доступно. Выбор следующего трека выполняется под блокировками плейлиста и состояния, запоздавшие/повторные события окончания не перезаписывают новую команду. Переупорядочивание и удаление других треков сохраняют текущий файл и позицию.
+- Результат: редактор сохраняет черновики при обновлении и удалении группы извне; исправлено пересоздание редакторов позиционными обёртками NSpace. Строковые int64 из protobuf JSON учитываются при сравнении версий и вычислении позиции паузы. Добавлены protobuf API и миграция `20261027120000_playlist_groups.sql`; группы, режимы, порядок и переназначенные ID переносятся экспортом/импортом. Старые пакеты без групп поддерживаются.
+- Результат: нативный плеер сохраняет выбранную группу при паузе, продолжении, громкости и повторном запуске трека; повтор единственного трека перезапускает аудио. Управление треками и режимом группы выполняется через сайт. При потере связи выбранная группа останавливается вместо перехода в общий список; отдельный нативный редактор и автономный каталог групп не добавлялись.
+- Проверено: генерация protobuf в трёх репозиториях; `gofmt`; полный `go test ./...` с отдельной PostgreSQL через `TEST_DATABASE_URL`, включая конкурентные окончания, права/ошибки, защиту редактирования, экспорт/импорт и старые пакеты; frontend `type-check`, `lint`, `lint:css`, `build`; native `type-check`, `lint`, `lint:css` и все 218 тестов; `git diff --check`.
+- Проверено: Firefox на 1280/375 px — режим по умолчанию, запуск/пауза/продолжение, запреты редактирования, порядок групп, ошибка создания и повтор, перенос трека, сохранение удалённого извне черновика и отсутствие горизонтального переполнения. Реальный короткий WAV в браузере — повтор одного трека, остановка, переход через пустую группу, повтор запроса после временной ошибки; строковые версии 9→10 и 9007199254740995 передаются точно. Скриншоты и временные стенды оставлены в `/tmp`.
+- Проверено: ручное ревью diff по `GO_STANDARD.md` и `FRONTEND_CODE_STANDARD.md`; независимое статическое ревью блокировок, согласованности каталога и сохранения черновиков. Блокирующих замечаний не осталось.
+- Не проверено: звук на физическом оборудовании площадки и развёртывание. Production-миграция не выполнялась; для включения функции нужны миграция БД и обновление сервера/клиентов. Commit/push не выполнялись.
+
+### ED-053 — Инструкция перед QR команды
+
+- Статус: `DONE`
+- Цель: в PDF и чеках сначала краткая инструкция Wi-Fi, затем QR, ниже название команды; предпросмотр соответствует чеку.
+- Критерии: текст помещается при предельных длинах, QR сохраняет размер и читаемость; без Wi-Fi печатаются QR и название. API и настройки не меняются.
+- Проверка: тесты рендеринга, просмотр образцов и декодирование QR; `gofmt`, `go test ./...`, ручное ревью по GO_STANDARD.md.
+- Результат: общий текст сокращён до подключения к сети, пароля, напоминания остаться без интернета и сканирования QR. В PDF, чеках и предпросмотре инструкция сверху, затем QR, ниже название команды. При пустой сети — QR и название; размеры QR сохранены.
+- Проверено: `gofmt`, `go test ./...`, целевые тесты печати (включая предельные длины и регрессию порядка QR/названия), `git diff --check`; ручное ревью diff по GO_STANDARD.md. Образцы PDF и чека просмотрены; три QR из PDF/PNG декодированы `zbarimg`.
+- Не проверено: физическая печать и сканирование телефоном на площадке. Commit/push не выполнялись.
+
+
+### ED-052 — Печатать инструкцию подключения к Wi-Fi рядом с QR команды
+
+- Статус: `DONE`
+- Цель: название сети и пароль задаются в настройках каждой игры и печатаются в PDF и чеках, включая предпросмотр.
+- Решение: при пустом названии сети инструкция отсутствует; пустой пароль означает открытую сеть. Печать доступна онлайн и на площадке. Настройки переносятся в пакете игры и доступны для редактирования только управляющим игрой.
+- Критерии: настройки сохраняются и очищаются; старые клиенты не стирают их; старые пакеты импортируются; текст помещается, QR читается. PDF с Wi-Fi содержит шесть карточек на листе.
+- Проверка: тесты хранения, прав, экспорта/импорта, валидации и рендеринга; `go test ./...`; генерация protobuf; frontend type-check, lint, lint:css и build; ручное ревью стандартов.
+- Ограничения: без Wi-Fi QR, новых зависимостей, commit и push. Проверка физического принтера и телефонов отдельно на площадке.
+- Результат: добавлены поля игры, миграция `20261023120000_game_wifi.sql`, optional-поля обновления и форма с маскированным паролем. Параметры возвращаются только управляющим игрой; игрокам и в списки не выдаются. Старый клиент сохраняет настройки, явные пустые значения очищают их.
+- Результат: PDF содержит 6 карточек при заданной сети, чек и его предпросмотр включают одинаковую инструкцию. Текст переносится, длинное название команды на чеке уменьшается при необходимости; размер QR сохранён. Пустая сеть отключает инструкцию, пустой пароль обозначает открытую сеть. Экспорт/импорт переносит параметры без изменения версии пакета; старые пакеты совместимы.
+- Проверено: ручное ревью всего diff по `GO_STANDARD.md` и `FRONTEND_CODE_STANDARD.md`, `gofmt`, `git diff --check`; `make generate`, `npm run proto:gen`; `go test ./...` с отдельной PostgreSQL (включая реальные миграции, сохранение/очистку, старых клиентов и экспорт→импорт); `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm run build` — успешно.
+- Проверено: браузерный smoke на 390 и 1440 px — загрузка полей, маскировка пароля, валидация, сохранение пробелов пароля, ошибка сервера, защита от повторной отправки и очистка. Образцы PDF и чеков просмотрены, 8 QR из PDF и PNG успешно декодированы `zbarimg`; предельные длины покрыты тестами.
+- Ограничения проверки: физический принтер, телефон и подключение к реальному Wi-Fi не проверялись. Рабочая БД и серверы не обновлялись; при запуске нужна миграция. Для изменения уже подготовленного офлайн-пакета требуется новый экспорт. Commit/push не выполнялись.
+
+
+### ED-051 — Перенести идеи пульта во вкладку «Экраны»
+
+- Статус: `DONE`
+- Цель: перенести согласованные идеи `evening_detective_screen` на вкладку «Экраны» страницы игры.
+- Результат: предпросмотр и подключение экранов расположены сверху; ниже адаптивные колонки плана экранов и музыки. Добавлены миниатюры всех шагов, выделение текущего шага, компактные кнопки и ползунки громкости; перемотка удалена.
+- Результат: редактор музыки отделён от управления воспроизведением. Создание, переименование, загрузка MP3, удаление и перестановка треков используют ID редактируемого плейлиста. Назначение музыки игре выполняется явно; черновики сохраняются при поллинге и ошибках, конфликты внешних изменений показываются пользователю.
+- Результат: пауза, продолжение и громкость основного медиа сохраняют позицию; повтор текущего музыкального трека обрабатывается проигрывателем. Устаревшие ответы поллинга музыки не перезаписывают команды. Добавлены проверки ввода таймера, текста и MP3, блокировка повторных запросов и сообщения об ошибках.
+- Проверено: `node tests/screens-control.mjs`, `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm run build-only`, `git diff --check` — успешно. Diff вручную проверен по FRONTEND_CODE_STANDARD.md.
+- Проверено: Firefox на 1280 и 375 px, отсутствие горизонтального переполнения и автозапуска медиа миниатюрами, таймер и музыка, громкость мышью и клавиатурой, черновик плана после поллинга, независимый выбор плейлиста, ошибка сохранения порядка и повтор, переименование, отключение экрана и ошибка API. Браузерные проверки выполнены на локальных синтетических ответах API.
+- Ограничения: сервер, protobuf, Electron и зависимости не изменялись. Реальный внешний дисплей и production не проверялись; предпросмотр отражает данные API, без точного прогресса и подтверждённого состояния медиапроигрывателя. Commit/push не выполнялись.
+
### ED-049 — Оптимизировать медиа пакета локальной игры
- Статус: `DONE`
@@ -418,3 +509,36 @@
- Автоматические проверки: `go test ./...`; фронтенд — `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm run build`; приложение экрана — те же проверки, сборка и `npm test` (203/203). Миграция проверена дважды на временных таблицах PostgreSQL: надписи, порядок, текущий шаг, неизменность остальных шагов и повторный запуск корректны.
- GUI: целевой проход восьми шагов Electron завершился `ED050_TEXT_SCREENS_PASS [1,2,8,4,8,6,7,8]`; обе надписи проверены в DOM. Общий smoke не завершился: `Timeout: volume slider keyboard update`; устаревшие ожидания автопаузы музыки в нём исправлены, код проигрывателя не менялся.
- Ограничения: production-миграция не запускалась; сервер, сайт и приложение экрана требуют согласованного обновления. Коммиты и push не выполнялись.
+
+### ED-054 — QR-экран, список команд и типы таймера
+
+- Статус: `DONE`
+- Результат: добавлены режимы QR (ссылка HTTP/HTTPS и необязательный текст) и списка команд; три типа таймера с исходной надписью и сохранением пользовательского текста при смене типа. Настройки доступны в веб-панели и приложении экрана, работают предпросмотры и браузерный вывод `/screen`.
+- Команды: только названия активных регистраций, по русскому алфавиту; обновление каждые пять секунд, сохранение последнего снимка по ключу игры в приложении экрана, восстановление без сети после перезапуска. При ошибке предыдущий список сохраняется; при смене игры очищается.
+- Совместимость: старые планы и хеши офлайн-пакетов сохранены, таймеры без типа означают «Перерыв» без замены собственного текста. Исправлена инициализация общего состояния таймера при первом подключении к шагу без длительности. Новый `GetScreenTeams` передаёт токен в теле POST; ответ содержит только ID и название.
+- Миграция: `20261023130000_screen_qr_timer_type.sql` добавлена в сервер и `prod/migrations`; на рабочей БД не применялась.
+- Проверки: штатная генерация protobuf во всех трёх приложениях; `go test ./...`; интеграционные тесты PostgreSQL для экранов, миграций и команд; 208 тестов приложения экрана; type-check, lint, lint:css и сборки обоих Vue-приложений; `node tests/screens-control.mjs`; Electron smoke предпросмотров 1280×720 / 1024×768 с QR и 32 длинными названиями; QR декодирован со снимка; Firefox smoke QR на ширинах 1280/375/160, списка и реального `/screen` с тестовыми API-ответами. Проверены ошибки сети, старые пакеты, сохранение снимка и изоляция игр. Diff вручную проверен по стандартам Go и фронтенда, `gofmt` и `git diff --check` — без замечаний.
+- Ограничения: новые анимации отложены по согласованию; реальный проектор не проверялся. Публикация, commit и push не выполнялись.
+
+### ED-059 — Отдельная страница плейлистов
+
+- Статус: `DONE`
+- Цель: `/playlists` для авторов и организаторов с текущими операциями; редактор в игре сохраняется и переиспользуется.
+- Критерии: общий редактор и API-действия; защита черновиков; состояния загрузки, ошибки, пустого списка и отсутствия доступа; пункт меню.
+- Проверка: type-check, lint, lint:css, build; smoke операций и регрессии музыки в игре.
+- Ограничения: без новых зависимостей, API и предпросмотра звука; без commit/push.
+- Результат: добавлены `/playlists` и пункт меню для авторов/организаторов; редактор и API-действия общие со страницей игры. Сохранены операции, MP3-валидация, блокировки, синхронизация и черновики; добавлено подтверждение ухода. API и зависимости не менялись.
+- Проверено: `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm run build`, `git diff --check`; ручное ревью diff по FRONTEND_CODE_STANDARD.md. `python3 tests/venue/playlists.py` прошёл: ошибка/повтор, пустой список, создание/выбор/переименование/удаление, пустой MP3 и загрузка, порядок и группы, отказ сохранения с сохранением черновика, роли, route guard, клавиатура, 1280/375 px. Отдельный браузерный smoke музыки в игре прошёл: play/pause/resume, активная группа, режимы, порядок, ошибки, черновик удалённой группы, 1280/375 px.
+- Ограничения проверки: браузерные тесты используют изолированный тестовый API; рабочий сервер и реальное воспроизведение MP3 не проверялись. Commit/push не выполнялись.
+
+### ED-060 — Загружать MP3 сразу в нужную группу
+
+- Статус: `DONE`
+- Цель: кнопка загрузки в каждой группе общего редактора; трек сразу сохраняется в выбранной группе.
+- Критерии: единый запрос и транзакция вставки с groupID; проверка принадлежности и активности группы; совместимость старых запросов без groupID; обе страницы.
+- Проверка: Go-тесты, интеграционная проверка PostgreSQL, генерация API, type-check, lint, lint:css, build и браузерный smoke.
+- Ограничения: без новых зависимостей, миграций, пакетной загрузки и commit/push.
+- Результат: «Добавить MP3» находится в каждой группе, включая «Без группы», на странице плейлистов и внутри игры. `groupID = 5` передаётся одним запросом; repository проверяет группу и её использование под существующей блокировкой и атомарно вставляет трек с группой. Запросы без поля сохраняют прежнюю загрузку без группы; очистка файла при отказе остаётся в существующем service flow. Обновлены все callers и generated API.
+- Проверено: `make generate`, `npm run proto:gen`, `gofmt`, `go test ./...`; полный набор тестов `playlists_repo` с `TEST_DATABASE_URL` на отдельной локальной PostgreSQL прошёл, включая пустую/заполненную/чужую/удалённую/активную группу и отсутствие вставки при отказе. `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm run build`, `git diff --check` прошли; diff проверен по Go- и frontend-стандартам.
+- Браузер: `python3 tests/venue/playlists.py` — прямое назначение группы одним запросом, отказ сервера с сохранением черновика, операции редактора и 375/1280 px. Дополнительный игровой smoke `/tmp/ed060-game-upload-browser.py` — загрузка в группу через GameMusicPlanPanel, блокировка активной группы, доступность другой группы и регрессия воспроизведения/групп/черновиков.
+- Ограничения проверки: браузер использует изолированный API; рабочее файловое хранилище и звучание MP3 не проверялись. Новому фронтенду для назначения группы нужен обновлённый бэкенд. Commit/push не выполнялись.
diff --git a/evening_detective_screen b/evening_detective_screen
index 06af1e0..6e83c01 160000
--- a/evening_detective_screen
+++ b/evening_detective_screen
@@ -1 +1 @@
-Subproject commit 06af1e047d67d84d0fc8161af5cb1827a76f1b08
+Subproject commit 6e83c014e55b2da7f756c94524d9c89376c79827
diff --git a/prod b/prod
index f4ad26f..34ad58a 160000
--- a/prod
+++ b/prod
@@ -1 +1 @@
-Subproject commit f4ad26f7ffe54ee815134926b1118109426c3bea
+Subproject commit 34ad58a2494bad8457c86ed8009b175d06132f93
diff --git a/reproxy b/reproxy
index 00bd14b..7aaa424 160000
--- a/reproxy
+++ b/reproxy
@@ -1 +1 @@
-Subproject commit 00bd14bac1ce007f05056ac663b352ad09023064
+Subproject commit 7aaa4245c8bdd05c28b02c4594d8193f5226796e
diff --git a/tests/venue/README.md b/tests/venue/README.md
index f4cbb26..23cca6e 100644
--- a/tests/venue/README.md
+++ b/tests/venue/README.md
@@ -223,3 +223,10 @@ Smoke проверяет три ширины, клавиатурные вкла
`python3 tests/venue/clean.py` проверяет очистку venue с подставным Docker:
порядок команд, повторный вызов, ошибки и сохранность настроек и посторонних томов.
Реальные данные не удаляются.
+
+### Страница плейлистов
+
+После сборки фронтенда: `python3 tests/venue/playlists.py`. Нужен Firefox; тест
+поднимает изолированный API, проверяет загрузку сразу в группу одним запросом, отказ загрузки без потери
+черновика, операции редактора, роли,
+клавиатуру и мобильную ширину. Рабочий сервер и настоящие MP3 не используются.
diff --git a/tests/venue/playlists.py b/tests/venue/playlists.py
new file mode 100644
index 0000000..592fcbe
--- /dev/null
+++ b/tests/venue/playlists.py
@@ -0,0 +1,178 @@
+"""Build the frontend, then run: python3 tests/venue/playlists.py. Requires Firefox."""
+import base64, json, mimetypes, pathlib, socket, subprocess, tempfile, threading, time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from urllib.parse import urlparse
+DIST=pathlib.Path(__file__).resolve().parents[2]/'evening_detective_frontend/dist'
+state={'playlists':[], 'calls':[], 'roles':['organizer'], 'failList':False, 'failGroup':False, 'failUpload':False}
+class Handler(BaseHTTPRequestHandler):
+ def log_message(self,*args):pass
+ def respond(self,data):
+ self.send_response(200);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(json.dumps(data,ensure_ascii=False).encode())
+ def do_GET(self):self.handle_request()
+ def do_POST(self):self.handle_request()
+ def do_PUT(self):self.handle_request()
+ def do_DELETE(self):self.handle_request()
+ def handle_request(self):
+ path=urlparse(self.path).path
+ if path.startswith('/__mock'):
+ path=path.removeprefix('/__mock');payload=json.loads(self.rfile.read(int(self.headers.get('Content-Length',0))) or '{}')
+ if self.command!='GET':state['calls'].append((path,payload))
+ if path=='/api/users/me':return self.respond({'user':{'ID':1,'username':'Тест','roles':state['roles']}})
+ if path=='/api/ui/permissions':return self.respond({'permissions':['games_page'],'environment':'prod'})
+ if path=='/api/playlists':
+ if self.command=='GET':return self.respond({'code':13,'message':'Ошибка списка'} if state['failList'] else {'playlists':state['playlists']})
+ p={'ID':max((p['ID'] for p in state['playlists']),default=0)+1,'name':payload['name'],'groups':[],'tracks':[]};state['playlists'].append(p)
+ return self.respond({'playlist':p})
+ if path.startswith('/api/playlists/'):
+ ident=int(path.split('/')[3]);p=next(p for p in state['playlists'] if p['ID']==ident)
+ if path.endswith('/tracks') and self.command=='POST':
+ assert payload['data'] and payload['filename'].endswith('.mp3')
+ if state['failUpload']:return self.respond({'code':9,'message':'Группа используется'})
+ if payload.get('groupID') and not any(g['ID']==payload['groupID'] for g in p['groups']):return self.respond({'code':5,'message':'Группа не найдена'})
+ p['tracks'].append({'ID':max((t['ID'] for t in p['tracks']),default=0)+1,'name':payload['name'],'filename':payload['filename'],'groupId':payload.get('groupID',0)})
+ elif '/tracks/' in path and self.command=='DELETE':p['tracks']=[t for t in p['tracks'] if t['ID']!=int(path.split('/')[-1])]
+ elif len(path.split('/'))==4 and self.command=='DELETE':state['playlists'].remove(p)
+ elif path.endswith('/groups/order'):
+ p['groups'].sort(key=lambda g:payload['groupIDs'].index(g['ID']))
+ elif path.endswith('/groups'):
+ if state['failGroup']:return self.respond({'code':13,'message':'Тест: группа не сохранена'})
+ if payload.get('groupID'):
+ g=next(g for g in p['groups'] if g['ID']==payload['groupID']);g.update(name=payload['name'])
+ else:
+ g={'ID':max((g['ID'] for g in p['groups']),default=0)+1,'name':payload['name'],'position':len(p['groups'])};p['groups'].append(g)
+ if payload.get('endMode') and payload['endMode']!='PLAYLIST_GROUP_END_MODE_REPEAT':g['endMode']=payload['endMode']
+ else:g.pop('endMode',None)
+ elif path.endswith('/group'):
+ t=next(t for t in p['tracks'] if t['ID']==int(path.split('/')[5]));t['groupId']=payload.get('groupID',0)
+ elif '/groups/' in path and self.command=='DELETE':
+ groupID=int(path.split('/')[-1]);p['groups']=[g for g in p['groups'] if g['ID']!=groupID]
+ for t in p['tracks']:
+ if t.get('groupId')==groupID:t['groupId']=0
+ elif 'trackIDs' in payload:
+ p['tracks'].sort(key=lambda t:payload['trackIDs'].index(t['ID']))
+ elif 'name' in payload:p['name']=payload['name']
+ return self.respond({'playlist':p})
+ return self.respond({})
+ f=DIST/path.lstrip('/')
+ if f.is_file():body=f.read_bytes();mime=mimetypes.guess_type(f.name)[0] or 'application/octet-stream'
+ else:
+ inject=''''''
+ body=(DIST/'index.html').read_text().replace('',''+inject).encode();mime='text/html'
+ self.send_response(200);self.send_header('Content-Type',mime);self.end_headers();self.wfile.write(body)
+server=ThreadingHTTPServer(('127.0.0.1',0),Handler);threading.Thread(target=server.serve_forever,daemon=True).start()
+profile=pathlib.Path(tempfile.mkdtemp(prefix='ed059-playlists-'))
+with socket.socket() as s:s.bind(('127.0.0.1',0));port=s.getsockname()[1]
+(profile/'user.js').write_text(f'user_pref("marionette.port", {port});\nuser_pref("browser.shell.checkDefaultBrowser", false);')
+log=(profile/'firefox.log').open('w');firefox=subprocess.Popen(['firefox','--headless','--no-remote','--profile',str(profile),'--marionette','--remote-allow-system-access','about:blank'],stdout=log,stderr=log);sock=None
+try:
+ for _ in range(100):
+ try:sock=socket.create_connection(('127.0.0.1',port),timeout=1);break
+ except OSError:time.sleep(.1)
+ assert sock is not None,(profile/'firefox.log').read_text()
+ sock.settimeout(20)
+ def read():
+ size=b''
+ while not size.endswith(b':'):size+=sock.recv(1)
+ data=b''
+ while len(data)b.textContent.trim()==={json.dumps(label)}||b.getAttribute('aria-label')==={json.dumps(label)});if(!b||b.disabled)return false;b.click();return true"
+ wait(f"[...{scope}.querySelectorAll('button')].some(b=>(b.textContent.trim()==={json.dumps(label)}||b.getAttribute('aria-label')==={json.dumps(label)})&&!b.disabled)")
+ assert js(code),label
+ time.sleep(.15)
+ def input_value(selector,value):
+ assert js(f'const e=document.querySelector({json.dumps(selector)});if(!e)return false;e.value={json.dumps(value)};e.dispatchEvent(new Event("input",{{bubbles:true}}));return true')
+ command('WebDriver:NewSession',{'capabilities':{'alwaysMatch':{}}});command('Marionette:SetContext',{'value':'chrome'});js('document.documentElement.style.minWidth="0";document.documentElement.setAttribute("minwidth","0");return true');command('Marionette:SetContext',{'value':'content'})
+
+ def choose(selector,label):
+ assert js(f'const e=document.querySelector({json.dumps(selector)});if(!e)return false;e.click();return true'),selector
+ wait('!!document.querySelector(".n-base-select-option")')
+ assert js(f'const e=[...document.querySelectorAll(".n-base-select-option")].find(e=>e.textContent.trim()==={json.dumps(label)});if(!e)return false;e.click();return true'),label
+ def navigate():
+ command('WebDriver:Navigate',{'url':f'http://127.0.0.1:{server.server_port}/playlists'})
+ def upload(name, content="fake-mp3", group="Без группы"):
+ assert js(f'const e=document.querySelector({json.dumps('section[aria-label="'+group+'"] input[type=file]')});const d=new DataTransfer();d.items.add(new File([{json.dumps(content)}],{json.dumps(name)},{{type:"audio/mpeg"}}));e.files=d.files;e.dispatchEvent(new Event("change",{{bubbles:true}}));return true')
+ command('WebDriver:SetWindowRect',{'width':1280,'height':1000})
+ state['failList']=True
+ navigate();wait('document.body.innerText.includes("Ошибка списка")')
+ state['failList']=False;click('Повторить загрузку')
+ wait('document.body.innerText.includes("Плейлистов пока нет")')
+ assert not js('return document.body.innerText.includes("Использовать в игре")')
+ input_value('input[placeholder="Новый плейлист"]','Первый')
+ click('Создать');wait('!!document.querySelector("#music-playlist-name")')
+ wait('document.querySelector("#music-playlist-name").value==="Первый"')
+ wait(r'document.querySelector("input[placeholder=\"Новый плейлист\"]").value===""')
+ input_value('#music-playlist-name','Переименованный');click('Сохранить название')
+ wait('!document.querySelector("#music-playlist-name").disabled')
+ assert state['playlists'][0]['name']=='Переименованный'
+ input_value('input[placeholder="Новая группа"]','Группа')
+ state['failGroup']=True;click('Добавить группу');wait('document.body.innerText.includes("Тест: группа не сохранена")')
+ assert js(r'return document.querySelector("input[placeholder=\"Новая группа\"]").value')=='Группа'
+ state['failGroup']=False;click('Добавить группу');wait('!!document.querySelector("section[aria-label=Группа]")')
+ wait('!document.querySelector("#music-playlist-name").disabled')
+ upload('empty.mp3','');wait('document.body.innerText.includes("Выберите непустой MP3 размером до 64 МиБ")')
+ assert not state['playlists'][0]['tracks']
+ input_value('#music-playlist-name','Черновик загрузки')
+ state['failUpload']=True;upload('rejected.mp3',group='Группа');wait('document.body.innerText.includes("Группа используется")')
+ assert js('return document.querySelector("#music-playlist-name").value')=='Черновик загрузки'
+ assert not state['playlists'][0]['tracks']
+ state['failUpload']=False;input_value('#music-playlist-name','Переименованный')
+ upload('grouped.mp3',group='Группа');wait('document.querySelectorAll(".track-row").length===1')
+ assert state['playlists'][0]['tracks'][0]['groupId']==1
+ assert not any(path.endswith('/group') for path,payload in state['calls']), 'upload must assign the group in one request'
+ wait('!document.querySelector("#music-playlist-name").disabled')
+ upload('first.mp3');wait('document.querySelectorAll(".track-row").length===2')
+ wait('!document.querySelector("#music-playlist-name").disabled')
+ upload('second.mp3');wait('document.querySelectorAll(".track-row").length===3')
+ wait('!document.querySelector("#music-playlist-name").disabled')
+ click('Поднять трек 2 в группе Без группы');click('Сохранить порядок')
+ wait('!document.querySelector("#music-playlist-name").disabled')
+ assert [t['name'] for t in state['playlists'][0]['tracks'] if not t['groupId']]==['second.mp3','first.mp3']
+ choose('section[aria-label="Без группы"] .track-row .n-base-selection','Группа')
+ wait('document.querySelector("section[aria-label=Группа] .track-row")!==null')
+ wait('!document.querySelector("#music-playlist-name").disabled')
+ assert state['playlists'][0]['tracks'][1]['groupId']==1
+ input_value('#music-playlist-name','Черновик')
+ js('window.allowDiscard=false;return true')
+ click('Главная');time.sleep(.2)
+ assert js('return location.pathname')=='/playlists'
+ assert js('return document.querySelector("#music-playlist-name").value')=='Черновик'
+ state['failList']=True
+ # A fresh visit tests the initial load failure again without altering production data.
+ js('window.allowDiscard=true;return true')
+ navigate();wait('document.body.innerText.includes("Ошибка списка")')
+ state['failList']=False;click('Повторить загрузку');wait('!!document.querySelector("#music-playlist-name")')
+ for width in [1280,375]:
+ command('WebDriver:SetWindowRect',{'width':width,'height':1000});time.sleep(.3)
+ assert js('return document.documentElement.scrollWidth<=innerWidth'), 'horizontal overflow'
+ image=command('WebDriver:TakeScreenshot',{})['value'];pathlib.Path(f'/tmp/ed059-playlists-{width}.png').write_bytes(base64.b64decode(image))
+ js('document.querySelector("#music-playlist-name").focus();return true')
+ command('WebDriver:PerformActions',{'actions':[{'type':'key','id':'keyboard','actions':[{'type':'keyDown','value':'\ue004'},{'type':'keyUp','value':'\ue004'}]}]})
+ assert js('return document.activeElement.tagName')=='BUTTON'
+ click('Удалить', 'document.querySelector(".track-row")');click('Подтвердить')
+ wait('document.querySelectorAll(".track-row").length===2')
+ click('Удалить группу');click('Подтвердить');wait('!document.querySelector("section[aria-label=Группа]")')
+ click('Удалить плейлист');click('Подтвердить');wait('document.body.innerText.includes("Плейлистов пока нет")')
+ state['roles']=['author'];navigate();wait('!!document.querySelector("[data-testid=music-editor]")')
+ state['roles']=['user'];navigate();wait('document.body.innerText.includes("Нет доступа к плейлистам")')
+ assert not js('return !!document.querySelector("[data-testid=music-editor]")')
+ assert js('return testErrors')==[],js('return testErrors')
+ print('PLAYLISTS_PASS: direct group upload and rejection, load error/retry, empty/create/select/rename, group error/retry, upload/order/membership/delete, dirty route guard, author/organizer access, keyboard, desktop/mobile')
+finally:
+ if sock:sock.close()
+ firefox.terminate()
+ try:firefox.wait(timeout=10)
+ except subprocess.TimeoutExpired:firefox.kill();firefox.wait()
+ log.close();server.shutdown();server.server_close()