diff --git a/.gitea/workflows/ci-api.yml b/.gitea/workflows/ci-api.yml
index d2cb77e..1450a3d 100644
--- a/.gitea/workflows/ci-api.yml
+++ b/.gitea/workflows/ci-api.yml
@@ -1,4 +1,4 @@
-# Go: сервис api и storage-agent (docs/ci-cd.md). Только при изменении их кода или контракта.
+# Go: сервис api и storage-agent. Только при изменении их кода или контракта.
name: ci / api
on:
diff --git a/.gitea/workflows/ci-contract.yml b/.gitea/workflows/ci-contract.yml
index 163e96c..afdda24 100644
--- a/.gitea/workflows/ci-contract.yml
+++ b/.gitea/workflows/ci-contract.yml
@@ -1,4 +1,4 @@
-# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками (docs/ci-cd.md).
+# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками.
name: ci / contract
on:
diff --git a/.gitea/workflows/ci-mobile.yml b/.gitea/workflows/ci-mobile.yml
index 43c2523..b99b089 100644
--- a/.gitea/workflows/ci-mobile.yml
+++ b/.gitea/workflows/ci-mobile.yml
@@ -1,4 +1,4 @@
-# Expo: expo-doctor и Metro-бандл Android (docs/ci-cd.md). Полная сборка APK — только в release.yml.
+# Expo: expo-doctor и Metro-бандл Android. Полная сборка APK — только в release.yml.
name: ci / mobile
on:
diff --git a/.gitea/workflows/ci-recommender.yml b/.gitea/workflows/ci-recommender.yml
index 41dcd6b..4a9b0a8 100644
--- a/.gitea/workflows/ci-recommender.yml
+++ b/.gitea/workflows/ci-recommender.yml
@@ -1,4 +1,4 @@
-# Python: recommender и analyzer (docs/ci-cd.md).
+# Python: recommender и analyzer.
name: ci / recommender
on:
diff --git a/.gitea/workflows/ci-ts.yml b/.gitea/workflows/ci-ts.yml
index 3f13014..4833132 100644
--- a/.gitea/workflows/ci-ts.yml
+++ b/.gitea/workflows/ci-ts.yml
@@ -1,4 +1,4 @@
-# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза (docs/ci-cd.md).
+# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза.
name: ci / ts
on:
diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml
index 4d1f35b..29198dd 100644
--- a/.gitea/workflows/docker.yml
+++ b/.gitea/workflows/docker.yml
@@ -49,7 +49,7 @@ jobs:
- uses: actions/checkout@v4
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
- # не дают копиться образам; кэш сборки чистит на хосте `docker builder prune` (docs/ci-cd.md).
+ # не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
- name: Сборка образов
env: { DOCKER_BUILDKIT: "1" }
run: |
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
index bd2c9c1..d3ab406 100644
--- a/.gitea/workflows/release.yml
+++ b/.gitea/workflows/release.yml
@@ -1,4 +1,4 @@
-# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): Gitea Release с заметками → APK.
+# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK.
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы
# и переписывает статус: собран / не удалась (scripts/release.mjs).
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
@@ -53,7 +53,7 @@ jobs:
runs-on: ubuntu-latest
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
- # (container.valid_volumes: ['/opt/ci-cache/**'], docs/ci-cd.md); иначе раннер их пропускает и всё качается заново.
+ # (container.valid_volumes: ['/opt/ci-cache/**']); иначе раннер их пропускает и всё качается заново.
# с блоком container act запускает шаги через sh; скриптам нужен bash (массивы, pipefail)
defaults:
run:
diff --git a/README.md b/README.md
index 909ee5f..7354bf4 100644
--- a/README.md
+++ b/README.md
@@ -4,8 +4,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
-- Документация для разработки: [docs/README.md](docs/README.md)
-- План: [docs/roadmap.md](docs/roadmap.md)
+- Документация: [docs/README.md](docs/README.md)
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
## Структура
@@ -67,4 +66,4 @@ pnpm generate && (cd services/api && go generate ./...)
Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: Gitea Release с заметками → подписанный APK докладывается в него.
Docker-образы не публикуются: сервер клонирует репозиторий и собирает их сам (`deploy/update.sh`,
-[docs/install-guide.md](docs/install-guide.md)). Нужные секреты — в шапке workflow и в [docs/ci-cd.md](docs/ci-cd.md).
+[docs/install-guide.md](docs/install-guide.md)). Нужные секреты перечислены в шапке `.gitea/workflows/release.yml`.
diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx
index c226d62..cf3fef0 100644
--- a/apps/web/src/App.test.tsx
+++ b/apps/web/src/App.test.tsx
@@ -315,6 +315,15 @@ describe('my wave', () => {
});
});
+describe('suggest tracks', () => {
+ it('warns that FLAC and WAV are the formats to send', async () => {
+ const admin = makeUser('admin', 'admin');
+ await renderApp([admin], '/uploads/new', { signedInAs: admin });
+ expect(await screen.findByText(/Нужны FLAC или WAV/)).toBeInTheDocument();
+ expect(screen.getByText(/MP3 — только в крайнем случае/)).toBeInTheDocument();
+ });
+});
+
describe('accessibility', () => {
it('home and login have no serious axe violations', async () => {
const alice = makeUser('alice', 'user');
diff --git a/apps/web/src/features/uploads/files.test.ts b/apps/web/src/features/uploads/files.test.ts
new file mode 100644
index 0000000..4a2e828
--- /dev/null
+++ b/apps/web/src/features/uploads/files.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from 'vitest';
+
+import { needsFormatAttention } from './files';
+
+describe('needsFormatAttention', () => {
+ it('marks everything except flac and wav', () => {
+ expect(needsFormatAttention('flac', 'a.mp3')).toBe(false);
+ expect(needsFormatAttention('WAV', 'track.wav')).toBe(false);
+ expect(needsFormatAttention(null, 'Кино/01.wav')).toBe(false);
+ expect(needsFormatAttention('mp3', 'a.flac')).toBe(true);
+ expect(needsFormatAttention(null, 'folder/song.MP3')).toBe(true);
+ expect(needsFormatAttention(null, 'song.m4a')).toBe(true);
+ expect(needsFormatAttention(null, 'noext')).toBe(false);
+ });
+});
diff --git a/apps/web/src/features/uploads/files.ts b/apps/web/src/features/uploads/files.ts
index 911100d..7e89ceb 100644
--- a/apps/web/src/features/uploads/files.ts
+++ b/apps/web/src/features/uploads/files.ts
@@ -8,6 +8,22 @@ export interface PickedFile {
/** Форматы, которые принимает очередь (сервер всё равно проверяет содержимое через ffprobe). */
export const AUDIO_EXTENSIONS = ['flac', 'mp3', 'm4a', 'ogg', 'opus', 'wav', 'aiff', 'aif', 'wv'];
+/** То, что просим присылать. MP3 и прочие — крайний случай, их помечает очередь. */
+const PREFERRED_AUDIO = new Set(['flac', 'wav']);
+
+export function audioExt(name: string): string {
+ const base = name.split(/[/\\]/).pop() ?? name;
+ const dot = base.lastIndexOf('.');
+ if (dot < 0 || dot === base.length - 1) return '';
+ return base.slice(dot + 1).toLowerCase();
+}
+
+/** Не FLAC и не WAV: в очереди на добавление такой файл нужно выделить. */
+export function needsFormatAttention(format: string | null | undefined, name: string): boolean {
+ const ext = (format || audioExt(name)).toLowerCase();
+ return ext !== '' && !PREFERRED_AUDIO.has(ext);
+}
+
export function isAudio(name: string): boolean {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
return AUDIO_EXTENSIONS.includes(ext);
diff --git a/apps/web/src/pages/admin/UploadQueuePage.tsx b/apps/web/src/pages/admin/UploadQueuePage.tsx
index 06fc69c..1b5208f 100644
--- a/apps/web/src/pages/admin/UploadQueuePage.tsx
+++ b/apps/web/src/pages/admin/UploadQueuePage.tsx
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
+import { needsFormatAttention } from '../../features/uploads/files';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
@@ -309,6 +310,11 @@ function QueueRow({
+ {needsFormatAttention(f.format, f.original_name) && (
+
+ {t('uploads.flag_attention')}
+
+ )}
{f.possible_duplicate &&
{t('uploads.flag_possible')} }
{f.quality_upgrade &&
{t('uploads.flag_quality')} }
{f.matched_album_id ? (
diff --git a/apps/web/src/pages/home.module.css b/apps/web/src/pages/home.module.css
index 7eba488..f6de17e 100644
--- a/apps/web/src/pages/home.module.css
+++ b/apps/web/src/pages/home.module.css
@@ -10,6 +10,17 @@
user-select: none; /* главная — только элементы управления волной */
}
+/* Плеер на всю ширину окна, а главная — в колонке справа от сайдбара.
+ Сдвиг на половину сайдбара ставит волну на ту же ось, что и кнопка play.
+ Если чипам не хватает места, сдвиг меньше, чтобы они не уехали под меню. */
+@media (min-width: 768px) {
+ .home {
+ transform: translateX(
+ calc(-1 * min(var(--side-w, 240px) / 2, max(0px, (100% - 760px) / 2)))
+ );
+ }
+}
+
.orbLabel {
margin-top: 4px;
font-family: var(--font-display);
diff --git a/apps/web/src/pages/uploads/SuggestPage.tsx b/apps/web/src/pages/uploads/SuggestPage.tsx
index b3d982c..af7d5ac 100644
--- a/apps/web/src/pages/uploads/SuggestPage.tsx
+++ b/apps/web/src/pages/uploads/SuggestPage.tsx
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState, useSyncExternalStore, type DragEvent } fro
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
-import { AUDIO_EXTENSIONS, fromDrop, fromInput } from '../../features/uploads/files';
+import { AUDIO_EXTENSIONS, fromDrop, fromInput, needsFormatAttention } from '../../features/uploads/files';
import { uploaderFor, type UploadItem } from '../../features/uploads/uploader';
import { useErrorText } from '../../shared/lib/errors';
import { formatBytes } from '../../shared/lib/format';
@@ -81,7 +81,8 @@ export function SuggestPage() {
>
{t('uploads.drop_title')}
-
{t('uploads.drop_hint', { formats: AUDIO_EXTENSIONS.join(', ') })}
+
{t('uploads.drop_hint')}
+
{t('uploads.drop_folders')}
filesRef.current?.click()}>
{t('uploads.pick_files')}
@@ -224,6 +225,7 @@ function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean
)}
+ {needsFormatAttention(null, item.relPath) &&
{t('uploads.format_attention')} }
{note &&
{note.text} }
{formatBytes(item.size, i18n.language)}
diff --git a/apps/web/src/pages/uploads/uploads.module.css b/apps/web/src/pages/uploads/uploads.module.css
index 373088f..cceef70 100644
--- a/apps/web/src/pages/uploads/uploads.module.css
+++ b/apps/web/src/pages/uploads/uploads.module.css
@@ -25,6 +25,15 @@
font-weight: 600;
}
+.formatWarn {
+ max-width: 640px;
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1.45;
+ color: var(--warn);
+}
+
.dropActions {
display: flex;
flex-wrap: wrap;
diff --git a/apps/web/src/widgets/AppShell.module.css b/apps/web/src/widgets/AppShell.module.css
index 5154108..ba32f07 100644
--- a/apps/web/src/widgets/AppShell.module.css
+++ b/apps/web/src/widgets/AppShell.module.css
@@ -1,7 +1,8 @@
.shell {
+ --side-w: 240px;
position: relative;
display: grid;
- grid-template-columns: 240px 1fr;
+ grid-template-columns: var(--side-w) 1fr;
grid-template-rows: 60px 1fr 88px;
grid-template-areas:
'top top'
@@ -234,7 +235,7 @@
@media (max-width: 1023px) {
.shell {
- grid-template-columns: 72px 1fr;
+ --side-w: 72px;
}
.navItem {
@@ -269,6 +270,7 @@
@media (max-width: 767px) {
.shell {
+ --side-w: 0px;
grid-template-columns: 1fr;
grid-template-rows: 60px 1fr 72px 64px;
grid-template-areas:
diff --git a/apps/web/src/widgets/SearchBox.tsx b/apps/web/src/widgets/SearchBox.tsx
index a9bfa9b..7d01e2f 100644
--- a/apps/web/src/widgets/SearchBox.tsx
+++ b/apps/web/src/widgets/SearchBox.tsx
@@ -29,7 +29,7 @@ type Item = {
*/
export function SearchBox({
align = 'center',
- collapsible = true,
+ collapsible = false,
}: {
align?: 'left' | 'center' | 'right';
collapsible?: boolean;
diff --git a/docs/IDEA.md b/docs/IDEA.md
index d7d5790..5396595 100644
--- a/docs/IDEA.md
+++ b/docs/IDEA.md
@@ -1,7 +1,6 @@
# Pipu Music — исходная идея продукта
-> Документ описывает **что** должно быть в приложении с точки зрения пользователя.
-> Используется как входные данные для создания дизайна. Технические решения — в [README документации](README.md).
+> Как продукт выглядит для пользователя. Устройство системы — в [README документации](README.md).
## Суть
@@ -165,5 +164,3 @@
- Музыка хранится на сервере, подключение к хранилищу по NFS; хранилища подключает админ из админки.
- Одобренные новые треки приложение само записывает на файловый сервер, соблюдая принятую там структуру папок.
-- Один репозиторий: веб, мобильное приложение, бэкенд, тесты, CI/CD.
-- CI/CD на Gitea: по тегу собирается релиз.
diff --git a/docs/README.md b/docs/README.md
index 68d61bb..27ed382 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -6,10 +6,9 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| Документ | О чём |
|---|---|
-| [IDEA.md](IDEA.md) | Исходная идея продукта глазами пользователя (вход для дизайна) |
-| [design.md](design.md) | Дизайн-система (токены, шрифты, анимация), кнопка волны, решения по вариантам прототипа, ссылки на макеты |
+| [IDEA.md](IDEA.md) | Продукт глазами пользователя |
+| [design.md](design.md) | Дизайн-система: токены, шрифты, анимация, кнопка волны, каркас экранов |
| [architecture.md](architecture.md) | Общая архитектура, компоненты, потоки данных, структура репозитория |
-| [decisions.md](decisions.md) | ADR — принятые архитектурные решения и их обоснование |
| [data-model.md](data-model.md) | Схема БД PostgreSQL |
| [api.md](api.md) | REST API: соглашения, эндпоинты, ошибки |
| [auth-and-roles.md](auth-and-roles.md) | Аутентификация, токены, роли и права, первый запуск |
@@ -20,17 +19,13 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [web.md](web.md) | Веб-клиент |
| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление |
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
-| [testing.md](testing.md) | Стратегия тестирования |
-| [ci-cd.md](ci-cd.md) | Gitea Actions: проверки и релиз по тегу |
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
| [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, диагностика |
| [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные |
-| [conventions.md](conventions.md) | Соглашения по коду, git, версиям |
-| [roadmap.md](roadmap.md) | Этапы разработки и критерии готовности |
## Стек в одном абзаце
Backend API — **Go**; рекомендации и аудиоанализ — **Python**; БД — **PostgreSQL + pgvector**;
поиск — **Meilisearch**; кэш / очереди событий — **Redis**; раздача аудио — **nginx (X-Accel-Redirect)**;
веб — **React + TypeScript + Vite**; мобильное — **React Native (Expo, Android)**;
-монорепо — **pnpm + Turborepo**; CI/CD — **Gitea Actions**; деплой — **Docker Compose**.
+деплой — **Docker Compose**.
diff --git a/docs/admin-guide.md b/docs/admin-guide.md
index 2502ad3..4a5e5b9 100644
--- a/docs/admin-guide.md
+++ b/docs/admin-guide.md
@@ -114,7 +114,6 @@ Android-приложение обновляется само: при запус
| Логи | `docker compose logs -f --tail=100 api` (или `recommender`, `analyzer`, `storage-agent`) |
| Место на диске | `df -h /srv/pipu`; больше всего занимает `cache/transcode` (чистится сам) |
| Версия | `grep PIPU_VERSION .env`, `GET /api/v1/server-info`; Android — внизу вкладки «Профиль» |
-| Нагрузка | `tests/load/smoke.js` — см. [testing.md](testing.md#нагрузочный-smoke) |
## 8. Частые ситуации
diff --git a/docs/api.md b/docs/api.md
index dc3aeb8..bc85e93 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -136,7 +136,7 @@
| GET | `/uploads/quota` | moderator, admin | `{limit_bytes, used_bytes, per_user_limit_bytes, user_used_bytes, max_file_bytes}` |
| POST | `/uploads/precheck` | moderator, admin | `{files:[{client_id, size, sha256, tags:{artist,title,album,duration_ms}}]}` → для каждого `accepted` / `duplicate` (+ `track`) / `too_large`; ничего не резервирует |
| POST | `/uploads` | moderator, admin | Создать пачку `{comment, files:[{client_id, name, rel_path, size, sha256}]}` → резерв квоты; `{batch_id, files:[{client_id, upload_file_id, tus_url} | {client_id, rejected: code}]}`; при нехватке места — `uploads.queue_full` для не поместившихся |
-| tus | `/uploads/files/{upload_file_id}` | автор | Resumable-загрузка (`HEAD`/`PATCH`, ядро tus 1.0.0 — ADR-015) |
+| tus | `/uploads/files/{upload_file_id}` | автор | Докачка (`HEAD`/`PATCH`, ядро tus 1.0.0) |
| GET | `/uploads/mine?status=&cursor=` | moderator, admin | Мои предложения (`status` — через запятую) |
| DELETE | `/uploads/files/{id}` | автор | Отозвать (`withdrawn`), только до решения |
| GET | `/admin/uploads?status=&author=&possible_duplicate=&cursor=` | admin | Очередь, сгруппированная по пачкам |
diff --git a/docs/architecture.md b/docs/architecture.md
index 6494b99..53205fb 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -101,7 +101,7 @@ pipu-music/
│ └── recommender/ # Python (uv)
│ ├── pipu_rec/
│ │ ├── api/ # FastAPI
-│ │ ├── analysis/ # Essentia
+│ │ ├── analysis/ # признаки и эмбеддинги (numpy)
│ │ ├── candidates/ ranking/ wave/ training/
│ │ └── storage/ # доступ к Postgres / Redis
│ └── tests/
@@ -111,10 +111,6 @@ pipu-music/
│ ├── compose.dev.yaml
│ ├── nginx/
│ └── .env.example
-├── tests/
-│ ├── e2e/ # Playwright
-│ └── fixtures/music/ # генерируемые тестовые аудиофайлы
-├── .gitea/workflows/ # ci-*.yml, docker.yml (по областям), release.yml
├── docs/
├── turbo.json pnpm-workspace.yaml package.json
└── README.md
@@ -127,7 +123,7 @@ pipu-music/
не лезут в чужие таблицы напрямую.
- **Contract-first**: `api/openapi.yaml` — источник истины. Go-сервер генерирует типы и интерфейс
хендлеров (`oapi-codegen`, strict server), TS-клиент генерируется в `packages/api-client`.
- CI падает, если сгенерированный код не совпадает с контрактом.
+ Сгенерированный код совпадает с контрактом.
- **Внутренний API** `api ↔ recommender` и `api ↔ storage-agent` — HTTP/JSON во внутренней docker-сети,
описан отдельными `internal-*.yaml` спеками.
- **Идемпотентность**: события и мутирующие запросы клиентов принимают `Idempotency-Key` / `event_id` (UUIDv7),
diff --git a/docs/ci-cd.md b/docs/ci-cd.md
deleted file mode 100644
index b96c6ae..0000000
--- a/docs/ci-cd.md
+++ /dev/null
@@ -1,138 +0,0 @@
-# CI/CD (Gitea Actions)
-
-Workflows лежат в `.gitea/workflows/`. Синтаксис совместим с GitHub Actions; runner — существующий `act_runner`
-с доступом к Docker.
-
-Сборке APK нужно ~15 ГБ свободного места на хосте раннера (Android SDK, NDK ~2.5 ГБ, кэш Gradle,
-C++-сборка React Native). APK собирается только под `arm64-v8a`. Если job падает с `No space left on device`,
-освободите место на хосте раннера: `docker system prune -af` и старые кэши act в `~/.cache/act`.
-
-## Версионирование
-
-- SemVer, тег `vMAJOR.MINOR.PATCH` (предрелизы: `v1.2.0-rc.1` — помечаются как pre-release, мобильное их
- предлагает только при включённой настройке «бета-версии»).
-- Одна версия на весь продукт: образы, APK и веб собираются с одним номером.
-- Android `versionCode = MAJOR·1 000 000 + MINOR·10 000 + PATCH·100 + (99 для релиза | N для -rc.N)`,
- так что `1.2.0-rc.1 < 1.2.0-rc.2 < 1.2.0 < 1.2.1`; MINOR и PATCH ≤ 99. Считает `scripts/version.mjs`
- (тесты — `pnpm test:scripts`).
-- Changelog — `git-cliff` из Conventional Commits.
-
-## CI — на каждый push в `main` и PR
-
-Проверки разбиты на workflow по областям, у каждого фильтр путей (`on.push.paths`): запускается только то,
-что затронуто коммитом. Коммиты только в `docs/` и `*.md` CI не запускают вовсе.
-
-| Workflow | Job | Что делает | Когда |
-|---|---|---|---|
-| `ci-api.yml` | `api` | gofmt, go vet, `go test -race`, сборка | `services/api/**`, `api/openapi.yaml` |
-| `ci-contract.yml` | `contract` | регенерация Go-сервера, TS-клиента и токенов; всё ли закоммичено | `openapi.yaml`, `gen/`, `packages/api-client`, `packages/design-tokens`, общие файлы JS |
-| `ci-recommender.yml` | `recommender` | ruff, mypy, pytest | `services/recommender/**` |
-| `ci-ts.yml` | `ts` | i18n, тесты скриптов, typecheck (вкл. mobile), vitest, сборка веба | `apps/**`, `packages/**`, `scripts/**`, общие файлы JS |
-| `ci-mobile.yml` | `mobile` | `expo-doctor`, Metro-бандл Android | `apps/mobile/**`, `packages/**`, общие файлы JS |
-| `docker.yml` | `docker` | сборка всех 4 образов без push | `Dockerfile`, `.dockerignore`, `deploy/**`, манифесты зависимостей |
-
-Общие файлы JS: `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json`, `tsconfig.base.json`.
-Изменение самого workflow запускает его. У каждого workflow своя группа `concurrency`: новый push в ту же
-ветку отменяет устаревший прогон.
-
-`docker` собирает образы обычным `docker build` через Docker хоста раннера, а не в buildx-контейнере:
-кэш слоёв переживает job, и повторная сборка занимает секунды. Кэш растёт — на хосте раннера раз в сутки:
-`docker builder prune -af --filter until=72h` (см. [настройку раннера](#настройка-раннера)).
-
-### Кэши
-
-| Что | Где кэшируется | Чем |
-|---|---|---|
-| pnpm store | все job с `pnpm install` | `setup-node` `cache: pnpm` (кэш actions раннера) |
-| Go-модули и build cache | `api`, `contract` | `setup-go` |
-| uv | `recommender` | `setup-uv` `enable-cache` |
-| Результаты turbo (typecheck, test, build) | `ts` | `actions/cache` для `.turbo/cache` |
-| `npx expo-doctor`, трансформации Metro | `mobile` | `actions/cache` для `~/.npm/_npx` и `/tmp/metro-cache` |
-| Слои Docker и `--mount=type=cache` | `docker`, сборка на сервере | Docker хоста; манифесты копируются раньше кода |
-| Gradle (дистрибутив, зависимости, build cache), Android SDK и NDK | `android` в `release.yml` | тома `/opt/ci-cache/*` |
-| Node, Go, Java, скачанные setup-action'ами | все job | том `act-toolcache` раннера |
-
-Кэш actions работает, только если он включён на раннере ([настройка](#настройка-раннера)); без него в логах
-`Cache action is only supported on GHES…`, и всё скачивается заново.
-
-## `release.yml` — по тегу
-
-```yaml
-on:
- push:
- tags: ['v*.*.*']
-
-jobs:
- release: # версия из тега (scripts/version.mjs) и сразу Gitea Release (scripts/release.mjs create)
- android: # needs: release — собирает APK и докладывает его в релиз
-```
-
-Тесты в релизе не повторяются: тег ставится на коммит, который CI уже проверил в `main`. **Ставьте тег
-после зелёного CI** — релиз сам этого не проверяет.
-
-Релиз появляется в первую же минуту, не дожидаясь APK. Текст строит `scripts/release.mjs`:
-
-- **статус Android** вверху: «⏳ собирается» со ссылкой на ход сборки → «✅ собран» (имя файла, размер,
- versionCode, SHA-256; предупреждение, если подпись отладочная) или «❌ не удалась» со ссылкой на лог;
-- **изменения** — коммиты (Conventional Commits) с прошлого тега по разделам «Новое», «Исправления»,
- «Сборка и CI» и т. д., отдельно — несовместимые (`feat!:`); для финального релиза отсчёт от прошлого
- финального, а не от последнего `-rc`; ссылка на полный diff;
-- **обновление сервера** — `./update.sh vX.Y.Z`, проверка версии; **установка APK** и проверка SHA-256.
-
-Job `android`: Android SDK, `expo prebuild`, `./gradlew assembleRelease --build-cache` (только arm64-v8a), подпись `apksigner`,
-затем `release.mjs android success
` прикладывает APK и `.sha256` и меняет статус. При любом сбое —
-`release.mjs android failure`. Перезапуск job безопасен: статус снова «собирается», файлы заменяются.
-Перезапуск всего workflow обновляет текст существующего релиза, а не создаёт второй.
-
-Docker-образы **не публикуются**: сервер собирает их сам из клонированного репозитория (ADR-013).
-Чтобы сборка на сервере не ломалась, job `docker` в `ci.yml` собирает все образы без push на каждый PR.
-
-## Настройка раннера
-
-Раннер — `act_runner` на отдельной VM. Что ускоряет CI/CD (`config.yaml` раннера, затем перезапуск сервиса):
-
-```yaml
-runner:
- capacity: 2 # два job параллельно; только если у VM ≥ 8 ГБ RAM (иначе APK-сборка упирается в OOM)
-cache:
- enabled: true # кэш actions/cache: pnpm store, Go-модули, uv
- host: 10.10.1.x # IP хоста раннера, доступный из job-контейнеров (не 127.0.0.1)
- port: 8088 # фиксированный порт; открыть его для docker-сетей
-container:
- valid_volumes:
- - /opt/ci-cache/** # постоянные кэши Gradle и Android SDK для job android в release.yml
-```
-
-Сборка APK ограничена сама (`release.yml`: контейнер `--cpus=3 --memory=7g` без свопа, Gradle `-Xmx2g`, Metro `--max-old-space-size=1536`, таймаут 60 мин, CMake / ninja / Gradle
-по 2 потока, `nice` / `ionice`). Если раннер — VM в гипервизоре (Proxmox), дополнительно ограничьте её там:
-фиксированная RAM без ballooning, сумма RAM всех VM меньше RAM хоста, `cpulimit` ≤ половины ядер хоста,
-лимит дискового I/O — иначе тяжёлая сборка может подвесить весь хост.
-
-Без `valid_volumes` раннер пропускает тома из `release.yml`, и каждый релиз заново качает Gradle
-и NDK (~4 мин). Признак выключенного кэша actions в логах — `Cache action is only supported on GHES…`.
-
-Обслуживание хоста раннера (cron, раз в сутки):
-
-```
-0 4 * * * docker image prune -af --filter "until=72h"; docker builder prune -af --filter "until=72h"
-```
-
-Кэши в `/opt/ci-cache` занимают ~5–6 ГБ (Gradle, SDK, NDK); если место кончается — их можно удалить целиком,
-следующий релиз скачает всё заново.
-
-## Секреты
-
-| Секрет | Назначение |
-|---|---|
-| `RELEASE_TOKEN` | Необязательно. Создание и редактирование релиза, загрузка ассетов (scope `write:repository`). Без него используется встроенный токен job (`GITHUB_TOKEN`), у которого есть права на запись в этот репозиторий |
-| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. Без них финальный релиз падает; `-rc` собирается с отладочной подписью. |
-
-Переменная репозитория `PUBLIC_URL` (Settings → Actions → Variables) — внешний адрес Gitea для ссылок
-в тексте релиза, по умолчанию `https://gitea.hsrv.site`. Нужна потому, что job обращается к API по внутреннему
-адресу, и ссылки из ответа API ведут туда же.
-
-## Деплой
-
-Автодеплой не обязателен. На сервере в клоне репозитория: `deploy/update.sh [vX.Y.Z]` — checkout тега,
-`docker compose build` и `up -d`; миграции применяются при старте `api` ([install-guide.md](install-guide.md)).
-Опционально — job `deploy` в `release.yml` по SSH на сервер для не-pre-release тегов (с ручным approval через environment).
diff --git a/docs/conventions.md b/docs/conventions.md
deleted file mode 100644
index c191d47..0000000
--- a/docs/conventions.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# Соглашения
-
-## Git
-
-- Trunk-based: короткие ветки от `main`, merge через PR (squash).
-- Имена веток: `feat/wave-moods`, `fix/scan-rename`, `chore/ci-cache`.
-- Conventional Commits: `feat(wave): add running profile`, `fix(api): ...`. Скоупы: `api`, `agent`, `rec`, `web`,
- `mobile`, `core`, `i18n`, `ci`, `deploy`, `docs`. Breaking change — `!` и раздел `BREAKING CHANGE:`.
-- Изменение API = изменение `openapi.yaml` + регенерация клиентов в том же PR.
-- Существенные архитектурные решения — новый ADR в [decisions.md](decisions.md).
-
-## Go (`services/api`)
-
-- `gofumpt`, `golangci-lint` (конфиг в репо). Ошибки оборачиваются `fmt.Errorf("...: %w", err)`;
- доменные ошибки — типизированные (`ErrForbidden`, `ErrNotFound`) и маппятся в problem+json в одном месте.
-- Контекст первым аргументом, без глобального состояния, зависимости через конструкторы (`NewService(repo, clock, ...)`).
-- `clock` и генератор ID — интерфейсы (детерминированные тесты).
-- SQL — только в `queries/*.sql` (sqlc), без ORM.
-
-## Python (`services/recommender`)
-
-- `uv`, `ruff` (lint + format), `mypy --strict`, Pydantic v2 для схем.
-- Тяжёлые вычисления — `numpy`, без циклов Python по трекам.
-- Конфиги контекстов и весов — YAML, валидируются Pydantic при старте.
-
-## TypeScript
-
-- `strict`, ESLint (flat config) + Prettier, без `any` (кроме сгенерированного кода).
-- Общая логика — в `packages/core`, платформенная — только адаптеры в `apps/*`.
-- Все строки UI — через i18n, хардкод текста ловит `eslint-plugin-i18next`.
-
-## API
-
-- `snake_case` в JSON, курсорная пагинация, problem+json с `code`.
-- Новые поля — только дополнительные; удаление / переименование — через новую версию эндпоинта или депрекацию на один минорный релиз.
-
-## Безопасность
-
-- Секреты только через env / docker secrets, никогда в репозитории.
-- Любой ввод, попадающий в команды (`mount`, `ffmpeg`), — только как отдельные аргументы `exec.Command`, без shell,
- с whitelist-валидацией.
-- Пути файлов — проверка, что итоговый путь внутри корня источника (`filepath.Rel` без `..`).
diff --git a/docs/decisions.md b/docs/decisions.md
deleted file mode 100644
index e81ae6b..0000000
--- a/docs/decisions.md
+++ /dev/null
@@ -1,205 +0,0 @@
-# Архитектурные решения (ADR)
-
-Формат: контекст → решение → последствия. Новое решение — новый раздел с номером; старые не переписываются,
-а помечаются `Заменено ADR-NNN`.
-
----
-
-## ADR-001. Монорепозиторий
-
-**Контекст.** Веб, мобильное, бэкенд, тесты и CI должны жить в одном репозитории; у веба и мобильного много общего
-(API-клиент, логика очереди и событий, словари i18n).
-
-**Решение.** Монорепо: pnpm workspaces + Turborepo для TS-части; Go-модуль в `services/api`; Python-проект
-(uv) в `services/recommender`. Единый `api/openapi.yaml`.
-
-**Последствия.** Атомарные изменения контракта и клиентов в одном PR. CI использует фильтры путей,
-чтобы не гонять всё на каждый коммит.
-
----
-
-## ADR-002. Backend API на Go
-
-**Контекст.** Нагрузка — IO (стриминг, NFS, БД), десятки одновременных слушателей; важна простота деплоя и
-низкое потребление памяти.
-
-**Решение.** Go 1.23+: роутер `chi`, `oapi-codegen` (strict server), `pgx` + `sqlc`, миграции `goose`,
-фоновые задачи `River` (очередь в Postgres), логирование `log/slog`, конфиг через env (`caarlos0/env`).
-
-**Последствия.** Один статический бинарник на сервис, быстрые холодные старты, тайпсейф SQL.
-ML-код в Go не пишем — для этого отдельный сервис (ADR-003).
-
----
-
-## ADR-003. Рекомендации и аудиоанализ — отдельный Python-сервис
-
-**Контекст.** Нужны: аудиоанализ (Essentia и её предобученные модели), коллаборативная фильтрация (implicit ALS),
-learning-to-rank (LightGBM). Экосистема есть только в Python.
-
-**Решение.** `services/recommender`: FastAPI (онлайн-инференс), отдельный процесс `analyzer` (тот же образ),
-обучение по расписанию (APScheduler внутри сервиса). Python 3.12, `uv`, `numpy`, `implicit`, `lightgbm`,
-`essentia-tensorflow`, `psycopg`, `redis`.
-
-**Последствия.** Два языка на бэкенде. Граница чёткая: Python не обслуживает пользователей напрямую,
-только внутренние вызовы `api`. При падении сервиса работает fallback в `api`.
-
-⚠️ Предобученные модели Essentia распространяются под CC BY-NC-SA 4.0 — допустимо для частного
-некоммерческого self-hosted использования. При коммерческом использовании нужно заменить модели.
-
----
-
-## ADR-004. Мобильное приложение — React Native (Expo), только Android
-
-**Контекст.** Только Android, самообновление APK из релизов, фоновое воспроизведение, офлайн, анимированная кнопка волны.
-Рассматривались Flutter и React Native.
-
-**Решение.** React Native + Expo SDK (prebuild / dev client, не Expo Go), `expo-router`,
-`react-native-track-player` (ExoPlayer под капотом, MediaSession, уведомление, кэш), `@shopify/react-native-skia`
-+ `react-native-reanimated` для волны, `expo-sqlite` для офлайн-данных, `expo-file-system` для загрузок.
-
-**Почему не Flutter.** Flutter не хуже по возможностям, но React Native даёт общий TypeScript-код с вебом:
-API-клиент, типы, логику очереди / событий / офлайн-синхронизации, словари i18n, дизайн-токены.
-Для команды из одного-двух человек это существенно сокращает объём работы.
-
-**Последствия.** iOS не поддерживается (самообновление в обход стора там невозможно); при необходимости
-iOS добавляется позже через TestFlight без смены стека.
-
----
-
-## ADR-005. Раздача аудио через nginx X-Accel-Redirect
-
-**Контекст.** Нужна перемотка (HTTP Range), высокая пропускная способность, минимум нагрузки на бэкенд.
-
-**Решение.** Клиент обращается к `api`, `api` проверяет права и подпись ссылки и отвечает заголовком
-`X-Accel-Redirect` на `internal` location nginx. Файл отдаёт nginx.
-
-**Последствия.** Go не держит долгих соединений со стримами. Тома библиотеки и кэша транскодов
-должны быть смонтированы и в `api`, и в `nginx` по одинаковым путям.
-
----
-
-## ADR-006. Подписанные ссылки на стрим
-
-**Контекст.** `` в браузере и ExoPlayer не всегда умеют передавать заголовок `Authorization`.
-
-**Решение.** В DTO трека отдаётся `stream_url` с параметрами `exp` и `sig` = HMAC-SHA256(user_id, track_id, quality, exp).
-Срок жизни — 12 ч. Для офлайн-загрузки — отдельная ссылка со сроком 1 ч.
-
-**Последствия.** Ссылки нельзя использовать после блокировки пользователя дольше срока жизни — при блокировке
-дополнительно проверяется статус пользователя в `api` (кэш в Redis, 60 с).
-
----
-
-## ADR-007. Поиск — Meilisearch
-
-**Контекст.** Мгновенный автокомплит по трём типам сущностей, опечатки, русский и английский, 11k–100k документов.
-
-**Решение.** Meilisearch, три индекса (`artists`, `albums`, `tracks`), `multi-search` для выпадающего списка.
-Postgres остаётся источником истины, индексы перестраиваемы.
-
-**Альтернатива.** `pg_trgm` в Postgres — меньше компонентов, но хуже автокомплит и ранжирование. Оставлено как fallback.
-
----
-
-## ADR-008. Подключение NFS из админки через storage-agent
-
-**Контекст.** Админ должен подключать хранилища из UI. Монтирование требует `CAP_SYS_ADMIN`,
-давать его публичному `api` небезопасно.
-
-**Решение.** Отдельный минимальный сервис `storage-agent` с `CAP_SYS_ADMIN`, без публичных портов.
-Монтирует в `/library/` внутри общего bind-тома с `propagation: rshared`;
-`api`, `nginx`, `analyzer` видят точки монтирования через `rslave`. Поддерживаются типы
-`nfs` (монтирует агент) и `local` (папка, уже смонтированная на хосте).
-
-**Последствия.** Требуется Linux-хост (mount propagation) и `nfs-common` в образе агента.
-При рестарте агент перемонтирует все активные источники из БД. Дополнено ADR-012 (запись).
-
----
-
-## ADR-009. Хранение векторов — pgvector
-
-**Контекст.** Эмбеддинги треков (~11k–100k × 256 float) и векторы вкусов (~100). Нужен kNN.
-
-**Решение.** pgvector, HNSW-индекс, косинусное расстояние. Отдельная векторная БД не нужна при таком объёме.
-
----
-
-## ADR-010. Аутентификация — свои JWT + ротируемые refresh-токены
-
-**Решение.** Пароли — argon2id. Access JWT (EdDSA, 15 мин), refresh — непрозрачный токен (хэш в БД), 30 дней,
-ротация при каждом использовании с детекцией повторного использования (отзыв всей «семьи»).
-Веб хранит refresh в `HttpOnly; Secure; SameSite=Strict` cookie, мобильное — в `expo-secure-store` (Android Keystore).
-Подробно — [auth-and-roles.md](auth-and-roles.md).
-
----
-
-## ADR-011. Ошибки API — RFC 9457 + коды для i18n
-
-**Решение.** Ответы об ошибках — `application/problem+json` с машинным полем `code` (`auth.invalid_credentials`,
-`validation.password_too_short`, …). Клиенты переводят `code` через словари; сервер не возвращает локализованных текстов.
-
----
-
-## ADR-012. Очередь на добавление треков и запись в хранилище
-
-**Контекст.** Модераторы предлагают новые треки; очередь ограничена по размеру (настраивает админ); дубли отсекаются;
-одобренные файлы должны лечь на файловый сервер в принятой там структуре папок. До этого библиотека была строго read-only.
-
-**Решение.**
-- Загрузка по протоколу **tus** (resumable) в локальный staging, квота — счётчик в Postgres, резервируется до загрузки.
-- Дубли — три уровня: SHA-256 (предпроверка на клиенте), нормализованные метаданные, акустический отпечаток **Chromaprint**.
-- Запись в хранилище — **только `storage-agent`**, только в источники с `accepts_uploads`, через отдельную `rw`-точку
- монтирования, невидимую другим контейнерам; только создание новых файлов (tmp → fsync → hash → rename).
-- Путь — по `write_template` хранилища; шаблон автоподбирается по существующей структуре и проверяется на обратимость
- регэкспами хранилища.
-
-**Последствия.** Публичный `api` по-прежнему не может изменить библиотеку. Нужны права на запись в NFS-экспорт
-для UID агента. Подробно — [uploads.md](uploads.md).
-
----
-
-## ADR-013. Образы собираются на сервере, реестр не используется
-
-**Контекст.** Публикация образов в Gitea Container Registry требует настройки реестра, токенов на push и pull,
-`docker login` на сервере. Сервер один, и он сам может собрать образы.
-
-**Решение.** На сервере клонируется репозиторий; `deploy/update.sh [vX.Y.Z]` переключается на тег и выполняет
-`docker compose build` + `up -d` (`build:` у сервисов в `deploy/compose.yaml`). Релиз в Gitea содержит только APK.
-CI на каждый PR собирает все образы без push, чтобы сборка на сервере не ломалась.
-
-**Последствия.** Серверу нужен доступ к репозиторию и интернет на время сборки; первая сборка — 5–15 минут,
-нужно ~5–10 ГБ под кэш сборки. Откат — `update.sh` на предыдущий тег (пересборка из кэша). Отпадают секреты
-`REGISTRY_USER` / `REGISTRY_TOKEN`. Заменяет часть ADR-001 / ci-cd о публикации образов.
-
----
-
-## ADR-014. Анализатор v1 — признаки на numpy, Essentia позже
-
-**Контекст.** Нейросетевые модели Essentia (Discogs-EffNet, жанры, настроения) требуют essentia-tensorflow:
-тяжёлый образ, ограниченный набор платформ и версий Python. Волне v1 нужны звучание и базовые признаки уже сейчас.
-
-**Решение.** Анализатор `basic-1` (`services/recommender/pipu_rec/analysis/features.py`) считает признаки сам,
-на numpy поверх декодирования ffmpeg: 3 фрагмента по 30 с; эмбеддинг 256 = форма спектра, разброс, динамика
-и ритмичность по 64 mel-полосам (рекомендатель стандартизирует его по библиотеке); темп по автокорреляции онсетов;
-тональность по хромаграмме; громкость, энергия, танцевальность, акустичность, длина вступления.
-Valence — грубая оценка (лад + яркость); `mood_*`, `arousal`, жанры анализа не заполняются.
-
-**Последствия.** Анализ работает на любом CPU без моделей. Контексты используют энергию, темп, танцевальность
-и valence; настроения «весёлое» / «грустное» пока приблизительны. Переход на Essentia — новая версия
-`analyzer_ver`: воркер сам ставит пересчёт всех треков.
-
----
-
-## ADR-015. Приём загрузок — ядро tus своими силами, без tusd
-
-**Контекст.** ADR-012 предполагал `tusd` как библиотеку. Но файл очереди создаёт `POST /uploads` вместе с резервом
-квоты и проверкой дублей, а права (только автор, только `uploading`) проверяет `api`. От tus остаётся только ядро
-протокола: `HEAD` (сколько принято) и `PATCH` (следующий кусок с `Upload-Offset`).
-
-**Решение.** Ядро tus 1.0.0 — в `internal/uploads/tus.go` (~200 строк): смещение — фактический размер файла в staging,
-один `PATCH` на файл одновременно, данные сверх заявленного размера не принимаются, куски до 64 МБ. Расширения
-creation / termination / checksum не нужны. Клиент — `tus-js-client` с готовым `uploadUrl` из ответа `POST /uploads`.
-
-**Последствия.** Нет новой зависимости и отдельного хранилища состояния tus; квота и права — в одной транзакции
-с созданием файла. Целостность проверяется после приёма: SHA-256 файла сверяется с заявленным клиентом.
-
diff --git a/docs/deployment.md b/docs/deployment.md
index a8eed59..aec33ee 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -101,5 +101,4 @@ cd /opt/pipu-music/deploy && ./update.sh # последний рели
```
Скрипт: `git fetch` → checkout тега → `docker compose build --pull` → `up -d` → удаление старых образов →
ожидание `/readyz`; миграции применяются при старте api.
-Конкретная версия или откат: `./update.sh v1.2.4` (миграции пишутся обратно-совместимыми в пределах минорной версии: сначала добавить,
-потом — в следующем релизе — удалить).
+Конкретная версия или откат кода: `./update.sh v1.2.4`. Откат тега не откатывает уже применённые миграции базы.
diff --git a/docs/design.md b/docs/design.md
index 01152eb..8da36bc 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -1,29 +1,14 @@
# Дизайн и дизайн-система
-Источник — кликабельные прототипы в [`Разработка дизайна приложения/`](Разработка%20дизайна%20приложения/):
-
-| Файл | Что внутри |
-|---|---|
-| `Pipu Web.dc.html` | Веб: вход, setup-экраны, главная, поиск, исполнитель, альбом, коллекция, профиль, вся админка, диалоги, очередь плеера |
-| `Pipu Android.dc.html` | Android: подключение к серверу, вход, главная, поиск, коллекция (со «Скачанным»), альбом, исполнитель, профиль, полноэкранный плеер, листы (качество, пароль, очередь, обновление), офлайн-режим |
-| `Wave Options.dc.html` | Три варианта кнопки «Моя волна» |
-| `wave.js` | Реализация кнопки волны (Canvas 2D, React) — **референс для продакшен-компонента** |
-| `pipu-data.js` | Демо-данные прототипа |
-| `support.js`, `android-frame.jsx` | Рантайм прототипа и рамка устройства — в продукт не переносятся |
-
-Просмотр прототипов: прототипам нужен HTTP-сервер (из `file://` скрипты не работают).
-В репозитории есть конфиг `.claude/launch.json` (`python -m http.server 8765 --directory docs`),
-вручную — та же команда, затем `http://localhost:8765/`.
-
-## 1. Принятые решения по вариантам прототипа
+## 1. Внешний вид
| Развилка | Решение |
|---|---|
-| Кнопка «Моя волна» | **Сфера с шумом (`orb`)** и **Жидкая форма (`liquid`)** — пользователь выбирает в профиле, по умолчанию `orb`. Вариант «Кольца» не реализуем. |
+| Кнопка «Моя волна» | **Сфера с шумом (`orb`)** и **Жидкая форма (`liquid`)** — пользователь выбирает в профиле, по умолчанию `orb` |
| Каркас веба | **Classic**: сайдбар 240 px, топбар 60 px, разделители-линии, без «парящих» панелей |
| Плеер веба | **Оба**: `bar` (полоса 88 px внизу, по умолчанию) и `island` (плавающий остров) — выбор в профиле |
| Главная | **Center**: волна по центру, под ней чипы «Занятие» и «Настроение» |
-| Очередь на добавление (админ) | `grouped` — группировка по альбомам (вариант прототипа по умолчанию) |
+| Очередь на добавление (админ) | Группировка по пачкам (обычно альбом) |
| Полноэкранный плеер Android | `cover` (крупная обложка) по умолчанию, `wave` (визуализация волны) — переключатель в профиле |
| Тема | Тёмная (по умолчанию) и светлая, переключатель в профиле; `system` — следовать ОС |
| Ambient-фон | Включён: размытые дрейфующие пятна, третье пятно окрашено в цвет обложки текущего трека; отключается в профиле и при `prefers-reduced-motion` |
@@ -34,7 +19,6 @@
## 2. Токены
Источник истины — `packages/design-tokens/tokens.json`; из него генерируются CSS-переменные (веб) и TS-объект (React Native).
-Значения взяты из прототипа без изменений.
### Цвета
@@ -91,12 +75,12 @@
| Появление контента | `pipuIn`: opacity + translateY(6px), 200 мс |
| Эквалайзер текущего трека | `pipuEq`: scaleY 0.25 → 1 |
| Ambient-фон | 3 пятна 55–70vmax, blur 30 px, дрейф 16 / 21 / 26 с, `hue-rotate` ±35° за 30 с, прозрачность `amb-op` (0.8 тёмная / 0.45 светлая) |
-| Пружины (волна, нажатия) | Демпфированная пружина как в `wave.js` (`v += (target − x)·0.35; v *= 0.62`) |
+| Пружины (волна, нажатия) | Демпфированная пружина (`v += (target − x)·0.35; v *= 0.62`) |
| `prefers-reduced-motion` | Ambient статичен, волна замедлена ×0.15 без пульса |
## 3. Кнопка «Моя волна»
-Продакшен-компонент — порт `wave.js` на TypeScript, **Canvas 2D** (прототип показал, что этого достаточно; WebGL не нужен).
+Кнопка рисуется на **Canvas 2D** (веб) и Skia (Android). WebGL не используется.
```ts
interface WaveButtonProps {
@@ -113,7 +97,7 @@ interface WaveButtonProps {
}
```
-Поведение (как в прототипе):
+Поведение:
- **Пульс в такт**: фаза растёт со скоростью `bpm/60`, импульс `(1 − phase)^4 · (0.35 + 0.65·energy)`; в покое — медленное «дыхание».
- **Hover**: скорость анимации ×(1 + 1.4·hover), формы смещаются к курсору; **press**: сжатие до 0.93 с отскоком;
клик / Enter / Space — запуск / пауза.
@@ -134,7 +118,7 @@ interface WaveButtonProps {
очередь, громкость. Очередь — выезжающая справа панель; в режиме волны внизу пометка «Волна бесконечна…».
- Диалоги: стеклянные карточки по центру поверх `overlay`. Тосты — внизу над плеером.
-Экраны, которые есть в прототипе, и документы, где описана логика:
+Экраны и документы, где описана логика:
| Экран | Логика |
|---|---|
diff --git a/docs/i18n.md b/docs/i18n.md
index 4aaa903..a07deae 100644
--- a/docs/i18n.md
+++ b/docs/i18n.md
@@ -28,9 +28,4 @@ packages/i18n/
(`"validation.password_too_short": "Пароль должен быть не короче {{min}} символов"`).
Неизвестный код → общий текст «Что-то пошло не так» + код в деталях.
-## Процесс
-
-- CI запускает `pnpm i18n:check` — отсутствующий ключ в любом языке ломает сборку.
-- Новые строки сначала добавляются в `en` и `ru` одновременно в том же PR.
-- В будущем — подключение платформы переводов (Weblate, self-hosted) к папке `locales/`, формат JSON совместим.
-- Тесты UI запускаются минимум на двух локалях (длинные русские строки ловят проблемы вёрстки).
+Ключи есть в каждом языке, лишних нет, плейсхолдеры совпадают — это проверяет `packages/i18n/scripts/check.ts`.
diff --git a/docs/install-guide.md b/docs/install-guide.md
index 23a90f5..eb7e6f7 100644
--- a/docs/install-guide.md
+++ b/docs/install-guide.md
@@ -13,10 +13,6 @@
Готовые Docker-образы не публикуются: сервер клонирует репозиторий и собирает образы сам
(`docker compose build`). Релизы в Gitea содержат только APK для Android.
-> **Что умеет текущая версия.** В `v0.1.0` (этап M0) работают развёртывание, HTTPS и проверка связи
-> клиентов с сервером. Вход, библиотека и плеер появятся в следующих версиях
-> (см. [roadmap.md](roadmap.md)). Шаги, которые зависят от версии, помечены.
-
---
## 1. Что нужно заранее
@@ -192,12 +188,10 @@ LIBRARY_HOST=/mnt/music
./update.sh
```
-Скрипт переключает репозиторий на последний релизный тег (например, `v0.1.1`), собирает образы, запускает
+Скрипт переключает репозиторий на последний релизный тег, собирает образы, запускает
все сервисы и ждёт готовности API (до 2 минут). Первая сборка занимает 5–15 минут, следующие — быстрее
-за счёт кэша Docker. Конкретная версия: `./update.sh v0.1.1`.
+за счёт кэша Docker. Конкретная версия: `./update.sh v1.2.0`.
-> Локальная сборка поддерживается начиная с `v0.1.1`: в теге `v0.1.0` `compose.yaml` ещё ссылается на реестр образов.
-> Пока `v0.1.1` не вышел, запускайте `./update.sh main`.
При первом запуске Caddy сам получит сертификат для `DOMAIN`, обычно за 10–30 секунд.
Проверка с любого компьютера:
@@ -206,7 +200,7 @@ LIBRARY_HOST=/mnt/music
curl -s https://music.example.com/api/v1/server-info
```
-Ожидаемый ответ: `{"name":"pipu-music","version":"0.1.0",...}`. Проверка всех зависимостей:
+Ожидаемый ответ — JSON с полями `name` и `version`. Проверка всех зависимостей:
```bash
curl -s https://music.example.com/readyz
@@ -218,11 +212,9 @@ curl -s https://music.example.com/readyz
## 8. Веб-клиент
-Откройте `https://music.example.com` в браузере.
+Откройте `https://music.example.com` в браузере. На экране входа можно переключить язык RU / EN.
-- **v0.1.0:** карточка «Сервер 0.1.0 на связи» и переключатель языка RU / EN.
-- **С версии 0.2 (M1):** экран входа. Первый вход — логин `admin` и пароль, который сервер один раз
- выводит в лог при первом запуске:
+Первый вход — логин `admin` и пароль, который сервер один раз выводит в лог при первом запуске:
```bash
docker compose logs api | grep -i bootstrap
@@ -231,10 +223,11 @@ curl -s https://music.example.com/readyz
Сразу после входа приложение попросит сменить логин и пароль администратора
([auth-and-roles.md](auth-and-roles.md#первый-запуск-bootstrap)). Чтобы задать пароль заранее, впишите
`BOOTSTRAP_ADMIN_PASSWORD` в `.env` **до** первого запуска.
-- **С версии 0.3 (M2):** Админка → Хранилища → «Добавить хранилище» → тип «Локальная папка» → выберите
- подпапку на диске `/mnt/music` (в списке она видна как `/library-host/...`) → «Проверить» → шаблоны путей
- → «Сохранить и пересканировать» ([library-and-storage.md](library-and-storage.md)).
- Там же можно добавить NFS-хранилище: адрес сервера и путь экспорта.
+
+Музыку подключает администратор: Админка → Хранилища → «Добавить хранилище» → тип «Локальная папка» →
+подпапка на диске `/mnt/music` (в списке она видна как `/library-host/...`) → «Проверить» → шаблоны путей →
+«Сохранить и пересканировать» ([library-and-storage.md](library-and-storage.md)).
+Там же можно добавить NFS-хранилище: адрес сервера и путь экспорта.
---
@@ -246,10 +239,10 @@ curl -s https://music.example.com/readyz
разрешите и вернитесь к установке.
3. Запустите **Pipu Music**, на экране подключения введите адрес сервера: `music.example.com`
(`https://` подставится сам) → «Проверить».
-4. Ожидаемый результат — «Сервер 0.1.0 на связи». Если версия приложения слишком старая для сервера,
- приложение попросит обновиться.
+4. Приложение показывает, что сервер на связи. Если установленная версия слишком старая,
+ оно попросит обновиться.
-Дальше приложение обновляется само: с версии M5 оно проверяет новые релизы через ваш сервер
+Дальше приложение обновляется само: проверяет новые версии через ваш сервер
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
diff --git a/docs/mobile.md b/docs/mobile.md
index 6c7b2c8..4a1883c 100644
--- a/docs/mobile.md
+++ b/docs/mobile.md
@@ -21,16 +21,12 @@
- APK только под `arm64-v8a` (`plugins/withArm64Only.js`): нативные модули собираются под одну ABI, а без фильтра
в APK попадали неполные каталоги `x86`, `x86_64`, `armeabi-v7a` из библиотек React Native — эмулятор x86 и
32-битные устройства выбирали их и падали при старте (`couldn't find DSO to load: libexpo-modules-core.so`).
- Эмулятор для проверки — образ arm64 или настоящее устройство.
- Все `.so` выровнены по 16 КБ: устройства с Android 15+ и 16 КБ страницами памяти не загружают библиотеки
- с выравниванием 4 КБ, приложение закрывается сразу после запуска. Skia 2.0.0-next.4 собиралась без
- `ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES` — флаг добавлен pnpm-патчем (`patches/`).
-- `scripts/check-apk.mjs` в релизе проверяет ABI, наличие ключевых библиотек и выравнивание; без этого релиз
- не публикуется.
+ с выравниванием 4 КБ, приложение закрывается сразу после запуска.
## Экраны
-Макет — `Pipu Android.dc.html`, сводка решений — [design.md](design.md#android).
+Сводка внешнего вида — [design.md](design.md#android).
- **Подключение и вход**: адрес сервера (проверка `GET /server-info`: версия сервера, HTTPS, поддерживается ли эта версия
приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе.
@@ -53,9 +49,8 @@
загрузки (high) → параметр `q` стрима.
- **Кэш недавних треков** — кэш ExoPlayer (`cacheDir/TrackPlayer`, 500 МБ). Пока сервис плеера жив, SimpleCache держит
индекс в памяти, поэтому «Очистить» помечает кэш, а удаляется он при следующем холодном запуске до `setupPlayer`.
- Путь каталога взят из KotlinAudio — проверить на устройстве.
- **Офлайн-баннер** вверху, когда нет связи с сервером.
-- Админку в мобильном **не делаем** на первом этапе (ссылка «открыть в браузере»).
+- Админка открывается в браузере (ссылка в профиле у персонала).
## Воспроизведение
@@ -118,13 +113,13 @@
- В манифесте: `REQUEST_INSTALL_PACKAGES` (FileProvider добавляет expo-file-system).
- Сервер: `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN`, кэш APK — `UPDATES_DIR` (`/updates`). Без них — `404 app.update_unavailable`,
приложение молча пропускает проверку.
-- Все релизы подписываются **одним и тем же ключом** (keystore в секретах CI) — иначе Android откажет в обновлении.
-- `versionCode` монотонно растёт: `major·1 000 000 + minor·10 000 + patch·100 + (99 | rc)` (см. [ci-cd.md](ci-cd.md#версионирование)).
+- Все версии подписываются **одним и тем же ключом** — иначе Android откажет в обновлении.
+- `versionCode` монотонно растёт: `major·1 000 000 + minor·10 000 + patch·100 + (99 для выпуска, N для -rc.N)`.
- Ассеты релиза: `pipu-music-.apk`, `pipu-music-.apk.sha256`.
## Безопасность
- Refresh-токен — `expo-secure-store` (Android Keystore).
-- HTTPS обязателен; для self-signed сертификатов — возможность добавить пользовательский CA через
- `network_security_config` (собирается в CI по флагу) или пиннинг отпечатка, введённого при подключении к серверу.
+- HTTPS обязателен. Самоподписанный сертификат принимается, если при подключении указан его отпечаток
+ или на устройство установлен пользовательский CA.
- `android:allowBackup="false"` для данных с токенами.
diff --git a/docs/recommendations.md b/docs/recommendations.md
index c91ef9a..3670dc2 100644
--- a/docs/recommendations.md
+++ b/docs/recommendations.md
@@ -253,10 +253,7 @@ MMR-разнообразие и квоты остаются. Пока модел
- `POST /wave/sessions/{id}/next`: p95 < 150 мс (включая вызов recommender).
- Онлайн-обновление профиля по событию: < 50 мс, асинхронно из Redis Stream.
-## 11. Эволюция
+## 11. Что умеет волна
-| Версия | Содержимое |
-|---|---|
-| v1 | Аудиоанализ, эвристическое ранжирование, контексты, сессионный вектор, правила, fallback |
-| v2 | ALS, LightGBM-ранжировщик, бандит, центроиды вкуса, онбординг |
-| v3 | Последовательная модель по сессиям (SASRec / небольшой трансформер) как дополнительный источник кандидатов и признак — когда накопится ≥ 1 млн событий; «волна по исполнителю / треку», ежедневные персональные подборки |
+Эвристическое ранжирование, контексты (занятие и настроение), сессионный вектор и запасной алгоритм в `api` работают всегда.
+ALS, LightGBM, бандит, центроиды вкуса и онбординг включаются на накопленных прослушиваниях: ранжировщик — после примерно 20 тысяч размеченных показов.
diff --git a/docs/roadmap.md b/docs/roadmap.md
deleted file mode 100644
index d63a09a..0000000
--- a/docs/roadmap.md
+++ /dev/null
@@ -1,62 +0,0 @@
-# План разработки
-
-Каждый этап заканчивается рабочей версией с тегом и релизом.
-
-## M0 — Скелет (v0.1)
-- Монорепо: pnpm + Turborepo, Go-модуль, Python-проект, `openapi.yaml` с `server-info`.
-- `deploy/compose.dev.yaml`, nginx, Postgres, Redis, Meilisearch.
-- `ci.yml` и `release.yml` (пока собирают пустые образы и debug-APK «hello»), генерация тестовой музыки.
-- **Готово, когда:** тег `v0.1.0` создаёт релиз в Gitea с образами и APK.
-
-## M1 — Доступ (v0.2)
-- Bootstrap-админ, вход, refresh-ротация, смена пароля / учётных данных, сессии.
-- Роли и политика прав, админка «Пользователи», аудит.
-- Веб: каркас (топбар, сайдбар, пустой плеер), экраны входа и setup, i18n ru / en.
-- **Готово, когда:** E2E-сценарии 1, 2, 8, 9, 10 зелёные.
-
-## M2 — Библиотека и поиск (v0.3)
-- storage-agent, источники NFS / local, тест подключения.
-- Шаблоны путей с предпросмотром, сканер (инкрементальный, переименования, дубли), обложки.
-- Meilisearch, suggest и страница поиска, страницы исполнителя и альбома.
-- **Готово, когда:** реальная библиотека 11k треков сканируется, поиск < 50 мс, сценарии 3, 4.
-
-## M3 — Воспроизведение (v0.4)
-- Стриминг через X-Accel, подписанные ссылки, транскодинг и кэш.
-- Плеер в вебе (очередь, Media Session, горячие клавиши, нормализация), сохранение состояния.
-- События, история, лайки / дизлайки, раздел «Коллекция».
-- **Готово, когда:** сценарии 5, 7; перемотка мгновенная; позиция восстанавливается на другом устройстве.
-
-## M4 — Моя волна v1 (v0.5)
-- analyzer (Essentia), признаки и эмбеддинги, прогресс в админке.
-- recommender: кандидаты, эвристическое ранжирование, контексты (занятия / настроения), сессионный вектор,
- правила разнообразия; fallback в Go.
-- Анимированная кнопка волны и MoodPicker в вебе.
-- **Готово, когда:** сценарий 6; метрики волны видны в админке.
-
-## M5 — Android (v0.6)
-- Вход с адресом сервера, главная с волной, поиск, коллекция, плеер с фоновым воспроизведением.
-- Офлайн: загрузки, «Скачанное», очередь событий.
-- Самообновление через сервер.
-- **Готово, когда:** Maestro-сценарии зелёные; обновление 0.6.0 → 0.6.1 проходит на реальном устройстве.
-
-## M5.5 — Очередь на добавление (v0.7)
-- Отпечатки (SHA-256, Chromaprint) для существующей библиотеки.
-- Запись через storage-agent, `accepts_uploads`, `write_template` с автоподбором и проверкой обратимости.
-- tus-загрузка, предпроверка дублей, квота, обработка, экраны «Предложить треки», «Очередь на добавление», «Настройки».
-- **Готово, когда:** E2E-сценарии 11, 12; импорт реального альбома ложится рядом с существующими по той же структуре.
-
-## M6 — Моя волна v2 (v0.8)
-- ALS, центроиды вкусов, LightGBM-ранжировщик, бандит, онбординг, ночное обучение с проверкой метрик.
-- Персонализация suggest.
-- **Готово, когда:** на накопленных данных v2 лучше v1 по офлайн-NDCG и онлайн early-skip rate.
-- **Состояние:** всё реализовано (v0.8, v1.0). Ранжировщик включается сам после ~20 тыс. размеченных показов;
- критерий проверяется по метрикам версий в админке («Библиотека → Моя волна») и NDCG в `model_versions`.
-
-## M7 — Стабилизация (v1.0)
-- Нагрузочный smoke, документация администратора, доступность, полировка анимаций, багфиксы.
-- **Состояние:** v1.0 — [руководство администратора](admin-guide.md), аудит доступности WCAG 2.1 AA, k6-smoke
- (`tests/load/smoke.js`, запускается вручную на сервере перед релизом). Копии базы и музыки — внешними средствами.
-
-## Дальше (идеи)
-- Плейлисты, волна по исполнителю / треку, ежедневные подборки, тексты песен, Chromecast / DLNA,
- Android Auto, iOS (TestFlight), Last.fm-скробблинг, импорт лайков.
diff --git a/docs/testing.md b/docs/testing.md
deleted file mode 100644
index f9f896f..0000000
--- a/docs/testing.md
+++ /dev/null
@@ -1,79 +0,0 @@
-# Тестирование
-
-## Пирамида
-
-| Уровень | Где | Инструменты | Что покрываем |
-|---|---|---|---|
-| Unit | Go | `testing`, `testify`, табличные тесты | Политики прав (вся матрица из auth-and-roles.md), парсинг шаблонов путей и слияние с тегами, нормализация имён, подпись ссылок, расчёт `implicit_score`, рендер `write_template` и очистка имён, обратимость шаблон ↔ регэксп, учёт квоты при конкурентных резервах, переходы статусов загрузки |
-| Unit | Python | `pytest`, `hypothesis` | Формулы скоринга, context_fit, MMR, бандит, обновление сессионного вектора, правила переранжирования |
-| Unit | TS | `vitest` | `packages/core`: очередь, события (подсчёт `played_ms`, засчитанное прослушивание), single-flight refresh, офлайн-очередь |
-| Компоненты | Web | `vitest` + Testing Library + axe-core | Поиск, плеер, волна, формы админки, вход; серьёзные нарушения axe на главной и входе |
-| Компоненты | Mobile | `vitest` (`apps/mobile/test`) | Офлайн-волна и решение диалога обновления |
-| Интеграция | Go | `testcontainers-go` (Postgres + pgvector, Redis, Meilisearch) | Репозитории, миграции, сканер, API через `httptest`; `TestUserJourney` — сценарии 1–2 и скан |
-| Интеграция | Python | `testcontainers` | Чтение событий из Redis Stream, kNN в pgvector, ночной пайплайн на синтетических данных |
-| Контракт | — | `tests/contract/schemathesis.sh` | Ответы соответствуют `openapi.yaml`; нужен запущенный `api` |
-| E2E | Web | Playwright (`apps/web/e2e`, Chromium, `pnpm --filter @pipu/web test:e2e`) | Сценарии 1, 4 и 9 на фейковом API |
-| E2E | Mobile | Maestro (`apps/mobile/.maestro`) | Вход, коллекция, проверка обновления. Ночной прогон (`PIPU_E2E_SERVER`, `PIPU_E2E_USER`, `PIPU_E2E_PASSWORD`), не на каждый PR |
-| Качество рекомендаций | Python | офлайн-оценка | NDCG / Recall на синтетическом датасете с известными «вкусами»; регрессия качества ломает тест |
-
-## Тестовая музыка
-
-Реальную музыку в репозиторий не кладём. `tests/fixtures/music/generate.sh` генерирует через `ffmpeg`
-~200 коротких (5–20 с) файлов разных форматов (FLAC, MP3, Opus, M4A) с синусами / шумом разного темпа и громкости,
-с полными, частичными и отсутствующими тегами, в нескольких структурах папок — для проверки шаблонов,
-дублей, переименований, кириллицы в путях. Генерация детерминирована (фиксированные параметры), результат кэшируется в CI.
-
-Для аудиоанализа — отдельный набор из нескольких клипов с известным BPM (метроном 90 / 128 / 170).
-
-## E2E-сценарии (обязательные)
-
-Сценарии 1, 4 и 9 гоняет Playwright. Сценарии входа, ролей, поиска, плеера, волны и языка — `apps/web/src/App.test.tsx`.
-Сценарии источника и скана — `TestUserJourney` в `services/api` (нужен `TEST_DATABASE_URL`).
-
-1. Первый запуск: вход bootstrap-админа → принудительная смена логина / пароля → главная.
-2. Админ создаёт модератора; модератор создаёт пользователя; модератор **не может** создать модератора (UI и API).
-3. Админ добавляет `local`-источник, задаёт два шаблона, предпросмотр, скан → треки появились в поиске.
-4. Поиск: выпадающий список → переход к исполнителю; Enter → страница поиска.
-5. Воспроизведение: запуск трека, перемотка, перезагрузка страницы → трек и позиция восстановлены.
-6. Моя волна: запуск с занятием «Бегаю» → в выдаче треки с BPM из профиля; дизлайк → следующий трек, трек в «Дизлайках».
-7. Коллекция: лайк → в «Лайках»; история содержит прослушанное.
-8. Смена пароля пользователем → старые сессии разлогинены.
-9. Переключение языка ru ↔ en.
-10. Сброс пароля пользователю → обязательная смена при входе.
-11. Очередь на добавление: модератор загружает папку из 5 файлов, где 1 — точная копия трека из библиотеки,
- 1 — тот же трек в другом формате → первый отброшен на предпроверке, второй — по отпечатку; оба не учтены в квоте;
- оставшиеся 3 — `pending_review`. Админ правит альбом, одобряет → файлы появились в хранилище по `write_template`,
- трек найден поиском; квота освобождена.
-12. Квота: лимит 50 МБ, загрузка 80 МБ → часть файлов отклонена `uploads.queue_full`; модератор не может
- одобрить собственную загрузку, а обычный пользователь — открыть загрузку вообще (UI и API).
-
-## Правила
-
-- Покрытие: Go и Python ≥ 70% по строкам для `internal/*/service` и `pipu_rec/*`; для `auth/policy` — 100% веток.
-- Тест на каждый исправленный баг.
-- Flaky-тесты помечаются и чинятся в течение недели, а не перезапускаются бесконечно.
-- Нагрузочный smoke (k6, вручную перед релизом) — см. ниже.
-
-## Нагрузочный smoke
-
-`tests/load/smoke.js` (k6 ≥ 0.50), 3 минуты: 100 одновременных слушателей волны (старт волны, начало потока
-каждого трека — первые 64 КБ, события прослушивания, следующая пачка с отзывами) и 20 запросов подсказок поиска
-в секунду. Пороги — цели из [api.md](api.md) и [recommendations.md](recommendations.md#10-требования-к-производительности):
-
-| Запрос | p95 |
-|---|---|
-| `GET /search/suggest` | < 50 мс |
-| `POST /wave/sessions/{id}/next` | < 150 мс |
-| `POST /wave/sessions` | < 300 мс |
-| начало потока (`Range: bytes=0-65535`) | < 300 мс |
-| `POST /events` | < 100 мс |
-
-Ошибок < 1%. k6 завершается с ненулевым кодом, если порог не выполнен.
-
-```bash
-k6 run -e BASE_URL=https://music.example.com -e PIPU_USER=loadtest -e PIPU_PASSWORD=… tests/load/smoke.js
-```
-
-Пользователь — отдельный обычный (`role=user`): тест наполняет его историю и лайки. Нагрузку можно менять:
-`-e LISTENERS=20 -e SUGGEST_RPS=5 -e DURATION=1m`. Запускать с машины в той же сети, что и сервер, —
-иначе в цифрах будет задержка канала.
diff --git a/docs/uploads.md b/docs/uploads.md
index 4715284..e0f48e2 100644
--- a/docs/uploads.md
+++ b/docs/uploads.md
@@ -84,7 +84,7 @@
до 4 параллельных загрузок, прогресс по каждому файлу.
- Мобильный клиент загрузку не поддерживает (первая версия).
- Staging — локальный диск сервера `/srv/pipu/uploads//` (не NFS).
-- Разрешённые форматы: `flac mp3 m4a ogg opus wav aiff wv`. Проверяется не расширение, а содержимое (`ffprobe`):
+- Разрешённые форматы: `flac mp3 m4a ogg opus wav aiff wv`. Просим присылать **FLAC или WAV**; MP3 и остальные — крайний случай: форма предупреждает об этом, а очередь на добавление помечает такой файл «обратить внимание». Проверяется не расширение, а содержимое (`ffprobe`):
должен быть ровно один аудиопоток, длительность 5 с – 3 ч. Иначе `invalid` с кодом причины.
- Допускается отдельная картинка обложки (`cover.jpg|png`) в той же пачке — привязывается к файлам той же папки.
@@ -184,7 +184,7 @@
очереди — `upload_files.fingerprint` (дубли внутри очереди ловятся так же, как с библиотекой).
- Квота: `Store.CreateBatch` резервирует под `SELECT … FOR UPDATE` строки `upload_quota`; любая смена статуса идёт через
`transition` — место освобождается в той же транзакции, если новый статус его не занимает (таблица раздела 2).
-- Приём — ядро tus (ADR-015): `HEAD` / `PATCH /api/v1/uploads/files/{id}`, только автор и только в `uploading`.
+- Приём — ядро tus: `HEAD` / `PATCH /api/v1/uploads/files/{id}`, только автор и только в `uploading`.
Принят целиком → `processing`.
- Обработка (`worker.go`, по одному файлу): размер → `ffprobe` (5 с – 3 ч) → SHA-256 (сверка с заявленным) →
дубль по SHA-256 → Chromaprint против библиотеки и очереди (длительность ±5 с, `library.Similarity` ≥ 0.75) →
diff --git a/docs/web.md b/docs/web.md
index d7ca76e..029b7ef 100644
--- a/docs/web.md
+++ b/docs/web.md
@@ -11,9 +11,9 @@
| Клиентское состояние | Zustand (плеер, очередь, UI) |
| Стили | CSS Modules + CSS-переменные из `packages/design-tokens`; тёмная и светлая темы ([design.md](design.md#2-токены)) |
| Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono; иконки — SVG Material Symbols Rounded (`@material-symbols/svg-400`), импортируются поштучно в `shared/ui/icons.ts` и рисуются CSS-маской (`currentColor`), поэтому в сборку попадают только используемые — всё встроено в сборку |
-| Компоненты-примитивы | Radix UI (доступные меню, диалоги, слайдеры, popover поиска); в M1 — собственные `Menu` / `Dialog` в `shared/ui`, переход на Radix вместе с поиском (M2) |
-| Анимация | Motion (переходы UI), Canvas 2D для кнопки волны (порт `wave.js` из прототипа) |
-| Формы | React Hook Form + Zod (в M1 формы простые — контролируемые поля, валидация на сервере с ошибками полей) |
+| Компоненты-примитивы | Свои `Menu`, `Dialog`, поля и сегменты в `shared/ui` |
+| Анимация | Canvas 2D для кнопки волны |
+| Формы | Контролируемые поля; проверка на сервере, ошибки приходят по полям |
| i18n | i18next + react-i18next, словари из `packages/i18n` |
| API | `packages/api-client` (сгенерирован из OpenAPI, `openapi-typescript` + `openapi-fetch`) |
@@ -31,21 +31,20 @@ apps/web/src/
└── shared/ # ui-kit, lib, hooks, config
```
-Правило: слой может импортировать только из нижележащих (`pages → widgets → features → entities → shared`),
-проверяется `eslint-plugin-boundaries`.
+Слой импортирует только из нижележащих: `pages → widgets → features → entities → shared`.
## Ключевые компоненты
### App shell
-Макет — `Pipu Web.dc.html`, каркас **Classic** ([design.md](design.md#4-каркас-и-экраны)).
+Каркас **Classic** ([design.md](design.md#4-каркас-и-экраны)).
- Сетка: топбар 60 px (логотип, поиск, меню профиля) / сайдбар 240 px (Главная, Коллекция → История / Лайки / Дизлайки,
Админка по роли) / контент / плеер.
- Плеер в двух видах, выбор в профиле: `bar` — полоса 88 px внизу (по умолчанию), `island` — плавающий остров.
- Ambient-фон за всем интерфейсом: три размытых дрейфующих пятна, одно — в цвете обложки текущего трека.
-- Адаптивность (макетов нет): < 1024 px — сайдбар из иконок, < 768 px — нижняя навигация, как в мобильном.
+- Адаптивность: < 1024 px — сайдбар из иконок, < 768 px — нижняя навигация, как в мобильном.
### Поиск
-- `SearchBox` — Radix Popover + combobox по WAI-ARIA (стрелки, Enter, Esc).
+- `SearchBox` — combobox по WAI-ARIA (стрелки, Enter, Esc).
- Debounce 150 мс, отмена предыдущего запроса, `GET /search/suggest`.
- Enter без выбранного элемента → `/search?q=…`; Enter / клик по элементу → исполнитель / альбом / запуск трека.
- Горячая клавиша `/` или `Ctrl+K` — фокус в поиск.
@@ -87,7 +86,7 @@ apps/web/src/
Перед загрузкой — Web Worker считает SHA-256 и читает теги (`music-metadata`), `POST /uploads/precheck` →
дубли сразу показываются как «уже есть: <трек>» и не загружаются. Загрузка — `tus-js-client`, до 4 параллельно,
пауза / возобновление. Ниже — «Мои предложения» со статусами и причинами отклонения.
-- **Очередь на добавление** (admin): группы по альбомам (вариант `grouped` из прототипа), мини-плеер для прослушивания, inline-редактирование
+- **Очередь на добавление** (admin): группы по пачкам, прослушивание из staging, правка метаданных
метаданных (поле на весь альбом), live-превью итогового пути, бейджи «возможный дубль» / «лучшее качество»,
массовые одобрение / отклонение с причиной. Бейдж с числом ожидающих — в сайдбаре.
- **Настройки** (admin): лимит очереди (ГБ), лимит на модератора, макс. размер файла, хранилище по умолчанию.
diff --git a/docs/Разработка дизайна приложения/.thumbnail b/docs/Разработка дизайна приложения/.thumbnail
deleted file mode 100644
index c09d01c..0000000
Binary files a/docs/Разработка дизайна приложения/.thumbnail and /dev/null differ
diff --git a/docs/Разработка дизайна приложения/Pipu Android.dc.html b/docs/Разработка дизайна приложения/Pipu Android.dc.html
deleted file mode 100644
index 6dae1f3..0000000
--- a/docs/Разработка дизайна приложения/Pipu Android.dc.html
+++ /dev/null
@@ -1,790 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Кликабельный прототип. Внутри телефона всё работает: волна, плеер, загрузки, обновление. Слева — быстрый переход к экранам.
-
-
- {{ j.n }} {{ j.label }}
-
-
-
- {{ note }}
-
-
-
-
-
- {{ ambient }}
-
-
-
-
-
-
pipu
-
Подключитесь к своему серверу Pipu Music
-
-
- Адрес сервера
-
- dns
-
-
-
-
- progress_activity Проверяем сервер…
-
-
- check_circle Pipu Music · сервер 0.6.1 HTTPS · версия приложения поддерживается
-
-
- error Сервер не отвечает. Проверьте адрес и сеть.
-
-
-
Сохранённые серверы
-
-
- {{ p.icon }}
- {{ p.name }} {{ p.addr }}
-
-
-
-
-
- Проверить
- Далее
-
-
-
-
-
-
-
-
dns {{ srv.addr }}
-
- {{ l.label }}
-
-
-
-
Вход
-
Учётную запись выдаёт администратор сервера
-
-
Логин
-
Пароль
-
error {{ loginErr }}
-
-
Войти
-
-
-
-
-
-
- cloud_off Нет сети — играет скачанное. {{ pendingEvents }} прослушиваний отправятся позже
-
-
-
-
-
-
-
-
- {{ waveIcon }}
- Моя волна
- {{ ctxLabel }}
-
-
-
-
Занятие
-
-
- {{ c.icon }} {{ c.label }}
-
-
-
-
-
Настроение
-
-
- {{ c.icon }} {{ c.label }}
-
-
-
-
-
-
-
-
-
- search
-
- close
-
-
-
-
Недавние запросы
-
history {{ r.label }}
-
Опечатки и неправильная раскладка не помеха: «ршту» найдёт «Кино».
-
-
-
Исправили раскладку: «{{ sr.fixed }}»
-
-
-
{{ a.ini }}
{{ a.name }}
-
-
-
-
-
Альбомы
-
{{ a.title }} {{ a.sub }}
-
-
-
-
-
Треки
-
-
-
{{ r.eq }}
-
{{ r.title }} download_done {{ r.artist }}
-
favorite
-
-
-
-
-
Ничего не нашлось
-
-
-
-
-
-
Коллекция
-
-
- {{ t.icon }} {{ t.label }}
-
-
-
-
-
-
-
Занято на устройстве {{ dl.usedStr }} · {{ dl.count }}
-
-
Свободно в телефоне: 41,2 ГБ · файлы видны только приложению
-
-
-
-
favorite
-
Скачивать новые лайки Сразу после ♥
-
-
-
download Скачать все лайки {{ dl.likesLeft }}
-
-
-
-
Загружается · по Wi-Fi
-
-
-
-
-
-
-
-
-
-
{{ a.title }} {{ a.sub }}
-
delete
-
-
-
-
{{ r.eq }} {{ r.title }}
-
{{ r.size }}
-
close
-
-
-
-
-
-
-
-
-
-
-
- play_arrow Слушать
- shuffle Перемешать
-
-
-
Эти треки не попадают в волну. Снимите дизлайк, если передумали.
-
- {{ g.day }}
-
-
-
{{ r.eq }}
-
{{ r.title }} download_done {{ r.artist }}· {{ r.time }}
-
undo
-
favorite
-
-
-
-
-
-
-
-
-
-
-
arrow_back
-
-
-
{{ alb.title }}
-
{{ alb.artist }} · {{ alb.year }} · {{ alb.meta }}
-
-
- {{ alb.dlIcon }}
- {{ alb.playIcon }}
- shuffle
-
-
{{ alb.dlLabel }}
-
-
-
-
{{ r.eq }} {{ r.num }}
-
{{ r.title }} download_done progress_activity {{ r.dur }}
-
favorite
-
-
-
-
-
-
-
-
-
arrow_back
-
-
{{ art.ini }}
-
{{ art.name }}
-
{{ art.meta }}
-
-
- graphic_eq Волна по исполнителю
- play_arrow
-
-
-
Популярные треки
-
-
-
{{ r.eq }}
-
{{ r.title }} {{ r.album }}
-
favorite
-
-
-
-
-
Альбомы
-
-
{{ a.title }} {{ a.year }}
-
-
-
-
-
-
-
-
-
{{ me.ini }}
-
{{ me.name }} {{ me.login }} · {{ me.role }}
-
-
-
key Сменить пароль chevron_right
-
translate Язык {{ l.label }}
-
-
Качество звука
-
-
- {{ r.icon }} {{ r.label }} {{ r.value }}
-
-
wifi Скачивать только по Wi-Fi
-
cached Кэш недавних треков {{ cacheStr }} из 500 МБ
Очистить
-
-
Приложение
-
-
system_update Проверить обновления Версия {{ appVer }}
0.6.2
-
dns Сервер {{ srv.addr }}
chevron_right
-
shield_person Админка Откроется в браузере
open_in_new
-
logout Выйти
-
-
-
-
-
-
-
-
{{ cur.title }} {{ miniSub }}
-
favorite
-
{{ playIcon }}
-
-
-
-
-
-
- {{ t.icon }}
- {{ t.label }}
-
-
-
-
-
-
-
-
-
-
-
keyboard_arrow_down
-
{{ fp.srcKind }} {{ fp.srcName }}
-
queue_music
-
-
-
-
-
-
-
-
-
{{ cur.title }} {{ cur.artist }}
-
download_done
-
-
-
{{ posStr }} {{ durStr }}
-
- thumb_down
- skip_previous
- {{ playIcon }}
- skip_next
- favorite
-
-
phone_android {{ fp.output }}
-
-
-
-
-
-
-
-
-
-
-
- {{ qs.title }}
-
-
- {{ o.label }} {{ o.sub }}
-
-
-
-
-
- Смена пароля
-
-
-
- {{ pwErr }}
- Сохранить
-
-
-
- Очередь {{ srcLabel }}
-
-
-
-
{{ r.eq }}
-
{{ r.title }} {{ r.artist }}
-
{{ r.dur }}
-
-
-
- all_inclusive Волна бесконечна — дальше подберётся по ходу.
-
-
-
-
-
system_update
-
{{ up.title }} {{ up.sub }}
-
-
-
-
Что нового
-
• {{ n }}
-
- Позже Обновить
-
-
- shield Приложение ставится не из Google Play. Android один раз попросит разрешить установку из этого источника.
- Открыть настройки и продолжить
-
-
-
-
-
{{ up.dlStr }} {{ up.pctStr }}
-
-
-
- progress_activity Проверяем контрольную сумму SHA-256…
-
-
- verified Подпись и SHA-256 совпали sha256 9f2c…e41a
- Установить
-
-
-
-
-
-
-
- {{ toast }}
-
-
-
-
-
-
-
-
diff --git a/docs/Разработка дизайна приложения/Pipu Web.dc.html b/docs/Разработка дизайна приложения/Pipu Web.dc.html
deleted file mode 100644
index bd23910..0000000
--- a/docs/Разработка дизайна приложения/Pipu Web.dc.html
+++ /dev/null
@@ -1,1614 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-{{ ambient }}
-
-
-
-
-
-
-
-
-
- {{ l.label }}
-
-
-
-
-
-
-
Вход
-
Учётные записи выдаёт администратор сервера
-
-
-
- Логин
-
-
-
- Пароль
-
-
- {{ pwIcon }}
-
-
-
- error {{ err.login }}
-
-
-
Войти
-
Демо: admin / admin — первый вход администратора, пароль reset — после сброса
-
-
-
-
-
-
-
{{ setupBadgeIcon }} {{ setupBadge }}
-
{{ setupTitle }}
-
{{ setupText }}
-
-
-
Сохранить и продолжить
-
Выйти
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- keyboard Исправили раскладку: «{{ sFixed }}»
-
-
- Исполнители
-
-
-
{{ it.ini }}
-
{{ it.title }} {{ it.sub }}
-
-
-
-
- Альбомы
-
-
-
-
{{ it.title }} {{ it.sub }}
-
-
-
-
- Треки
-
-
-
play_arrow
-
{{ it.title }} {{ it.sub }}
-
{{ it.dur }}
-
-
-
-
- Ничего не нашлось
-
-
- Все результаты по «{{ q }}»
- Enter ↵
-
-
-
-
-
-
-
- {{ me.ini }}
- expand_more
-
-
-
-
- {{ me.name }}
- {{ me.login }} · {{ me.role }}
-
-
settings Профиль и настройки
-
{{ themeIcon }} {{ themeLabel }}
-
logout Выйти
-
-
-
-
-
-
-
-
- {{ n.icon }} {{ n.label }}
-
-
-
- Коллекция
-
-
- {{ n.icon }} {{ n.label }} {{ n.count }}
-
-
-
-
- Админка
-
-
- {{ n.icon }} {{ n.label }}
- {{ n.badge }}
-
-
-
-
-
-
-
-
-
-
-
-
- {{ waveIcon }}
- Моя волна
- {{ ctxLabel }}
-
-
-
-
Занятие
-
-
- {{ c.icon }} {{ c.label }}
-
-
-
-
-
Настроение
-
-
- {{ c.icon }} {{ c.label }}
-
-
-
-
-
-
-
-
Сейчас в волне: {{ cur.title }} — {{ cur.artist }}
-
-
-
-
-
-
-
-
-
- {{ waveIcon }}
-
-
-
-
-
Моя волна
-
Бесконечный поток под ваш вкус. Учится на лайках, пропусках и звучании треков — выберите, что вы делаете, и волна подстроится.
-
-
-
-
-
{{ cur.title }} {{ cur.artist }} · {{ ctxLabel }}
-
skip_next
-
-
-
-
Занятие
- Сбросить — просто под меня
-
-
-
- {{ c.icon }} {{ c.label }}
-
-
-
-
-
Настроение
-
-
- {{ c.icon }} {{ c.label }}
-
-
-
-
-
-
-
-
-
-
-
-
Результаты поиска
-
«{{ sp.q }}»
-
Показаны результаты для «{{ sp.fixed }}» — запрос был набран в другой раскладке
-
-
-
- {{ t.label }} {{ t.count }}
-
-
-
-
-
Исполнители
-
-
-
-
{{ a.ini }}
-
{{ a.name }} {{ a.sub }}
-
-
-
-
-
-
-
-
Альбомы
-
-
-
-
-
{{ a.title }} {{ a.sub }}
-
-
-
-
-
-
-
-
Треки
-
-
-
{{ r.eq }} {{ r.num }}
-
-
{{ r.title }} {{ r.artist }}
-
{{ r.album }}
-
favorite
-
{{ r.dur }}
-
-
-
-
-
- search_off Ничего не нашлось. Попробуйте другое написание.
-
-
-
-
-
-
-
-
{{ ap.ini }}
-
-
Исполнитель · {{ ap.genre }}
-
{{ ap.name }}
-
{{ ap.meta }}
-
-
-
-
- graphic_eq Волна по исполнителю
- play_arrow Слушать
- shuffle
-
-
-
Популярные треки
-
-
-
{{ r.eq }} {{ r.num }}
-
-
{{ r.title }}
-
{{ r.album }}
-
favorite
-
{{ r.dur }}
-
-
-
-
-
Альбомы
-
-
-
-
-
{{ a.title }} {{ a.sub }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Альбом
-
{{ alp.title }}
-
-
-
{{ alp.artist }}
-
· {{ alp.year }} · {{ alp.meta }}
-
-
-
-
-
- {{ alp.playIcon }}
- shuffle
-
-
-
# Название schedule
-
-
-
-
{{ r.eq }} {{ r.num }}
-
{{ r.title }} {{ r.artist }}
-
favorite
-
{{ r.dur }}
-
-
-
-
-
-
-
-
-
-
-
{{ col.icon }}
-
-
Коллекция
-
{{ col.title }}
-
{{ col.sub }}
-
-
-
- play_arrow Слушать всё
- shuffle Перемешать
-
-
-
-
-
- {{ t.label }}
-
-
-
-
-
{{ g.day }}
-
-
-
{{ r.eq }} {{ r.num }}
-
-
{{ r.title }} {{ r.artist }}
-
{{ r.album }}
-
- undo Убрать дизлайк
-
-
- favorite
-
-
{{ r.dur }}
-
-
-
-
-
- {{ col.icon }} {{ col.emptyText }}
-
-
-
-
-
-
-
Профиль и настройки
-
-
{{ me.ini }}
-
{{ me.name }} Логин: {{ me.login }}
-
{{ me.role }}
-
-
-
-
-
translate
-
Язык интерфейса Словари ru / en, можно добавить другие
-
- {{ l.full }}
-
-
-
-
contrast
-
Тема
-
- {{ l.label }}
-
-
-
-
blur_linear
-
Кроссфейд Плавный переход между треками
-
-
{{ crossfade }} с
-
-
-
equalizer
-
Выравнивание громкости По EBU R128, без перепадов между треками
-
-
-
-
logout Выйти из аккаунта
-
-
-
-
-
-
-
Пользователи
{{ uu.hint }}
-
person_add Создать пользователя
-
-
-
search
-
- {{ t.label }} {{ t.count }}
-
-
-
-
Пользователь Роль Статус Был в сети
-
-
-
-
{{ u.ini }}
-
{{ u.name }} {{ u.login }}
-
-
{{ u.roleLabel }}
-
{{ u.stLabel }}
-
{{ u.last }}
-
- more_horiz
-
-
-
- {{ a.icon }} {{ a.label }}
-
-
-
-
-
-
-
-
-
-
-
-
Хранилища
Откуда берётся музыка и как из путей извлекаются данные о треках
-
add Добавить хранилище
-
-
-
-
-
hard_drive {{ x.name }} {{ x.stLabel }}
-
{{ x.addr }}
-
{{ x.typeLabel }} · {{ x.tracks }} принимает загрузки
-
{{ x.err }}
-
-
-
-
-
-
-
-
- Адрес сервера
- Путь экспорта
- Опции монтирования
-
-
-
- Подпапка в /srv/pipu/library-host
-
-
-
lan {{ src.testLabel }}
-
Принимает новые треки (монтируется на запись)
-
-
- {{ src.testRes.icon }} {{ src.testRes.text }}
-
-
-
-
-
Шаблоны путей Регулярные выражения RE2 с именованными группами. Срабатывает первое совпавшее — порядок меняется перетаскиванием.
-
{{ l.label }}
-
-
-
-
-
drag_indicator #{{ p.n }}
-
-
delete
-
-
-
add Добавить выражение
-
-
-
-
Предпросмотр на реальных файлах {{ src.prevSummary }}
-
-
-
-
-
{{ g.t }}
-
{{ r.badge }}
-
-
- {{ fl.k }} {{ fl.v }}
-
-
-
-
-
-
-
-
-
Шаблон раскладки новых треков По нему одобренные файлы записываются в хранилище — так же, как уже разложена музыка
-
-
- auto_fix_high Подобрать автоматически
- fact_check Проверить
-
-
-
-
{{ src.chkSummary }} {{ src.chkNote }}
-
-
-
{{ c.icon }}
-
{{ c.real }} → {{ c.gen }}
-
-
-
-
-
-
Сохранить и пересканировать
-
-
-
-
-
-
-
Библиотека
Состояние каталога, сканирования и аудиоанализа
-
-
- {{ x.l }} {{ x.v }} {{ x.n }}
-
-
-
-
-
folder_open {{ lib.scanLabel }} {{ lib.scanPct }}
-
-
{{ lib.scanFiles }} {{ lib.scanEta }}
-
refresh Пересканировать
-
-
-
graphic_eq Аудиоанализ звучания {{ lib.anaPct }}
-
-
{{ lib.anaFiles }} {{ lib.anaEta }}
-
Темп, энергия, настроение, жанры — нужны волне даже для треков, которые никто не слушал
-
-
-
-
Качество «Моей волны» · 7 дней
-
-
- {{ x.l }} {{ x.v }} {{ x.n }}
-
-
-
-
-
Ошибки
-
-
-
-
error
-
{{ x.p }} {{ x.e }}
-
{{ x.w }}
-
-
-
-
-
-
-
-
-
-
Предложить треки
Файлы попадут в очередь на добавление. Дубли отсекаются до загрузки и не занимают место.
-
-
Очередь на добавление {{ up.usedStr }} из {{ up.limStr }} ГБ · свободно {{ up.freeStr }} ГБ
-
-
-
-
upload
-
Перетащите файлы или папки альбомов
-
flac, mp3, m4a, ogg, opus, wav, aiff, wv · до 2 ГБ на файл · cover.jpg привяжется к альбому
-
-
-
-
Текущая загрузка
-
-
-
-
-
audio_file
-
{{ u.name }} {{ u.folder }} · {{ u.size }}
-
{{ u.stLabel }}
-
Отозвать
-
-
-
{{ u.note }}
-
-
-
-
-
-
-
Мои предложения
-
-
-
-
album
-
{{ p.name }} {{ p.meta }} {{ p.reason }}
-
{{ p.stLabel }}
-
Отозвать
-
-
-
-
-
-
-
-
-
-
-
Очередь на добавление
{{ qq.summary }}
-
{{ t.label }} {{ t.count }}
-
-
-
- {{ qq.selLabel }}
- Снять выделение
- close Отклонить
- check Одобрить
-
-
-
- task_alt Всё рассмотрено — новых предложений нет
-
-
-
-
-
-
-
{{ g.selIcon }}
-
-
-
- Отклонить
- done_all Одобрить альбом
-
-
-
-
-
{{ t.selIcon }}
-
{{ t.playIcon }}
-
{{ t.n }}
-
-
-
{{ t.flagIcon }} {{ t.flagLabel }}
-
{{ t.note }}
-
subdirectory_arrow_right {{ sg.t }}
-
-
{{ t.size }}
-
- close
- check
-
-
-
-
-
- папка уже есть будет создана
-
-
-
-
-
-
-
-
-
{{ g.album }} {{ g.artist }} {{ g.count }} · {{ g.by }}
-
-
-
-
-
-
-
-
Куда ляжет в хранилище
-
- {{ n.icon }} {{ n.t }} {{ n.note }}
-
-
-
-
-
-
{{ t.selIcon }}
-
{{ t.playIcon }}
-
{{ t.n }}
-
-
- {{ t.flagIcon }} {{ t.flagLabel }}
- {{ t.note }}
-
-
{{ t.size }}
-
- close
- check
-
-
-
-
-
- Отклонить альбом
- done_all Одобрить и разложить
-
-
-
-
-
-
-
-
-
-
-
Настройки
Очередь на добавление и запись в хранилище
-
-
-
Лимит очереди Общий объём файлов на рассмотрении
-
ГБ
-
-
-
-
Лимит на модератора Чтобы один человек не занял всю очередь
-
-
-
ГБ на человека
-
-
-
Максимальный размер файла
-
ГБ
-
-
-
Хранилище для новых треков При одобрении можно выбрать другое
-
- {{ o.l }}
-
-
-
-
Принимать замену на лучшее качество Тот же трек в lossless пойдёт на рассмотрение, а не отсеется как дубль
-
-
-
-
Сохранить
-
-
-
-
-
-
-
-
-
-
-
{{ cur.title }}
-
{{ cur.artist }} · {{ srcLabel }}
-
-
skip_previous
-
{{ playIcon }}
-
skip_next
-
{{ posStr }} / {{ durStr }}
-
favorite
-
thumb_down
-
queue_music
-
{{ volIcon }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Очередь {{ srcLabel }}
-
close
-
-
-
-
-
{{ r.eq }}
-
{{ r.title }} {{ r.artist }}
-
{{ r.dur }}
-
-
-
- all_inclusive Волна бесконечна — следующие треки подбираются по ходу, с учётом ваших реакций.
-
-
-
-
-
-
-
-
-
- Новый пользователь
- Логин {{ nu.err }}
- Имя
-
-
-
- Пароль сгенерируется автоматически. При первом входе пользователь задаст свой.
- Отмена Создать
-
-
- Временный пароль
Для: {{ tp.who }}
- {{ tp.pw }} content_copy Копировать
- visibility_off Пароль показывается один раз. При входе пользователь обязан будет задать новый.
- Готово
-
-
- {{ rj.title }}
- {{ r.label }}
-
- Отмена Отклонить
-
-
-
-
-
-
- check_circle {{ toast }}
-
-
-
-
-
-
-
diff --git a/docs/Разработка дизайна приложения/Wave Options.dc.html b/docs/Разработка дизайна приложения/Wave Options.dc.html
deleted file mode 100644
index 3515a7c..0000000
--- a/docs/Разработка дизайна приложения/Wave Options.dc.html
+++ /dev/null
@@ -1,66 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Кнопка «Моя волна» — 3 варианта
-
Наведите курсор — волны ускоряются и тянутся к нему; зажмите — сжатие с отскоком; кликните — включение/пауза (пульс в такт BPM). Выбранный вариант переключается и в прототипе через Tweaks.
-
-
-
-
-
-
{{ v.id }}
-
{{ v.name }}
-
-
-
- {{ v.icon }}
- Моя волна
-
-
-
{{ v.desc }}
-
-
-
-
-
-
-
-
diff --git a/docs/Разработка дизайна приложения/android-frame.jsx b/docs/Разработка дизайна приложения/android-frame.jsx
deleted file mode 100644
index d3ba6dc..0000000
--- a/docs/Разработка дизайна приложения/android-frame.jsx
+++ /dev/null
@@ -1,229 +0,0 @@
-// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
-// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
-
-/* BEGIN USAGE */
-// Android.jsx — Simplified Android (Material 3) device frame
-// Status bar + top app bar + content + gesture nav + keyboard.
-// Based on Figma M3 spec. No dependencies, no image assets.
-// Exports (to window): AndroidDevice, AndroidStatusBar, AndroidAppBar, AndroidListItem, AndroidNavBar, AndroidKeyboard
-//
-// Usage — wrap your screen content in to get the bezel, status
-// bar and gesture nav (props: title, large, keyboard, dark):
-//
-//
-// ...your screen content...
-//
-// …
-/* END USAGE */
-
-const MD_C = {
- surface: '#f4fbf8',
- surfaceVariant: '#dae5e1',
- inverseOnSurface: '#ecf2ef',
- secondaryContainer: '#cde8e1',
- primaryFixedDim: '#83d5c6',
- onSurface: '#171d1b',
- onSurfaceVar: '#49454f',
- onPrimaryContainer: '#00201c',
- primary: '#006a60',
- frameBorder: 'rgba(116,119,117,0.5)',
-};
-
-// ─────────────────────────────────────────────────────────────
-// Status bar (time left, wifi/cell/battery right)
-// ─────────────────────────────────────────────────────────────
-function AndroidStatusBar({ dark = false }) {
- const c = dark ? '#fff' : MD_C.onSurface;
- return (
-
- {/* time left */}
-
- 9:30
-
- {/* camera punch-hole (center) */}
-
- {/* status icons right */}
-
-
- );
-}
-
-// ─────────────────────────────────────────────────────────────
-// Top app bar (Material 3 small/medium)
-// ─────────────────────────────────────────────────────────────
-function AndroidAppBar({ title = 'Title', large = false }) {
- const iconDot = (
-
- );
- return (
-
-
- {iconDot}
- {!large && (
-
{title}
- )}
- {large &&
}
- {iconDot}
-
- {large && (
-
{title}
- )}
-
- );
-}
-
-// ─────────────────────────────────────────────────────────────
-// List item (Material 3)
-// ─────────────────────────────────────────────────────────────
-function AndroidListItem({ headline, supporting, leading }) {
- return (
-
- {leading && (
-
{leading}
- )}
-
-
{headline}
- {supporting && (
-
{supporting}
- )}
-
-
- );
-}
-
-// ─────────────────────────────────────────────────────────────
-// Gesture nav bar (pill)
-// ─────────────────────────────────────────────────────────────
-function AndroidNavBar({ dark = false }) {
- return (
-
- );
-}
-
-// ─────────────────────────────────────────────────────────────
-// Device frame — wraps everything
-// ─────────────────────────────────────────────────────────────
-function AndroidDevice({
- children, width = 412, height = 892, dark = false,
- title, large = false, keyboard = false, bg,
-}) {
- return (
- // data-om-starter: inert presence marker — Claude Design's starter-usage
- // probe reads it; it renders nothing. Keep it on this root element.
-
-
- {title !== undefined &&
}
-
- {children}
-
- {keyboard &&
}
-
-
- );
-}
-
-// ─────────────────────────────────────────────────────────────
-// Keyboard — Gboard (Material 3)
-// ─────────────────────────────────────────────────────────────
-function AndroidKeyboard() {
- let _k = 0;
- const key = (l, { flex = 1, bg = MD_C.surface, r = 6, minW, fs = 21 } = {}) => (
- {l}
- );
- const row = (keys, style = {}) => (
-
- {keys.map(l => key(l))}
-
- );
- return (
-
- {/* navbar spacer (icons omitted) */}
-
- {/* key rows */}
-
- {row(['q','w','e','r','t','y','u','i','o','p'])}
- {row(['a','s','d','f','g','h','j','k','l'], { padding: '0 20px' })}
-
- {key('', { bg: MD_C.surfaceVariant })}
-
- {['z','x','c','v','b','n','m'].map(l => key(l))}
-
- {key('', { bg: MD_C.surfaceVariant })}
-
-
- {key('?123', { bg: MD_C.secondaryContainer, r: 100, minW: 58, fs: 14 })}
- {key(',', { bg: MD_C.surfaceVariant })}
- {key('', { flex: 3, minW: 154 })}
- {key('.', { bg: MD_C.surfaceVariant })}
- {key('', { bg: MD_C.primaryFixedDim, r: 100, minW: 58 })}
-
-
-
- );
-}
-
-Object.assign(window, {
- AndroidDevice, AndroidStatusBar, AndroidAppBar, AndroidListItem, AndroidNavBar, AndroidKeyboard,
-});
diff --git a/docs/Разработка дизайна приложения/pipu-data.js b/docs/Разработка дизайна приложения/pipu-data.js
deleted file mode 100644
index deb213c..0000000
--- a/docs/Разработка дизайна приложения/pipu-data.js
+++ /dev/null
@@ -1,104 +0,0 @@
-(function () {
- const H = s => { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; };
- const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
- const fmt = s => { s = Math.max(0, Math.floor(s)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
- const plural = (n, f) => { const a = n % 10, b = n % 100; return f[(a === 1 && b !== 11) ? 0 : (a >= 2 && a <= 4 && (b < 10 || b >= 20)) ? 1 : 2]; };
-
- const AR = {
- kino: ['Кино', 'Русский рок', 0.62], zemfira: ['Земфира', 'Рок', 0.55], radiohead: ['Radiohead', 'Альтернатива', 0.45],
- massive: ['Massive Attack', 'Трип-хоп', 0.38], moderat: ['Moderat', 'Электроника', 0.66], bonobo: ['Bonobo', 'Даунтемпо', 0.48],
- daftpunk: ['Daft Punk', 'Электроника', 0.78], splean: ['Сплин', 'Русский рок', 0.56]
- };
- const AL = [
- ['rh-ir', 'In Rainbows', 'radiohead', 2007, '#F2A23A', '#1C3B6E', ['15 Step', 'Bodysnatchers', 'Nude', 'Weird Fishes/Arpeggi', 'All I Need', 'Faust Arp', 'Reckoner', 'House of Cards', 'Jigsaw Falling into Place', 'Videotape']],
- ['kino-gk', 'Группа крови', 'kino', 1988, '#C2412D', '#2A0E0A', ['Группа крови', 'Закрой за мной дверь, я ухожу', 'Война', 'Спокойная ночь', 'Мама, мы все сошли с ума', 'Бошетунмай', 'Попробуй спеть вместе со мной', 'Звезда']],
- ['kino-zs', 'Звезда по имени Солнце', 'kino', 1989, '#E39B2D', '#3A1A05', ['Звезда по имени Солнце', 'Песня без слов', 'Место для шага вперёд', 'Невесёлая песня', 'Апрель', 'Пачка сигарет']],
- ['zem-pm', 'Прости меня моя любовь', 'zemfira', 2000, '#D9467A', '#2B0B23', ['Хочешь?', 'Искала', 'Ромашки', 'Непошлое', 'Прости меня моя любовь', 'Город']],
- ['ma-mz', 'Mezzanine', 'massive', 1998, '#6A7385', '#0E1116', ['Angel', 'Risingson', 'Teardrop', 'Inertia Creeps', 'Exchange', 'Dissolved Girl']],
- ['mod-2', 'II', 'moderat', 2013, '#3A7BFF', '#0A1636', ['The Mark', 'Bad Kingdom', 'Versions', 'Let in the Light', 'Milk', 'Damage Done']],
- ['bon-mg', 'Migration', 'bonobo', 2017, '#2FB6A0', '#0B2730', ['Migration', 'Break Apart', 'Outlier', 'Kerala', 'Grains', 'No Reason']],
- ['dp-ram', 'Random Access Memories', 'daftpunk', 2013, '#A7AFBF', '#141821', ['Give Life Back to Music', 'The Game of Love', 'Instant Crush', 'Lose Yourself to Dance', 'Get Lucky', "Doin' It Right"]],
- ['spl-ga', 'Гранатовый альбом', 'splean', 1998, '#B4233A', '#1D0710', ['Орбит без сахара', 'Пластмассовая жизнь', 'Иди через лес', 'Феллини']]
- ];
- const ART = {}, ALB = {}, TR = {}, ALL = [];
- Object.keys(AR).forEach(k => { ART[k] = { id: k, name: AR[k][0], genre: AR[k][1], energy: AR[k][2], albums: [], tracks: [] }; });
- AL.forEach(([id, title, ar, year, c1, c2, list]) => {
- const cover = `radial-gradient(circle at 28% 22%, rgba(255,255,255,.32), rgba(255,255,255,0) 55%), linear-gradient(140deg, ${c1}, ${c2})`;
- ALB[id] = { id, title, artistId: ar, year, c1, c2, cover, tracks: [] };
- const A = ART[ar];
- A.albums.push(id);
- if (!A.c1) { A.c1 = c1; A.c2 = c2; A.avatar = `radial-gradient(circle at 35% 28%, ${c1}, ${c2} 78%)`; A.banner = `radial-gradient(ellipse at 20% 0%, ${c1}55, transparent 60%), linear-gradient(180deg, ${c2}cc, transparent)`; }
- list.forEach((t, i) => {
- const h = H(id + t), tid = id + '-' + i;
- TR[tid] = { id: tid, title: t, artistId: ar, albumId: id, n: i + 1, dur: 150 + h % 170, bpm: 72 + h % 74, energy: clamp(AR[ar][2] + ((h >> 3) % 40 - 20) / 100, 0.05, 0.98), pop: h % 100 };
- ALB[id].tracks.push(tid); A.tracks.push(tid); ALL.push(tid);
- });
- });
-
- const ACT = [['run', 'Бегаю', 'directions_run', 0.85], ['wake', 'Просыпаюсь', 'wb_twilight', 0.55], ['focus', 'Работаю', 'center_focus_strong', 0.4], ['gym', 'Тренировка', 'fitness_center', 0.92], ['road', 'В дороге', 'directions_car', 0.6], ['rest', 'Отдыхаю', 'weekend', 0.35], ['sleep', 'Засыпаю', 'bedtime', 0.15]];
- const MOOD = [['energy', 'Энергичное', 'bolt', 0.85, '#2F80FF', '#35D3FF', '#7CF0FF'], ['happy', 'Весёлое', 'sentiment_satisfied', 0.7, '#2F80FF', '#35D3FF', '#9B7CFF'], ['calm', 'Спокойное', 'spa', 0.3, '#2F80FF', '#6C7CFF', '#5AD1E6'], ['sad', 'Грустное', 'water_drop', 0.22, '#3B5BFF', '#7C5CFF', '#24317A']];
-
- function waveBatch(n, opts) {
- const { activity, mood, dislikes = [], likes = [], recent = [], artistId } = opts || {};
- const tg = [];
- if (activity) tg.push(ACT.find(a => a[0] === activity)[3]);
- if (mood) tg.push(MOOD.find(m => m[0] === mood)[3]);
- const target = tg.length ? tg.reduce((a, b) => a + b, 0) / tg.length : 0.55;
- return ALL.filter(id => !dislikes.includes(id) && !recent.includes(id))
- .map(id => {
- const t = TR[id];
- let sc = -Math.abs(t.energy - target) * 1.4 + (likes.includes(id) ? 0.2 : 0) + Math.random() * 0.35;
- if (artistId) sc += t.artistId === artistId ? 0.8 : (ART[t.artistId].genre === ART[artistId].genre ? 0.35 : 0);
- return [id, sc];
- })
- .sort((a, b) => b[1] - a[1]).slice(0, n).map(x => x[0]);
- }
-
- const EN = "qwertyuiop[]asdfghjkl;'zxcvbnm,.`", RU = 'йцукенгшщзхъфывапролджэячсмитьбюё';
- const swapLayout = q => q.split('').map(c => { const l = c.toLowerCase(); const i = EN.indexOf(l); if (i >= 0) return RU[i]; const j = RU.indexOf(l); if (j >= 0) return EN[j]; return c; }).join('');
- const TL = { а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'c', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya' };
- const norm = s => s.toLowerCase().replace(/ё/g, 'е').replace(/[^a-zа-я0-9 ]/g, ' ').replace(/\s+/g, ' ').trim();
- const tl = s => s.split('').map(c => TL[c] ?? c).join('');
- const lev = (a, b) => { const d = Array.from({ length: a.length + 1 }, (_, i) => [i]); for (let j = 1; j <= b.length; j++) d[0][j] = j; for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); return d[a.length][b.length]; };
- const m1 = (n, q) => { if (n.includes(q)) return true; if (q.length >= 4) return n.split(' ').some(w => lev(w.slice(0, q.length), q) <= 1) || lev(n.slice(0, q.length), q) <= 1; return false; };
- const match = (name, q) => { const n = norm(name), qq = norm(q); if (!qq) return false; return m1(n, qq) || m1(tl(n), tl(qq)); };
- function searchRaw(q) {
- const artists = Object.values(ART).filter(a => match(a.name, q));
- const albums = Object.values(ALB).filter(a => match(a.title, q) || match(ART[a.artistId].name, q));
- const tracks = ALL.filter(id => match(TR[id].title, q) || match(ART[TR[id].artistId].name, q)).sort((a, b) => TR[b].pop - TR[a].pop);
- return { artists, albums, tracks };
- }
- function search(q) {
- let r = searchRaw(q), fixed = null;
- if (q.trim() && !r.artists.length && !r.albums.length && !r.tracks.length) {
- const q2 = swapLayout(q), r2 = searchRaw(q2);
- if (r2.artists.length || r2.albums.length || r2.tracks.length) { r = r2; fixed = q2; }
- }
- return { ...r, fixed };
- }
-
- const sanitize = v => String(v ?? '').replace(/[\/\\:*?"<>|]/g, '_').replace(/[. ]+$/, '');
- function fillTpl(tpl, f) {
- let out = tpl.replace(/\[([^\]]*)\]/g, (m, inner) => [...inner.matchAll(/\{(\w+)(?::[^}]*)?\}/g)].every(x => f[x[1]]) ? inner : '');
- return out.replace(/\{(\w+)(?::([^}]*))?\}/g, (m, k, mod) => {
- let v = f[k]; if (k === 'albumartist' && !v) v = f.artist;
- v = v == null ? '' : (k === 'ext' ? v : sanitize(v));
- if (mod === '02') v = String(v).padStart(2, '0');
- if (mod === '1-') v = v ? v + '-' : '';
- return v;
- });
- }
- const GC = { artist: '#5E9BFF', albumartist: '#8FB4FF', album: '#A08BFF', year: '#FFB547', disc: '#FF8FB1', track: '#35D3FF', title: '#2FD39A', genre: '#FF9F5A', skip: '#FF5C70' };
- const GL = { artist: 'исполнитель', albumartist: 'исп. альбома', album: 'альбом', year: 'год', disc: 'диск', track: '№', title: 'название', genre: 'жанр', skip: 'пропуск' };
- function compile(p) { try { return new RegExp(p.replace(/\(\?P)/).filter(Boolean).map(t => { const m = t.match(/^\(\?P<(\w+)>$/); return { t, c: m ? (GC[m[1]] || 'var(--accent2)') : 'var(--text)', w: m ? '600' : '400' }; }); }
- function segments(path, m) {
- const g = m.indices && m.indices.groups ? Object.entries(m.indices.groups).filter(([, v]) => v).sort((a, b) => a[1][0] - b[1][0]) : [];
- const out = []; let i = 0;
- g.forEach(([k, [s, e]]) => { if (s > i) out.push({ t: path.slice(i, s), c: 'var(--text3)' }); out.push({ t: path.slice(s, e), c: GC[k] || 'var(--text)' }); i = e; });
- if (i < path.length) out.push({ t: path.slice(i), c: 'var(--text3)' });
- return out;
- }
-
- window.PIPU = { H, clamp, fmt, plural, ART, ALB, TR, ALL, ACT, MOOD, waveBatch, search, swapLayout, fillTpl, sanitize, compile, tokens, segments, GC, GL };
-})();
diff --git a/docs/Разработка дизайна приложения/support.js b/docs/Разработка дизайна приложения/support.js
deleted file mode 100644
index cb009b6..0000000
--- a/docs/Разработка дизайна приложения/support.js
+++ /dev/null
@@ -1,1911 +0,0 @@
-// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`.
-"use strict";
-(() => {
- var __defProp = Object.defineProperty;
- var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
- var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
-
- // src/react.ts
- function getReact() {
- const R = window.React;
- if (!R) throw new Error("dc-runtime: window.React is not available yet");
- return R;
- }
- function getReactDOM() {
- const RD = window.ReactDOM;
- if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet");
- return RD;
- }
- var h = ((...args) => getReact().createElement(
- ...args
- ));
-
- // src/parse.ts
- function parseDcDocument(doc) {
- const dc = doc.querySelector("x-dc");
- if (!dc) return null;
- const scriptEl = doc.querySelector("script[data-dc-script]");
- const { props, preview } = parseDataProps(
- scriptEl?.getAttribute("data-props") ?? null
- );
- return {
- template: dc.innerHTML,
- js: scriptEl ? scriptEl.textContent || "" : "",
- props,
- preview
- };
- }
- function parseDcText(src) {
- const openMatch = /]*)?>/.exec(src);
- if (!openMatch) return null;
- const close = src.lastIndexOf(" ");
- if (close === -1 || close < openMatch.index) return null;
- const template = src.slice(openMatch.index + openMatch[0].length, close);
- const doc = new DOMParser().parseFromString(src, "text/html");
- const scriptEl = doc.querySelector("script[data-dc-script]");
- const { props, preview } = parseDataProps(
- scriptEl?.getAttribute("data-props") ?? null
- );
- return {
- template,
- js: scriptEl ? scriptEl.textContent || "" : "",
- props,
- preview
- };
- }
- function parseDataProps(raw) {
- if (!raw) return { props: null, preview: null };
- let parsed;
- try {
- parsed = JSON.parse(raw);
- } catch {
- return { props: null, preview: null };
- }
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
- return { props: null, preview: null };
- }
- const obj = parsed;
- const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null;
- const rest = {};
- for (const k of Object.keys(obj)) {
- if (k[0] !== "$") rest[k] = obj[k];
- }
- return { props: Object.keys(rest).length ? rest : null, preview };
- }
- function dcNameFromPath(pathname) {
- let p = pathname || "";
- try {
- p = decodeURIComponent(p);
- } catch {
- }
- const base = p.split("/").pop() || "Root";
- return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root";
- }
-
- // src/boot.ts
- var BASE_CSS = `
- .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent);
- border:1px solid color-mix(in srgb,currentColor 50%,transparent);
- border-radius:2px;box-sizing:border-box;overflow:hidden}
- @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}}
- html.sc-dc-streaming .sc-placeholder,
- html.sc-dc-streaming .sc-interp.sc-missing{position:relative;
- background:color-mix(in srgb,currentColor 5%,transparent);
- border-color:transparent}
- html.sc-dc-streaming .sc-placeholder::before,
- html.sc-dc-streaming .sc-interp.sc-missing::before{content:'';
- position:absolute;inset:0;pointer-events:none;
- background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%);
- background-size:400% 100%;animation:sc-shine 1.4s ease infinite}
- html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before,
- html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none;
- background:color-mix(in srgb,currentColor 8%,transparent)}
- .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace;
- color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word}
- .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden;
- vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5);
- border-radius:2px;box-sizing:border-box;color:transparent;
- user-select:none}
- .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em;
- color:color-mix(in srgb,currentColor 50%,transparent);
- background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px;
- padding:0 3px}
- .sc-host.sc-has-error{position:relative}
- .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch;
- padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace;
- border-radius:4px;white-space:pre-wrap;pointer-events:none}
- /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both
- in sync until dc-runtime regains a build step. */
- @media print {
- @page { margin: 0.5cm; }
- figure, table { break-inside: avoid; }
- #dc-root, #dc-root > .sc-host { height: auto; }
- *, *::before, *::after {
- print-color-adjust: exact; -webkit-print-color-adjust: exact;
- backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
- animation-delay: -99s !important; animation-duration: .001s !important;
- animation-iteration-count: 1 !important; animation-fill-mode: both !important;
- animation-play-state: running !important; transition-duration: 0s !important;
- }
- }
- `;
- var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}";
- function rootNameForDocument(doc, loc) {
- let bootPath = loc.pathname || "";
- if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) {
- try {
- bootPath = new URL(doc.baseURI || "/").pathname;
- } catch {
- }
- }
- return dcNameFromPath(bootPath);
- }
- function safeDecode(s) {
- try {
- return decodeURIComponent(s);
- } catch {
- return s;
- }
- }
- function boot(runtime, doc = document) {
- const parsed = parseDcDocument(doc);
- if (!parsed) return null;
- const React = getReact();
- const rootName = rootNameForDocument(doc, location);
- runtime.markFetched(rootName);
- runtime.setRootName(rootName);
- runtime.adoptParsed(rootName, parsed);
- if (!window.__resources) {
- fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => {
- const raw = t ? parseDcText(t) : null;
- if (raw?.template) runtime.updateHtml(rootName, raw.template);
- }).catch(() => {
- });
- }
- const dc = doc.querySelector("x-dc");
- const hostEl = doc.createElement("div");
- hostEl.id = "dc-root";
- dc.replaceWith(hostEl);
- if (!parsed.preview) {
- const s = doc.createElement("style");
- s.textContent = FULL_PAGE_CSS;
- doc.head.appendChild(s);
- }
- const Root = runtime.getDC(rootName);
- const entry = runtime.registry.get(rootName);
- function StandaloneRoot() {
- const [, setTick] = React.useState(0);
- React.useEffect(() => {
- const sub = () => setTick((n) => n + 1);
- entry.subs.add(sub);
- return () => {
- entry.subs.delete(sub);
- };
- }, []);
- const defaults = React.useMemo(() => {
- const d = {};
- for (const k in entry.propsMeta || {}) {
- const v = entry.propsMeta?.[k]?.default;
- if (v !== void 0) d[k] = v;
- }
- return d;
- }, [entry.propsMeta]);
- return h(Root, { ...defaults, ...entry.propOverrides || {} });
- }
- const ReactDOM = getReactDOM();
- if (ReactDOM.createRoot)
- ReactDOM.createRoot(hostEl).render(h(StandaloneRoot));
- else ReactDOM.render(h(StandaloneRoot), hostEl);
- return rootName;
- }
-
- // src/expr.ts
- var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/;
- var NUMBER_RE = /^-?\d+(\.\d+)?$/;
- function resolve(vals, src) {
- const expr = String(src).trim();
- if (!expr) return void 0;
- if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) {
- return resolve(vals, expr.slice(1, -1));
- }
- const eq = findTopLevelEquality(expr);
- if (eq) {
- const lv = resolve(vals, expr.slice(0, eq.index));
- const rv = resolve(vals, expr.slice(eq.index + eq.op.length));
- switch (eq.op) {
- case "===":
- return lv === rv;
- case "!==":
- return lv !== rv;
- case "==":
- return lv == rv;
- default:
- return lv != rv;
- }
- }
- if (expr[0] === "!") return !resolve(vals, expr.slice(1));
- if (expr === "true") return true;
- if (expr === "false") return false;
- if (expr === "null") return null;
- if (expr === "undefined") return void 0;
- if (NUMBER_RE.test(expr)) return Number(expr);
- if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) {
- return expr.slice(1, -1);
- }
- return resolvePath(vals, expr);
- }
- function parensWrapWhole(expr) {
- let depth = 0;
- for (let i = 0; i < expr.length - 1; i++) {
- if (expr[i] === "(") depth++;
- else if (expr[i] === ")") {
- depth--;
- if (depth === 0) return false;
- }
- }
- return true;
- }
- function findTopLevelEquality(expr) {
- let depth = 0;
- for (let i = 0; i < expr.length; i++) {
- const c = expr[i];
- if (c === "[" || c === "(") depth++;
- else if (c === "]" || c === ")") depth--;
- else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") {
- if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue;
- if (!expr.slice(0, i).trim()) continue;
- const op = expr[i + 2] === "=" ? c + "==" : c + "=";
- return { index: i, op };
- }
- }
- return null;
- }
- function resolvePath(vals, expr) {
- const head = expr.match(IDENT_RE);
- if (!head) return void 0;
- let cur = vals == null ? void 0 : vals[head[0]];
- let i = head[0].length;
- while (i < expr.length) {
- if (expr[i] === ".") {
- const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/);
- if (!m) return void 0;
- cur = cur == null ? void 0 : cur[m[0]];
- i += 1 + m[0].length;
- } else if (expr[i] === "[") {
- let depth = 1;
- let j = i + 1;
- while (j < expr.length && depth > 0) {
- if (expr[j] === "[") depth++;
- else if (expr[j] === "]") {
- depth--;
- if (depth === 0) break;
- }
- j++;
- }
- if (depth !== 0) return void 0;
- const key = resolve(vals, expr.slice(i + 1, j));
- cur = cur == null ? void 0 : cur[key];
- i = j + 1;
- } else {
- return void 0;
- }
- }
- return cur;
- }
-
- // src/encode.ts
- var CAMEL_ATTR = "sc-camel-";
- var INLINE_TEXT_TAGS = new Set(
- "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split(
- " "
- )
- );
- var RAW_WRAP = {
- select: "sc-raw-select",
- table: "sc-raw-table",
- tbody: "sc-raw-tbody",
- thead: "sc-raw-thead",
- tfoot: "sc-raw-tfoot",
- tr: "sc-raw-tr",
- td: "sc-raw-td",
- th: "sc-raw-th",
- caption: "sc-raw-caption"
- };
- var RAW_UNWRAP = Object.fromEntries(
- Object.entries(RAW_WRAP).map(([k, v]) => [v, k])
- );
- var EVENT_MAP = {
- onclick: "onClick",
- onchange: "onChange",
- oninput: "onInput",
- onsubmit: "onSubmit",
- onkeydown: "onKeyDown",
- onkeyup: "onKeyUp",
- onkeypress: "onKeyPress",
- onmousedown: "onMouseDown",
- onmouseup: "onMouseUp",
- onmouseenter: "onMouseEnter",
- onmouseleave: "onMouseLeave",
- onfocus: "onFocus",
- onblur: "onBlur",
- ondoubleclick: "onDoubleClick",
- oncontextmenu: "onContextMenu",
- onmousemove: "onMouseMove",
- onmouseover: "onMouseOver",
- onmouseout: "onMouseOut",
- onpointerdown: "onPointerDown",
- onpointerup: "onPointerUp",
- onpointermove: "onPointerMove",
- onpointerenter: "onPointerEnter",
- onpointerleave: "onPointerLeave",
- onpointercancel: "onPointerCancel",
- onpointerover: "onPointerOver",
- onpointerout: "onPointerOut",
- ongotpointercapture: "onGotPointerCapture",
- onlostpointercapture: "onLostPointerCapture",
- ontouchstart: "onTouchStart",
- ontouchend: "onTouchEnd",
- ontouchmove: "onTouchMove",
- ontouchcancel: "onTouchCancel",
- ondragstart: "onDragStart",
- ondragend: "onDragEnd",
- ondragenter: "onDragEnter",
- ondragleave: "onDragLeave",
- ondragover: "onDragOver",
- onanimationstart: "onAnimationStart",
- onanimationend: "onAnimationEnd",
- onanimationiteration: "onAnimationIteration",
- ontransitionend: "onTransitionEnd"
- };
- var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`;
- var IMPORT_SELF_CLOSE_RE = new RegExp(
- "<(x-import|dc-import)(" + ATTRS + ")/>",
- "gi"
- );
- var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g;
- function encodeCamelAttrs(html) {
- return html.replace(
- CAMEL_ATTR_RE,
- (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq
- );
- }
- function encodeCase(html) {
- html = html.replace(
- IMPORT_SELF_CLOSE_RE,
- (_, t, a) => "<" + t + a + ">" + t + ">"
- );
- html = html.replace(/)/gi, "/gi, " ");
- html = encodeCamelAttrs(html);
- for (const [real, alias] of Object.entries(RAW_WRAP)) {
- html = html.replace(
- new RegExp("(?)" + real + "(?=[\\s>])", "gi"),
- "$1" + alias
- );
- }
- return html;
- }
- function kebabToCamel(s) {
- return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
- }
- function cssToObj(css) {
- const o = {};
- for (const decl of css.split(";")) {
- const i = decl.indexOf(":");
- if (i < 0) continue;
- const prop = decl.slice(0, i).trim();
- o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim();
- }
- return o;
- }
- function compileAttr(raw) {
- const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/);
- if (whole) {
- const path = whole[1];
- return (vals) => resolve(vals, path);
- }
- if (raw.includes("{{")) {
- const parts = raw.split(/\{\{([\s\S]+?)\}\}/g);
- return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join("");
- }
- return () => raw;
- }
-
- // src/compile.ts
- function collectProps(node, kind, host) {
- const propGetters = [];
- const pseudoClasses = [];
- let hintSize = null;
- for (const { name, value } of [...node.attributes]) {
- if (name === "sc-name" || name === "data-dc-tpl") continue;
- let key = name;
- if (key.startsWith(CAMEL_ATTR))
- key = kebabToCamel(key.slice(CAMEL_ATTR.length));
- if (key === "hint-size") {
- hintSize = value;
- continue;
- }
- if (key.startsWith("style-")) {
- pseudoClasses.push(host.pseudoClass(key.slice(6), value));
- continue;
- }
- if (kind !== "dom") {
- if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-"))))
- key = kebabToCamel(key);
- } else {
- if (key === "class") key = "className";
- else if (key === "for") key = "htmlFor";
- else if (key.startsWith("on"))
- key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3);
- }
- propGetters.push([key, compileAttr(value)]);
- }
- return { propGetters, pseudoClasses, hintSize };
- }
- var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([
- "position",
- "left",
- "right",
- "top",
- "bottom",
- "inset",
- "width",
- "height",
- "z-index",
- "transform"
- ]);
- function hostPositionStyle(style) {
- const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null;
- if (!all) return void 0;
- const out = {};
- for (const [k, v] of Object.entries(all)) {
- const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
- if (HOST_STYLE_PROPS.has(kebab)) out[k] = v;
- }
- return Object.keys(out).length ? out : void 0;
- }
- function compileTemplate(html, host) {
- const tpl = document.createElement("template");
- //! nosemgrep: direct-inner-html-assignment
- tpl.innerHTML = encodeCase(html);
- let tplN = 0;
- (function stamp(node) {
- if (node.nodeType === Node.ELEMENT_NODE) {
- node.setAttribute("data-dc-tpl", String(tplN++));
- }
- for (const c of node.childNodes) stamp(c);
- })(tpl.content);
- const builders = walkChildren(tpl.content, host);
- const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i)));
- render.__annotated = tpl.innerHTML;
- return render;
- }
- function walkChildren(node, host) {
- return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null);
- }
- var SLIDE_ID_VALUE_RE = /^[0-9a-f]{8}$/;
- var DECK_CONTROL_FLOW_RE = /^(sc-if|sc-for|sc-else|dc-import|x-import)$/;
- var DECK_AUX_RE = /^(template|script|style|sc-helmet|helmet)$/;
- function isDeckMountTag(el) {
- if (el.localName === "deck-stage") return true;
- return el.localName === "x-import" && (el.getAttribute("component-from-global-scope") || "") === "deck-stage";
- }
- function walkDeckChildren(el, host) {
- const pairs = [...el.childNodes].map((c) => ({ c, b: walk(c, host) })).filter((p) => p.b !== null);
- const kids = pairs.map((p) => p.b);
- const seen = /* @__PURE__ */ new Set();
- const wsSeen = /* @__PURE__ */ new Map();
- const keys = [];
- const nextSlideId = new Array(pairs.length);
- {
- let upcoming = null;
- for (let j = pairs.length - 1; j >= 0; j--) {
- const n = pairs[j].c;
- if (n.nodeType === Node.ELEMENT_NODE) {
- const t = n.localName;
- upcoming = !DECK_AUX_RE.test(t) && !DECK_CONTROL_FLOW_RE.test(t) ? n.getAttribute("data-om-slide-id") : null;
- }
- nextSlideId[j] = upcoming;
- }
- }
- for (let j = 0; j < pairs.length; j++) {
- const { c } = pairs[j];
- if (c.nodeType === Node.TEXT_NODE) {
- if ((c.nodeValue ?? "").trim() === "") {
- const base = nextSlideId[j] ? "omid-ws:" + nextSlideId[j] : "omid-ws:aux";
- const n = wsSeen.get(base) ?? 0;
- wsSeen.set(base, n + 1);
- keys.push(n === 0 ? base : base + ":" + n);
- continue;
- }
- return { kids, keys: null };
- }
- if (c.nodeType !== Node.ELEMENT_NODE) {
- keys.push(j);
- continue;
- }
- const child = c;
- const tag = child.localName;
- if (DECK_AUX_RE.test(tag)) {
- keys.push(j);
- continue;
- }
- if (DECK_CONTROL_FLOW_RE.test(tag)) return { kids, keys: null };
- const v = child.getAttribute("data-om-slide-id");
- if (!v || !SLIDE_ID_VALUE_RE.test(v) || seen.has(v)) {
- return { kids, keys: null };
- }
- seen.add(v);
- keys.push("omid:" + v);
- }
- return { kids, keys };
- }
- function renderDeckKids(kids, kidKeys, vals, ctx) {
- return kids.map((b, j) => {
- const k = kidKeys ? kidKeys[j] : j;
- const out = b(vals, ctx, k);
- return kidKeys != null && typeof out === "string" ? h(getReact().Fragment, { key: k }, out) : out;
- });
- }
- function walk(node, host) {
- if (node.nodeType === Node.TEXT_NODE) return walkText(node);
- if (node.nodeType !== Node.ELEMENT_NODE) return null;
- const el = node;
- const tag = el.tagName.toLowerCase();
- if (tag === "sc-for") return walkFor(el, host);
- if (tag === "sc-if") return walkIf(el, host);
- if (tag === "x-import") return walkXImport(el, host);
- if (tag === "sc-helmet") return host.helmet(el);
- if (tag === "dc-import") return walkComponent(el, host);
- return walkElement(el, host);
- }
- var warnedHoles = /* @__PURE__ */ new Set();
- function warnUnresolved(ctx, what) {
- const key = (ctx?.__name || "?") + "\0" + what;
- if (warnedHoles.has(key)) return;
- warnedHoles.add(key);
- console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what);
- }
- function walkText(node) {
- const txt = node.nodeValue ?? "";
- if (!txt.includes("{{")) {
- if (!txt.trim() && !txt.includes(" ")) return null;
- return () => txt;
- }
- const parts = txt.split(/\{\{([\s\S]+?)\}\}/g);
- return (vals, ctx, key) => h(
- getReact().Fragment,
- { key },
- ...parts.map((p, i) => {
- if (!(i & 1)) return p;
- const v = resolve(vals, p);
- if (v === void 0) {
- if (!ctx?.__streamingNow) {
- if (document.body?.hasAttribute("data-dc-editor-on")) {
- return h(
- "span",
- { key: i, className: "sc-interp sc-unresolved" },
- "{{ " + p.trim() + " }}"
- );
- }
- warnUnresolved(
- ctx,
- "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty"
- );
- return null;
- }
- return h(
- "span",
- { key: i, className: "sc-interp sc-missing" },
- p.trim()
- );
- }
- if (getReact().isValidElement(v) || Array.isArray(v)) {
- return h(getReact().Fragment, { key: i }, v);
- }
- if (v === null || typeof v === "boolean") return null;
- return h("span", { key: i, className: "sc-interp" }, String(v));
- })
- );
- }
- function walkFor(el, host) {
- const listGet = compileAttr(el.getAttribute("list") || "");
- const asName = el.getAttribute("as") || "item";
- const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10);
- const kids = walkChildren(el, host);
- const listSrc = el.getAttribute("list") || "";
- return (vals, ctx, key) => {
- let list = listGet(vals);
- if (!Array.isArray(list)) {
- if (!ctx?.__streamingNow) {
- if (list !== void 0 && list !== null) {
- warnUnresolved(
- ctx,
- 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")"
- );
- }
- list = [];
- } else {
- list = hintN > 0 ? Array(hintN).fill(void 0) : [];
- }
- }
- return h(
- getReact().Fragment,
- { key },
- list.map((item, i) => {
- const sub = { ...vals, [asName]: item, $index: i };
- return h(
- getReact().Fragment,
- { key: i },
- kids.map((b, j) => b(sub, ctx, j))
- );
- })
- );
- };
- }
- function walkIf(el, host) {
- const valGet = compileAttr(el.getAttribute("value") || "");
- const hintRaw = el.getAttribute("hint-placeholder-val");
- const hintGet = hintRaw != null ? compileAttr(hintRaw) : null;
- const kids = walkChildren(el, host);
- return (vals, ctx, key) => {
- let v = valGet(vals);
- if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals);
- return v ? h(
- getReact().Fragment,
- { key },
- kids.map((b, j) => b(vals, ctx, j))
- ) : null;
- };
- }
- function walkComponent(el, host) {
- const name = el.getAttribute("name") || el.getAttribute("component") || "";
- el.removeAttribute("name");
- el.removeAttribute("component");
- const tplId = el.getAttribute("data-dc-tpl");
- const styleRaw = el.getAttribute("style");
- el.removeAttribute("style");
- const styleGet = styleRaw != null ? compileAttr(styleRaw) : null;
- const { propGetters, hintSize } = collectProps(el, "dc-import", host);
- const kids = walkChildren(el, host);
- return (vals, ctx, key) => {
- const props = {
- key,
- __hintSize: hintSize,
- __tplId: tplId,
- __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0
- };
- for (const [k, g] of propGetters) {
- const v = g(vals);
- if (k === "dcProps") {
- if (v && typeof v === "object") Object.assign(props, v);
- continue;
- }
- props[k] = v;
- }
- if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j));
- return h(host.component(name), props);
- };
- }
- function walkXImport(el, host) {
- const globalNameGet = compileAttr(
- el.getAttribute("component-from-global-scope") || ""
- );
- const exportNameGet = compileAttr(
- el.getAttribute("component") || el.getAttribute("name") || ""
- );
- const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || "");
- const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : [];
- const url = urls.length ? urls[urls.length - 1] : "";
- const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js";
- const tplId = el.getAttribute("data-dc-tpl");
- const styleRaw = el.getAttribute("style");
- el.removeAttribute("style");
- const styleGet = styleRaw != null ? compileAttr(styleRaw) : null;
- const wrap = tplId != null || styleGet != null;
- const { propGetters, hintSize } = collectProps(el, "x-import", host);
- const hasContent = el.children.length > 0 || !!(el.textContent || "").trim();
- const deckKeyed = hasContent && isDeckMountTag(el) ? walkDeckChildren(el, host) : null;
- const kids = deckKeyed ? deckKeyed.kids : hasContent ? walkChildren(el, host) : [];
- const kidKeys = deckKeyed?.keys ?? null;
- const urlBindable = fromRaw.includes("{{");
- if (urls.length && !urlBindable) {
- let prev;
- for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev);
- }
- const evalName = (g, vals) => {
- const v = g(vals);
- const s = v == null ? "" : String(v);
- return s.includes("{{") ? "" : s;
- };
- return (vals, ctx, key) => {
- const globalName = evalName(globalNameGet, vals);
- const name = globalName || evalName(exportNameGet, vals);
- const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name);
- const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0;
- const wrapper = wrap ? {
- key,
- className: "sc-host-x",
- "data-dc-tpl": tplId,
- style: hostStyle || { display: "contents" }
- } : null;
- if (!C) {
- const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name);
- const ph = host.placeholder({
- key: wrapper ? void 0 : key,
- name,
- hintSize,
- error
- });
- return wrapper ? h("div", wrapper, ph) : ph;
- }
- const props = wrapper ? {} : { key };
- let unresolvedHole = false;
- for (const [k, g] of propGetters) {
- if (k === "component" || k === "componentFromGlobalScope" || k === "from") {
- continue;
- }
- const v = g(vals);
- if (v === void 0) unresolvedHole = true;
- if (k === "dcProps") {
- if (v && typeof v === "object") Object.assign(props, v);
- continue;
- }
- props[k] = v;
- }
- if (unresolvedHole && ctx?.__htmlStreamingNow) {
- const ph = host.placeholder({
- key: wrapper ? void 0 : key,
- name,
- hintSize,
- error: null
- });
- return wrapper ? h("div", wrapper, ph) : ph;
- }
- if (kids.length) {
- props.children = renderDeckKids(kids, kidKeys, vals, ctx);
- }
- return wrapper ? h("div", wrapper, h(C, props)) : h(C, props);
- };
- }
- function contentKey(el) {
- const clone = el.cloneNode(true);
- for (const d of clone.querySelectorAll("*")) {
- while (d.attributes.length) d.removeAttribute(d.attributes[0].name);
- }
- const s = clone.innerHTML;
- let h2 = 5381;
- for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0;
- return s.length + "." + (h2 >>> 0).toString(36);
- }
- var NEVER_CONTENT_KEYED = new Set(
- "script style textarea option title select canvas iframe video audio".split(
- " "
- )
- );
- var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")";
- function walkElement(el, host) {
- const realTag = RAW_UNWRAP[el.localName] || el.localName;
- const tplId = el.getAttribute("data-dc-tpl");
- const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null;
- const keySuffix = inlineOnly ? "|" + contentKey(el) : "";
- const { propGetters, pseudoClasses } = collectProps(el, "dom", host);
- const deckKeyed = isDeckMountTag(el) ? walkDeckChildren(el, host) : null;
- const kids = deckKeyed ? deckKeyed.kids : walkChildren(el, host);
- const kidKeys = deckKeyed?.keys ?? null;
- return (vals, ctx, key) => {
- const props = {
- key: key + keySuffix,
- "data-dc-tpl": tplId
- };
- for (const [k, g] of propGetters) {
- let v = g(vals);
- if (k === "style" && typeof v === "string") v = cssToObj(v);
- if ((k === "value" || k === "checked") && v === void 0) {
- v = k === "checked" ? false : "";
- }
- props[k] = v;
- }
- if (pseudoClasses.length) {
- props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" ");
- }
- return h(realTag, props, ...renderDeckKids(kids, kidKeys, vals, ctx));
- };
- }
-
- // src/logic.ts
- var StreamableLogic = class {
- constructor(props) {
- __publicField(this, "props");
- __publicField(this, "state", {});
- /** Back-pointer to the wrapper component, installed after construction. */
- __publicField(this, "__host");
- this.props = props || {};
- }
- setState(update, cb) {
- this.__host && this.__host.__setLogicState(update, cb);
- }
- forceUpdate() {
- this.__host && this.__host.forceUpdate();
- }
- componentDidMount() {
- }
- componentDidUpdate(_prevProps) {
- }
- componentWillUnmount() {
- }
- /** The flat object the template renders against (merged over props). */
- renderVals() {
- return {};
- }
- };
- function evalDcLogic(src) {
- //! nosemgrep: eval-and-function-constructor
- const fn = new Function(
- "DCLogic",
- "StreamableLogic",
- "React",
- src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;'
- );
- return fn(StreamableLogic, StreamableLogic, getReact());
- }
-
- // src/component.ts
- function shallowEqual(a, b) {
- if (!b) return false;
- const ak = Object.keys(a).filter((k) => k !== "children");
- const bk = Object.keys(b).filter((k) => k !== "children");
- if (ak.length !== bk.length) return false;
- for (const k of ak) if (a[k] !== b[k]) return false;
- return true;
- }
- function Placeholder({
- name,
- hintSize,
- streaming,
- error
- }) {
- const [w, hgt] = (hintSize || "100%,60px").split(",");
- return h(
- "div",
- {
- className: "sc-placeholder" + (streaming ? " sc-streaming" : ""),
- style: { width: w.trim(), height: hgt && hgt.trim() },
- title: name
- },
- error ? h(
- "div",
- { className: "sc-placeholder-error" },
- (name ? name + ": " : "") + error
- ) : null
- );
- }
- function hintToMin(hint) {
- if (!hint) return void 0;
- const [w, hgt] = hint.split(",");
- return { minWidth: w.trim(), minHeight: hgt && hgt.trim() };
- }
- function createComponentFactory(registry, ensureFetched) {
- const React = getReact();
- const AncestorContext = React.createContext([]);
- class StreamableComponent extends React.Component {
- constructor(props) {
- super(props);
- __publicField(this, "__name");
- __publicField(this, "__sub");
- __publicField(this, "__needsDidMount", false);
- /** Snapshot of the registry's streaming flags taken at render time —
- * builders read it off the RenderCtx (this) to pick placeholder vs
- * render-nothing for unresolved values. */
- __publicField(this, "__streamingNow", false);
- __publicField(this, "__htmlStreamingNow", false);
- /** When a construct throws, remember the (class, registry.ver, props)
- * triple so render-time reconcile doesn't re-attempt it on every parent
- * re-render. A registry bump (new class, template, external module
- * resolving via bumpAll) changes `ver` and breaks the memo so an
- * env-dependent constructor can self-heal. */
- __publicField(this, "__failedLogic", null);
- __publicField(this, "__failedUserProps", null);
- __publicField(this, "__failedVer", -1);
- /** Per-instance constructor error — kept here (not on the registry entry)
- * so one instance's successful construct can't hide a sibling's failure,
- * and a construct can never wipe an eval error `updateJs` recorded on
- * `r.logicError`. */
- __publicField(this, "__ctorError", null);
- __publicField(this, "logic");
- this.__name = props.__name;
- this.state = { __v: 0, __err: null };
- this.__sub = () => {
- if (this.state.__err) this.setState({ __err: null });
- this.forceUpdate();
- };
- this.__makeLogic(registry.get(this.__name).Logic, null);
- ensureFetched(this.__name);
- }
- /** Error-boundary hook: a render crash anywhere in this DC's subtree
- * (its own template, an x-import'd component, a child DC without its
- * own deeper boundary) lands here instead of unmounting the page. */
- static getDerivedStateFromError(e) {
- return { __err: e instanceof Error && e.message ? e.message : String(e) };
- }
- componentDidCatch(e, info) {
- console.error(
- "[dc-runtime] render error in <" + this.__name + ">:",
- e,
- info?.componentStack || ""
- );
- }
- /** Instantiate the logic class (or the no-op base) and adopt `prevState`
- * over its initial state — used both at mount and on hot-swap. */
- __makeLogic(Logic, prevState) {
- const L = Logic || StreamableLogic;
- try {
- this.logic = new L(this.__userProps());
- this.__failedLogic = null;
- this.__failedUserProps = null;
- this.__ctorError = null;
- } catch (e) {
- console.error(e);
- this.__failedLogic = Logic;
- this.__failedUserProps = this.__userProps();
- this.__failedVer = registry.get(this.__name).ver;
- this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e));
- this.logic = new StreamableLogic(
- this.__userProps()
- );
- }
- this.logic.__host = this;
- if (prevState)
- this.logic.state = { ...this.logic.state || {}, ...prevState };
- }
- /** The props the author's logic + template see — internal __-prefixed
- * wiring stripped. */
- __userProps() {
- const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props;
- return rest;
- }
- __setLogicState(update, cb) {
- const prev = this.logic.state;
- const patch = typeof update === "function" ? update(prev) : update;
- this.logic.state = { ...prev, ...patch };
- this.setState((s) => ({ __v: s.__v + 1 }), cb);
- }
- /** Swap the logic instance when the registry's Logic class changed
- * (streaming completion, hot reload). State carries over; didMount
- * re-fires after the swap commits so refs exist. */
- __reconcileLogic() {
- const r = registry.get(this.__name);
- const Next = r.Logic;
- const Cur = this.logic.constructor;
- if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) {
- return;
- }
- if (!this.__needsDidMount) {
- try {
- this.logic.componentWillUnmount();
- } catch (e) {
- console.error(e);
- }
- }
- this.__makeLogic(Next, this.logic.state);
- this.__needsDidMount = true;
- }
- componentDidMount() {
- registry.get(this.__name).subs.add(this.__sub);
- try {
- this.logic.componentDidMount();
- } catch (e) {
- console.error(e);
- }
- }
- componentDidUpdate(prevProps) {
- this.logic.props = this.__userProps();
- if (this.__needsDidMount) {
- if (this.state.__err || !registry.get(this.__name).tpl) return;
- this.__needsDidMount = false;
- try {
- this.logic.componentDidMount();
- } catch (e) {
- console.error(e);
- }
- } else {
- try {
- this.logic.componentDidUpdate(prevProps);
- } catch (e) {
- console.error(e);
- }
- }
- }
- componentWillUnmount() {
- registry.get(this.__name).subs.delete(this.__sub);
- if (!this.__needsDidMount) {
- try {
- this.logic.componentWillUnmount();
- } catch (e) {
- console.error(e);
- }
- }
- }
- render() {
- const r = registry.get(this.__name);
- const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : "");
- const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0;
- const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0;
- const hostBase = {
- className: cls,
- style: hostStyle,
- "data-sc-name": this.__name,
- "data-dc-tpl": this.props.__tplId
- };
- const chain = Array.isArray(this.context) ? this.context : [];
- if (chain.includes(this.__name)) {
- const cycle = [
- ...chain.slice(chain.indexOf(this.__name)),
- this.__name
- ].join(" \u2192 ");
- return h(
- "div",
- { ...hostBase, className: cls + " sc-has-error" },
- h(Placeholder, {
- name: this.__name,
- hintSize: this.props.__hintSize,
- error: "circular import: " + cycle
- })
- );
- }
- if (this.state.__err) {
- return h(
- "div",
- { ...hostBase, className: cls + " sc-has-error" },
- h(
- "div",
- { className: "sc-logic-error", "data-omelette-chrome": "" },
- this.__name + ": " + this.state.__err
- ),
- h(Placeholder, {
- name: this.__name,
- hintSize: this.props.__hintSize,
- error: this.state.__err
- })
- );
- }
- this.__reconcileLogic();
- if (!r.tpl) {
- return h(
- "div",
- hostBase,
- h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize })
- );
- }
- const userProps = this.__userProps();
- this.logic.props = userProps;
- let vals = userProps;
- let renderErr = r.logicError || this.__ctorError;
- try {
- vals = { ...userProps, ...this.logic.renderVals() || {} };
- } catch (e) {
- console.error(e);
- renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e));
- }
- this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming);
- this.__htmlStreamingNow = !!r.htmlStreaming;
- return h(
- "div",
- { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") },
- renderErr && h(
- "div",
- { className: "sc-logic-error", "data-omelette-chrome": "" },
- renderErr
- ),
- h(
- AncestorContext.Provider,
- { value: [...chain, this.__name] },
- r.tpl(vals, this)
- )
- );
- }
- }
- __publicField(StreamableComponent, "contextType", AncestorContext);
- const named = /* @__PURE__ */ new Map();
- function getDC(name) {
- const hit = named.get(name);
- if (hit) return hit;
- function Dispatcher(p) {
- const [, setTick] = React.useState(0);
- React.useEffect(() => {
- const sub = () => setTick((n) => n + 1);
- registry.get(name).subs.add(sub);
- return () => {
- registry.get(name).subs.delete(sub);
- };
- }, []);
- ensureFetched(name);
- return h(StreamableComponent, { ...p, __name: name });
- }
- Dispatcher.displayName = name;
- named.set(name, Dispatcher);
- return Dispatcher;
- }
- return {
- getDC,
- StreamableComponent
- };
- }
-
- // src/bundled.ts
- function bundledBlob(url) {
- const blobs = window.__resourceBlobs;
- const b = blobs ? blobs[url.split("#")[0]] : void 0;
- return b instanceof Blob ? b : null;
- }
-
- // src/cdn.ts
- var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js";
- var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z";
- var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js";
- var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1";
- var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js";
- var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y";
- function cdnScriptFor(url, sri) {
- const res = window.__resources;
- const v = res ? res[url] : void 0;
- return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri };
- }
-
- // src/external.ts
- var isCustomElementName = (n) => !n.includes(".") && n.includes("-");
- function isRenderableType(g) {
- if (typeof g === "function") return !isElementClass(g);
- return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol";
- }
- function resolveDottedPath(root, name) {
- let cur = root;
- for (const seg of name.split(".")) {
- if (cur == null) return void 0;
- cur = cur[seg];
- }
- return cur;
- }
- var GLOBAL_POLL_INTERVAL_MS = 50;
- var GLOBAL_POLL_TIMEOUT_MS = 3e4;
- function createExternalModules(onResolved) {
- const cache = /* @__PURE__ */ new Map();
- let babelLoading = null;
- const reportedMissing = /* @__PURE__ */ new Map();
- const polling = /* @__PURE__ */ new Set();
- function ensureBabel() {
- if (window.Babel) return Promise.resolve();
- if (babelLoading) return babelLoading;
- const babel = cdnScriptFor(BABEL_URL, BABEL_SRI);
- babelLoading = new Promise((res, rej) => {
- const s = document.createElement("script");
- s.src = babel.src;
- if (babel.integrity) {
- s.integrity = babel.integrity;
- s.crossOrigin = "anonymous";
- }
- s.onload = () => res();
- s.onerror = rej;
- document.head.appendChild(s);
- });
- return babelLoading;
- }
- const pending = /* @__PURE__ */ new Map();
- function load(kind, url, after) {
- const existing = pending.get(url);
- if (existing) return existing;
- cache.set(url, null);
- console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")");
- const ready = Promise.all([
- kind === "jsx" ? ensureBabel() : Promise.resolve(),
- after ?? Promise.resolve()
- ]);
- const p = ready.then(() => {
- const pre = bundledBlob(url);
- if (pre) return pre.text();
- return fetch(url).then((r) => {
- if (!r.ok) throw new Error("HTTP " + r.status);
- return r.text();
- });
- }).then((src) => {
- const code = kind === "jsx" ? window.Babel.transform(src, {
- filename: url,
- presets: ["react", "typescript"]
- }).code : src;
- const module = { exports: {} };
- const before = new Set(Object.keys(window));
- //! nosemgrep: eval-and-function-constructor
- new Function("React", "module", "exports", "require", code)(
- getReact(),
- module,
- module.exports,
- () => ({})
- );
- const globals = {};
- for (const k of Object.keys(window)) {
- if (!before.has(k) && typeof window[k] === "function") {
- globals[k] = window[k];
- }
- }
- cache.set(url, { mod: module.exports, globals });
- console.info(
- "[dc-runtime] x-import: loaded",
- url,
- "\u2014 exports:",
- Object.keys(module.exports),
- "window globals:",
- Object.keys(globals)
- );
- onResolved();
- }).catch((e) => {
- cache.set(url, {
- mod: {},
- globals: {},
- error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e))
- });
- console.error(
- "[dc-runtime] x-import: FAILED to load",
- url,
- "(" + kind + ")",
- e
- );
- onResolved();
- });
- pending.set(url, p);
- return p;
- }
- function resolve2(url, name) {
- const entry = cache.get(url);
- if (!entry) return null;
- const { mod, globals } = entry;
- const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default;
- if (typeof C === "function") return C;
- const key = url + "\0" + name;
- if (!reportedMissing.has(key)) {
- reportedMissing.set(
- key,
- entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")"
- );
- console.error(
- "[dc-runtime] x-import: module",
- url,
- "loaded but has no component named",
- JSON.stringify(name),
- "\u2014 available exports:",
- Object.keys(mod),
- "window globals:",
- Object.keys(globals),
- ". The module must `module.exports = {" + name + "}` or set `window." + name + "`."
- );
- }
- return null;
- }
- function waitForGlobal(name) {
- if (polling.has(name)) return;
- polling.add(name);
- const started = Date.now();
- const isCE = isCustomElementName(name);
- const tick = () => {
- const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name));
- if (found) {
- polling.delete(name);
- onResolved();
- return;
- }
- if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) {
- console.warn(
- "[dc-runtime] x-import: global",
- JSON.stringify(name),
- "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms"
- );
- return;
- }
- setTimeout(tick, GLOBAL_POLL_INTERVAL_MS);
- };
- setTimeout(tick, GLOBAL_POLL_INTERVAL_MS);
- }
- function resolveGlobal(url, name) {
- const isCE = isCustomElementName(name);
- if (!url) {
- if (isCE) {
- if (customElements.get(name)) return name;
- waitForGlobal(name);
- return null;
- }
- const g2 = resolveDottedPath(window, name);
- if (isRenderableType(g2)) return g2;
- waitForGlobal(name);
- return null;
- }
- const entry = cache.get(url);
- if (!entry) return null;
- if (isCE && customElements.get(name)) return name;
- const g = entry.globals[name] ?? resolveDottedPath(window, name);
- if (isRenderableType(g)) return g;
- if (name.includes(".")) return null;
- const key = url + "\0global\0" + name;
- if (!reportedMissing.has(key)) {
- reportedMissing.set(key, null);
- if (isCE && !customElements.get(name)) {
- console.warn(
- "[dc-runtime] x-import:",
- url,
- "loaded but no custom element",
- JSON.stringify(name),
- "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element."
- );
- }
- }
- return name;
- }
- function getError(url, name) {
- const entry = cache.get(url);
- if (entry?.error) return entry.error;
- return reportedMissing.get(url + "\0" + name) || null;
- }
- return { load, resolve: resolve2, resolveGlobal, getError };
- }
- function isElementClass(g) {
- try {
- return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement;
- } catch {
- return false;
- }
- }
-
- // src/atomics.ts
- var ATOMIC_CSS = (
- // layout
- ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}"
- );
-
- // src/helmet.ts
- var DESIGN_DOC_MODE_RE = / ]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i;
- var CANVAS_BG_LIGHT = "#f0eee6";
- var CANVAS_BG_DARK = "#2e2c26";
- function createHelmetManager(doc, isStreaming) {
- const mounted = /* @__PURE__ */ new Set();
- const live = /* @__PURE__ */ new Map();
- let designDocMode = null;
- let canvasStyleEl = null;
- let appTheme = "light";
- try {
- const ds = doc.documentElement.dataset.theme;
- appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get(
- "theme"
- ) === "dark" ? "dark" : "light";
- } catch {
- }
- function applyCanvasBg() {
- if (!canvasStyleEl) return;
- const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT;
- canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`;
- }
- function postDesignMode(mode) {
- if (window.parent === window) return;
- try {
- window.parent.postMessage({ type: "__dc_design_mode", mode }, "*");
- } catch {
- }
- }
- function setDesignDocMode(mode) {
- if (mode === designDocMode) return;
- designDocMode = mode;
- postDesignMode(mode);
- if (mode === "canvas") {
- doc.documentElement.setAttribute("data-dc-canvas", "");
- canvasStyleEl = doc.createElement("style");
- canvasStyleEl.setAttribute("data-dc-canvas", "");
- applyCanvasBg();
- doc.head.appendChild(canvasStyleEl);
- } else {
- doc.documentElement.removeAttribute("data-dc-canvas");
- canvasStyleEl?.remove();
- canvasStyleEl = null;
- }
- }
- window.addEventListener("message", (e) => {
- const type = e.data && e.data.type;
- if (type === "__dc_theme") {
- const t = e.data.theme;
- if (t === "light" || t === "dark") {
- appTheme = t;
- applyCanvasBg();
- }
- return;
- }
- if (!designDocMode || type !== "__dc_probe") return;
- postDesignMode(designDocMode);
- });
- function compile(node) {
- const raw = [...node.children];
- const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null;
- if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) {
- mounted.add("__dc-atomics");
- const el = doc.createElement("style");
- el.id = "__dc-atomics";
- el.textContent = ATOMIC_CSS;
- doc.head.appendChild(el);
- }
- return (_vals, ctx) => {
- const name = ctx && ctx.__name || "";
- const streaming = !!(name && isStreaming(name));
- for (let i = 0; i < raw.length; i++) {
- const child = raw[i];
- const tag = child.tagName;
- const mayBePartial = streaming && !helmetClosed && i === raw.length - 1;
- if (tag === "SCRIPT") {
- if (mayBePartial) continue;
- const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || "");
- if (mounted.has(key)) continue;
- mounted.add(key);
- const el = doc.createElement("script");
- for (const { name: an, value } of [...child.attributes])
- el.setAttribute(an, value);
- if (child.textContent) el.textContent = child.textContent;
- doc.head.appendChild(el);
- } else if (tag === "LINK" || tag === "META") {
- if (mayBePartial) continue;
- const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML);
- if (mounted.has(key)) continue;
- mounted.add(key);
- if (tag === "LINK") {
- const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/);
- const href = (child.getAttribute("href") || "").trim();
- const res = window.__resources;
- const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0;
- const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null;
- if (blob) {
- const el = doc.createElement("style");
- if (child.hasAttribute("disabled")) {
- el.setAttribute("media", "not all");
- } else if (child.getAttribute("media")) {
- el.setAttribute("media", child.getAttribute("media"));
- }
- if (child.getAttribute("title"))
- el.setAttribute("title", child.getAttribute("title"));
- void blob.text().then((css) => {
- el.textContent = css;
- });
- doc.head.appendChild(el);
- continue;
- }
- }
- doc.head.appendChild(child.cloneNode(true));
- } else {
- const key = name + "|" + i;
- let el = live.get(key);
- if (!el || el.tagName !== tag) {
- if (el) el.remove();
- el = doc.createElement(tag.toLowerCase());
- live.set(key, el);
- doc.head.appendChild(el);
- }
- for (const { name: an, value } of [...child.attributes]) {
- if (el.getAttribute(an) !== value) el.setAttribute(an, value);
- }
- if (el.textContent !== child.textContent)
- el.textContent = child.textContent;
- }
- }
- return null;
- };
- }
- return { compile, setDesignDocMode };
- }
-
- // src/pseudo.ts
- function scanUnquotedUrl(css, i) {
- if (css[i] !== "u" && css[i] !== "U" || css.slice(i, i + 4).toLowerCase() !== "url(" || /[a-z0-9_-]/i.test(css[i - 1] ?? "")) {
- return -1;
- }
- let j = i + 4;
- while (j < css.length && /\s/.test(css[j])) j++;
- if (css[j] === '"' || css[j] === "'") return -1;
- while (j < css.length && css[j] !== ")") {
- if (css[j] === "\\") j++;
- j++;
- }
- return j < css.length ? j + 1 : css.length;
- }
- function stripComments(css) {
- let out = "";
- let quote = "";
- for (let i = 0; i < css.length; i++) {
- const c = css[i];
- if (quote) {
- if (c === "\\") {
- out += c + (css[i + 1] ?? "");
- i++;
- continue;
- }
- if (c === quote) quote = "";
- out += c;
- } else if (c === "'" || c === '"') {
- quote = c;
- out += c;
- } else if (c === "/" && css[i + 1] === "*") {
- const end = css.indexOf("*/", i + 2);
- i = end === -1 ? css.length : end + 1;
- out += " ";
- } else {
- const end = scanUnquotedUrl(css, i);
- if (end === -1) out += c;
- else {
- out += css.slice(i, end);
- i = end - 1;
- }
- }
- }
- return out;
- }
- function importantify(css) {
- css = stripComments(css);
- const decls = [];
- let start = 0;
- let depth = 0;
- let quote = "";
- for (let i = 0; i < css.length; i++) {
- const c = css[i];
- if (quote) {
- if (c === "\\") i++;
- else if (c === quote) quote = "";
- } else if (c === "'" || c === '"') quote = c;
- else if (c === "(") depth++;
- else if (c === ")") depth = Math.max(0, depth - 1);
- else if (c === ";" && depth === 0) {
- decls.push(css.slice(start, i));
- start = i + 1;
- } else {
- const end = scanUnquotedUrl(css, i);
- if (end !== -1) i = end - 1;
- }
- }
- decls.push(css.slice(start));
- return decls.map((d) => d.trim()).filter(Boolean).map((d) => /!\s*important$/i.test(d) ? d : d + " !important").join(";");
- }
- function createPseudoSheet(doc) {
- let el = null;
- const cache = /* @__PURE__ */ new Map();
- let n = 0;
- return (pseudo, css) => {
- const k = pseudo + "|" + css;
- const hit = cache.get(k);
- if (hit) return hit;
- if (!el) {
- el = doc.createElement("style");
- doc.head.appendChild(el);
- }
- const cls = "scp" + (n++).toString(36);
- const isPseudoElement = pseudo === "before" || pseudo === "after";
- const sel = isPseudoElement ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo;
- el.sheet.insertRule(
- sel + "{" + (isPseudoElement ? css : importantify(css)) + "}",
- el.sheet.cssRules.length
- );
- cache.set(k, cls);
- return cls;
- };
- }
-
- // src/registry.ts
- function createRegistry() {
- const entries = /* @__PURE__ */ Object.create(null);
- function get(name) {
- return entries[name] || (entries[name] = {
- html: "",
- tpl: null,
- Logic: null,
- jsStreaming: false,
- htmlStreaming: false,
- ver: 0,
- subs: /* @__PURE__ */ new Set(),
- fetched: false
- });
- }
- function bump(name) {
- const r = get(name);
- r.ver++;
- for (const fn of r.subs) fn();
- }
- return {
- entries,
- get,
- bump,
- bumpAll() {
- for (const n in entries) bump(n);
- }
- };
- }
-
- // src/runtime.ts
- var COMPONENT_DIR = ".";
- function createRuntime(doc = document) {
- const registry = createRegistry();
- const pseudoClass = createPseudoSheet(doc);
- const helmet = createHelmetManager(
- doc,
- (name) => registry.get(name).htmlStreaming
- );
- const external = createExternalModules(() => registry.bumpAll());
- const factory = createComponentFactory(registry, ensureFetched);
- const host = {
- component: (name) => factory.getDC(name),
- placeholder: (props) => h(Placeholder, props),
- helmet: (node) => helmet.compile(node),
- loadExternal: (kind, url, after) => external.load(kind, url, after),
- resolveExternal: (url, name) => external.resolve(url, name),
- resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name),
- resolveExternalError: (url, name) => external.getError(url, name),
- pseudoClass
- };
- function ensureFetched(name) {
- const r = registry.get(name);
- if (r.fetched) return;
- r.fetched = true;
- const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html";
- const res = window.__resources;
- const pre = res ? res[url] : void 0;
- const target = typeof pre === "string" && pre ? pre : url;
- const blob = bundledBlob(target);
- (blob ? blob.text() : fetch(target).then((res2) => {
- if (!res2.ok) {
- console.error(
- '[dc-runtime] sibling fetch for "' + name + '" failed:',
- url,
- "returned",
- res2.status,
- "\u2014 the reference renders as an empty placeholder."
- );
- return "";
- }
- return res2.text();
- })).then((t) => {
- if (!t) return;
- const parsed = parseDcText(t);
- if (!parsed) {
- console.error(
- '[dc-runtime] sibling fetch for "' + name + '":',
- url,
- "has no block \u2014 not a Design Component."
- );
- return;
- }
- if (parsed.props) r.propsMeta = parsed.props;
- if (parsed.preview) r.preview = parsed.preview;
- if (parsed.template && !r.html) updateHtml(name, parsed.template);
- if (parsed.js && !r.Logic) updateJs(name, parsed.js);
- }).catch(
- (e) => console.error(
- '[dc-runtime] sibling fetch for "' + name + '" threw:',
- url,
- e
- )
- );
- }
- let rootName = null;
- function updateHtml(name, html) {
- const r = registry.get(name);
- r.html = html;
- if (name === rootName) {
- const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null;
- if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode);
- }
- try {
- r.tpl = compileTemplate(html, host);
- } catch (e) {
- console.error("[dc-runtime] template compile FAILED for", name, e);
- }
- registry.bump(name);
- }
- function updateJs(name, src) {
- const r = registry.get(name);
- const seq = r.jsSeq = (r.jsSeq || 0) + 1;
- try {
- const Cls = evalDcLogic(src);
- if (r.jsSeq !== seq) return;
- if (typeof Cls !== "function") {
- r.logicError = name + ".dc.html: