diff --git a/.gitignore b/.gitignore
index bceca49..c84c621 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,22 @@ venv/
node_modules/
dist/
build/
+
+# Next.js (frontend/)
+frontend/.next/
+frontend/next-env.d.ts
+frontend/out/
+frontend/.turbo/
+frontend/tsconfig.tsbuildinfo
+frontend/*.tsbuildinfo
+
+# Editor / OS
+*.swp
+*.swo
+.idea/
+.vscode/
+Thumbs.db
+
backend/data/*.json
backend/data/*.db
backend/data/*.db-shm
@@ -28,6 +44,3 @@ backend/data/qc_graph_exploration.zip
backend/logs/
repomix-output.xml
docs/review_bundles/*.patch
-frontend/*.tsbuildinfo
-frontend/vite.config.js
-frontend/vite.config.d.ts
diff --git a/INSTALL.md b/INSTALL.md
index ce9cd2a..4ae2908 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -68,7 +68,7 @@ make dev-frontend
Default frontend URL:
-- `http://127.0.0.1:5173`
+- `http://127.0.0.1:3002`
The Makefile points the frontend at `http://127.0.0.1:8010` by default.
@@ -105,7 +105,7 @@ FRONTEND_API_BASE_URL=http://127.0.0.1:8000 make dev-frontend
The frontend can also be configured directly with:
```bash
-VITE_API_BASE_URL=http://127.0.0.1:8010 npm run dev
+SPEECHCRAFT_BACKEND_URL=http://127.0.0.1:8010 bun run dev
```
## ASR Configuration
@@ -176,7 +176,7 @@ uv run python -m unittest discover -s tests -p 'test_*.py'
```bash
cd frontend
-npm run build
+bun run build
```
Smoke test against a running backend:
@@ -197,7 +197,7 @@ Expected local files/directories:
- `backend/.venv/`
- `frontend/node_modules/`
-- `frontend/dist/`
+- `frontend/.next/`
- `backend/data/project.db`
- `backend/data/media/`
- `backend/exports/`
diff --git a/Makefile b/Makefile
index 59bf0c6..5fd8468 100644
--- a/Makefile
+++ b/Makefile
@@ -28,7 +28,7 @@ help:
@printf " make dev-backend Run the FastAPI API and ProcessingJob worker\n"
@printf " make dev-api Run only the FastAPI API with reload\n"
@printf " make dev-worker Run only the ProcessingJob worker\n"
- @printf " make dev-frontend Run the Vite frontend dev server\n"
+ @printf " make dev-frontend Run the Next.js frontend dev server (http://127.0.0.1:3002)\n"
@printf " make check Run backend and frontend verification\n"
@printf " make check-backend Compile-check backend Python code\n"
@printf " make check-frontend Build the frontend production bundle\n"
@@ -44,7 +44,7 @@ setup-backend:
cd $(BACKEND_DIR) && UV_CACHE_DIR=$(UV_CACHE_DIR) uv sync
setup-frontend:
- cd $(FRONTEND_DIR) && npm install
+ cd $(FRONTEND_DIR) && bun install
dev-backend:
cd $(BACKEND_DIR) && UV_CACHE_DIR=$(UV_CACHE_DIR) sh -c 'set -e; mkdir -p logs; : > logs/dev-backend.log; (uv run uvicorn app.main:app --reload --host $(BACKEND_HOST) --port $(BACKEND_PORT) 2>&1 | tee -a logs/dev-backend.log) & api_pid=$$!; (uv run python -m app.worker 2>&1 | tee -a logs/dev-backend.log) & worker_pid=$$!; trap "kill $$api_pid $$worker_pid 2>/dev/null || true" INT TERM EXIT; wait'
@@ -58,7 +58,7 @@ dev-worker:
backend-docs: dev-backend
dev-frontend:
- cd $(FRONTEND_DIR) && VITE_API_BASE_URL=$(FRONTEND_API_BASE_URL) npm run dev
+ cd $(FRONTEND_DIR) && SPEECHCRAFT_BACKEND_URL=$(FRONTEND_API_BASE_URL) bun run dev
check: check-backend check-frontend
@@ -68,7 +68,7 @@ check-backend:
cd $(WORKER_ROOT) && PYTHONPATH=. $(BACKEND_DIR)/.venv/bin/python tests/test_clip_lab_coordination.py
check-frontend:
- cd $(FRONTEND_DIR) && npm run build
+ cd $(FRONTEND_DIR) && bun run build
smoke-backend:
cd $(BACKEND_DIR) && UV_CACHE_DIR=$(UV_CACHE_DIR) uv run python scripts/smoke_backend.py --base-url $(SMOKE_BACKEND_BASE_URL) --project-id $(SMOKE_BACKEND_PROJECT_ID)
diff --git a/README.md b/README.md
index affd6ad..3cbd9f8 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ Export is present in navigation as the downstream handoff stage. The backend has
## Project Layout
- `backend/`: FastAPI API, SQLite persistence, media management, processing worker, slicing/QC/export logic
-- `frontend/`: React + Vite UI for the staged workstation
+- `frontend/`: Next.js UI — the setup wizard and the Lab workstation.
- `docs/`: product, workflow, and implementation notes
## Core Docs
@@ -104,11 +104,11 @@ The combined backend/API dev output is also written to `backend/logs/dev-backend
By default the Makefile uses:
-- frontend: `http://127.0.0.1:5173`
+- frontend: `http://127.0.0.1:3002`
- backend: `http://127.0.0.1:8010`
- backend docs: `http://127.0.0.1:8010/docs`
-The frontend API target can be overridden with `VITE_API_BASE_URL`.
+The frontend reaches the backend same-origin through the `/sc-api` rewrite (see `frontend/next.config.ts`); override the target with `SPEECHCRAFT_BACKEND_URL`.
## Local State
@@ -148,7 +148,7 @@ Useful focused checks:
```bash
python3 -m compileall backend/app
cd backend && uv run python -m unittest discover -s tests -p 'test_*.py'
-cd frontend && npm run build
+cd frontend && bun run build
```
## Current Status
diff --git a/backend/app/canonical_export.py b/backend/app/canonical_export.py
new file mode 100644
index 0000000..cf2f5d9
--- /dev/null
+++ b/backend/app/canonical_export.py
@@ -0,0 +1,409 @@
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import wave
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+from uuid import uuid4
+
+from sqlmodel import Session
+
+from .clip_lab_audio import sha256_file
+from .clip_lab_audio_ops import render_cache_path
+from .clip_lab_state import (
+ ClipLabValidationError,
+ build_clip_lab_view,
+ clip_lab_run_lock,
+ index_manifest_by_clip_id,
+ load_candidate_manifest,
+ load_clip_lab_state,
+)
+from .dataset_runs import _run_root
+from .models import (
+ CanonicalExportBlockedReasonView,
+ CanonicalExportPreviewView,
+ CanonicalExportSummaryView,
+ ProcessingRun,
+)
+
+SCHEMA_VERSION = 1
+EXPORTS_RELATIVE_DIR = Path("artifacts/canonical_exports")
+MANIFEST_FILENAME = "speechcraft_dataset.jsonl"
+METADATA_FILENAME = "speechcraft_export.json"
+REPORT_FILENAME = "export_report.json"
+
+
+class CanonicalExportConflictError(ValueError):
+ """Current Clip Lab state cannot produce a canonical export."""
+
+
+def _utc_now() -> datetime:
+ return datetime.now(timezone.utc).replace(microsecond=0)
+
+
+def _utc_now_iso() -> str:
+ return _utc_now().isoformat().replace("+00:00", "Z")
+
+
+def _canonical_exports_root(run_root: Path) -> Path:
+ return run_root / EXPORTS_RELATIVE_DIR
+
+
+def _format_export_id(now: datetime) -> str:
+ return f"canonical_export_{now.strftime('%Y-%m-%d_%H%M%S')}_{uuid4().hex[:8]}"
+
+
+def _next_export_id(exports_root: Path) -> str:
+ while True:
+ candidate = _format_export_id(_utc_now())
+ if not (exports_root / candidate).exists():
+ return candidate
+
+
+def _read_wave_metadata(path: Path) -> dict[str, int | float]:
+ with wave.open(str(path), "rb") as handle:
+ frame_count = int(handle.getnframes())
+ sample_rate_hz = int(handle.getframerate())
+ channels = int(handle.getnchannels())
+ if sample_rate_hz <= 0:
+ raise ClipLabValidationError(f"audio file has invalid sample rate: {path}")
+ return {
+ "sample_rate_hz": sample_rate_hz,
+ "channels": channels,
+ "duration_sec": round(frame_count / sample_rate_hz, 6),
+ }
+
+
+def _resolve_manifest_audio_path(run_root: Path, manifest_row: dict[str, Any], *, clip_id: str) -> Path:
+ audio_path = manifest_row.get("audio_path")
+ if not isinstance(audio_path, str) or not audio_path.strip():
+ raise ClipLabValidationError(f"{clip_id} candidate manifest row is missing audio_path")
+ return run_root / audio_path
+
+
+def _relative_path(from_dir: Path, to_path: Path) -> str:
+ return os.path.relpath(to_path, start=from_dir)
+
+
+def _serialize_json(path: Path, payload: dict[str, Any]) -> None:
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
+
+
+def _quality_payload(clip_view: dict[str, Any]) -> dict[str, float] | None:
+ quality: dict[str, float] = {}
+ transcript_match = clip_view.get("transcript_match")
+ speaker_check = clip_view.get("speaker_check")
+ if isinstance(transcript_match, (int, float)):
+ quality["transcript_match"] = round(float(transcript_match), 2)
+ if isinstance(speaker_check, (int, float)):
+ quality["speaker_check"] = round(float(speaker_check), 2)
+ return quality or None
+
+
+def _build_clip_export_row(
+ *,
+ export_dir: Path,
+ run_root: Path,
+ manifest_row: dict[str, Any],
+ clip_view: dict[str, Any],
+ clip_entry: dict[str, Any] | None = None,
+) -> tuple[dict[str, Any] | None, list[str], str | None, float]:
+ clip_id = str(clip_view["clip_id"])
+ transcript = clip_view["transcript"]
+ if not isinstance(transcript, str):
+ return None, ["transcript_missing"], None, 0.0
+
+ source_audio_sha256 = clip_view.get("source_audio_sha256")
+ if not isinstance(source_audio_sha256, str) or not source_audio_sha256:
+ return None, ["source_audio_sha256_missing"], None, 0.0
+
+ audio_path: Path | None = None
+ audio_sha256: str | None = None
+ audio_revision_hash: str | None = None
+ reasons: list[str] = []
+ audio_kind: str
+ audio_edit = clip_entry.get("audio_edit") if isinstance(clip_entry, dict) else None
+ has_active_audio_edit = isinstance(audio_edit, dict) and bool(audio_edit.get("ops") or [])
+
+ if has_active_audio_edit:
+ audio_kind = "rendered_revision"
+ render_status = audio_edit.get("render_status")
+ audio_revision_hash = audio_edit.get("audio_revision_hash")
+ rendered_audio_sha256 = audio_edit.get("rendered_audio_sha256")
+ if render_status != "ready":
+ reasons.append("rendered_audio_not_ready")
+ if not isinstance(audio_revision_hash, str) or not audio_revision_hash:
+ reasons.append("audio_revision_hash_missing")
+ if not isinstance(rendered_audio_sha256, str) or not rendered_audio_sha256:
+ reasons.append("rendered_audio_sha256_missing")
+ if not reasons:
+ audio_path = render_cache_path(run_root, clip_id, audio_revision_hash)
+ if not audio_path.is_file():
+ reasons.append("rendered_audio_missing")
+ elif sha256_file(audio_path) != rendered_audio_sha256:
+ reasons.append("rendered_audio_hash_mismatch")
+ else:
+ audio_sha256 = rendered_audio_sha256
+ else:
+ audio_kind = "candidate_original"
+ audio_path = _resolve_manifest_audio_path(run_root, manifest_row, clip_id=clip_id)
+ if not audio_path.is_file():
+ reasons.append("candidate_audio_missing")
+ elif sha256_file(audio_path) != source_audio_sha256:
+ reasons.append("candidate_audio_hash_mismatch")
+ else:
+ audio_sha256 = source_audio_sha256
+
+ if reasons or audio_path is None or audio_sha256 is None:
+ return None, reasons, audio_kind, 0.0
+
+ audio_meta = _read_wave_metadata(audio_path)
+ audio_payload: dict[str, Any] = {
+ "path": _relative_path(export_dir, audio_path),
+ "kind": audio_kind,
+ "sha256": audio_sha256,
+ "source_audio_sha256": source_audio_sha256,
+ "sample_rate_hz": audio_meta["sample_rate_hz"],
+ "channels": audio_meta["channels"],
+ "duration_sec": audio_meta["duration_sec"],
+ }
+ if audio_kind == "rendered_revision" and audio_revision_hash:
+ audio_payload["audio_revision_hash"] = audio_revision_hash
+
+ row: dict[str, Any] = {
+ "schema_version": SCHEMA_VERSION,
+ "clip_id": clip_id,
+ "transcript": transcript,
+ "lineage": {
+ "source_clip_id": clip_id,
+ "parent_clip_ids": [],
+ },
+ "audio": audio_payload,
+ "review": {
+ "status": "accepted",
+ "reviewer_tags": list(clip_view.get("reviewer_tags") or []),
+ },
+ }
+ quality = _quality_payload(clip_view)
+ if quality:
+ row["quality"] = quality
+ return row, [], audio_kind, float(audio_meta["duration_sec"])
+
+
+def _collect_export_material(
+ repository: Any,
+ run_id: str,
+) -> tuple[ProcessingRun, Path, list[tuple[dict[str, Any], dict[str, Any], dict[str, Any] | None]], CanonicalExportPreviewView]:
+ with Session(repository.engine) as session:
+ run = session.get(ProcessingRun, run_id)
+ if run is None:
+ raise KeyError("Dataset run not found")
+ run_root = _run_root(repository, run)
+
+ with clip_lab_run_lock(run_root):
+ view = build_clip_lab_view(run_root, run_id=run_id)
+ if view.get("stale_state"):
+ raise CanonicalExportConflictError(
+ f"Clip Lab state is stale: {view.get('stale_reason') or 'candidate manifest changed'}"
+ )
+ if view.get("invalid_state"):
+ raise CanonicalExportConflictError(
+ f"Clip Lab state is invalid: {view.get('invalid_state_reason') or 'validation failed'}"
+ )
+
+ manifest = load_candidate_manifest(run_root)
+ manifest_by_id = index_manifest_by_clip_id(manifest)
+ saved_state = load_clip_lab_state(run_root)
+ stored_clips = saved_state.get("clips") if isinstance(saved_state, dict) else None
+ accepted_clip_views = [dict(clip) for clip in view.get("clips") or [] if clip.get("review_status") == "accepted"]
+ accepted_manifest_rows: dict[str, dict[str, Any]] = {}
+ accepted_clip_entries: dict[str, dict[str, Any] | None] = {}
+ for clip_view in accepted_clip_views:
+ clip_id = str(clip_view["clip_id"])
+ manifest_row = manifest_by_id.get(clip_id)
+ if manifest_row is not None:
+ accepted_manifest_rows[clip_id] = dict(manifest_row)
+ if isinstance(stored_clips, dict) and isinstance(stored_clips.get(clip_id), dict):
+ accepted_clip_entries[clip_id] = dict(stored_clips[clip_id])
+ else:
+ accepted_clip_entries[clip_id] = None
+
+ export_inputs: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any] | None]] = []
+ blocked_reasons: list[CanonicalExportBlockedReasonView] = []
+ total_duration_sec = 0.0
+ original_audio_count = 0
+ edited_audio_count = 0
+ preview_export_dir = _canonical_exports_root(run_root) / "preview"
+ for clip_view in accepted_clip_views:
+ clip_id = str(clip_view["clip_id"])
+ manifest_row = accepted_manifest_rows.get(clip_id)
+ if manifest_row is None:
+ blocked_reasons.append(
+ CanonicalExportBlockedReasonView(clip_id=clip_id, reasons=["manifest_row_missing"])
+ )
+ continue
+ row, reasons, audio_kind, duration_sec = _build_clip_export_row(
+ export_dir=preview_export_dir,
+ run_root=run_root,
+ manifest_row=manifest_row,
+ clip_view=clip_view,
+ clip_entry=accepted_clip_entries.get(clip_id),
+ )
+ if reasons:
+ blocked_reasons.append(CanonicalExportBlockedReasonView(clip_id=clip_id, reasons=reasons))
+ continue
+ assert row is not None
+ export_inputs.append((manifest_row, clip_view, accepted_clip_entries.get(clip_id)))
+ total_duration_sec += duration_sec
+ if audio_kind == "rendered_revision":
+ edited_audio_count += 1
+ else:
+ original_audio_count += 1
+
+ preview = CanonicalExportPreviewView(
+ run_id=run_id,
+ accepted_clip_count=len(accepted_clip_views),
+ total_duration_sec=round(total_duration_sec, 6),
+ original_audio_count=original_audio_count,
+ edited_audio_count=edited_audio_count,
+ blocked_clip_count=len(blocked_reasons),
+ blocked_reasons=blocked_reasons,
+ )
+ return run, run_root, export_inputs, preview
+
+
+def preview_canonical_export(repository: Any, run_id: str) -> CanonicalExportPreviewView:
+ _run, _run_root_path, _rows, preview = _collect_export_material(repository, run_id)
+ return preview
+
+
+def create_canonical_export(repository: Any, run_id: str) -> CanonicalExportSummaryView:
+ run, run_root, export_inputs, preview = _collect_export_material(repository, run_id)
+ if preview.accepted_clip_count == 0:
+ raise CanonicalExportConflictError("no accepted clips available for canonical export")
+ if preview.blocked_clip_count > 0:
+ raise CanonicalExportConflictError("accepted clips are blocked; resolve Clip Lab export blockers first")
+
+ exports_root = _canonical_exports_root(run_root)
+ exports_root.mkdir(parents=True, exist_ok=True)
+ export_id = _next_export_id(exports_root)
+ tmp_dir = exports_root / f".tmp_{export_id}_{uuid4().hex}"
+ final_dir = exports_root / export_id
+ if final_dir.exists():
+ raise CanonicalExportConflictError("canonical export snapshot already exists; retry")
+ tmp_dir.mkdir(parents=True, exist_ok=False)
+
+ try:
+ export_rows: list[dict[str, Any]] = []
+ for manifest_row, clip_view, clip_entry in export_inputs:
+ row, reasons, _audio_kind, _duration_sec = _build_clip_export_row(
+ export_dir=final_dir,
+ run_root=run_root,
+ manifest_row=manifest_row,
+ clip_view=clip_view,
+ clip_entry=clip_entry,
+ )
+ if reasons or row is None:
+ raise CanonicalExportConflictError("canonical export inputs changed while exporting; retry")
+ export_rows.append(row)
+
+ manifest_path = tmp_dir / MANIFEST_FILENAME
+ with manifest_path.open("w", encoding="utf-8") as handle:
+ for row in export_rows:
+ handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True))
+ handle.write("\n")
+
+ metadata = {
+ "schema_version": SCHEMA_VERSION,
+ "export_id": export_id,
+ "run_id": run.id,
+ "project_id": run.project_id,
+ "created_at": _utc_now_iso(),
+ "path_mode": "snapshot_relative",
+ "audio_storage_mode": "project_artifact_reference",
+ "portable": False,
+ "dataset_manifest": MANIFEST_FILENAME,
+ "accepted_clip_count": preview.accepted_clip_count,
+ "total_duration_sec": preview.total_duration_sec,
+ "original_audio_count": preview.original_audio_count,
+ "edited_audio_count": preview.edited_audio_count,
+ "blocked_clip_count": preview.blocked_clip_count,
+ }
+ _serialize_json(tmp_dir / METADATA_FILENAME, metadata)
+ _serialize_json(
+ tmp_dir / REPORT_FILENAME,
+ {
+ "run_id": run.id,
+ "export_id": export_id,
+ "accepted_clip_count": preview.accepted_clip_count,
+ "blocked_clip_count": preview.blocked_clip_count,
+ "blocked_reasons": [item.model_dump() for item in preview.blocked_reasons],
+ },
+ )
+
+ tmp_dir.rename(final_dir)
+ except Exception:
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+ raise
+ return CanonicalExportSummaryView(
+ export_id=export_id,
+ run_id=run.id,
+ project_id=run.project_id,
+ created_at=str(metadata["created_at"]),
+ accepted_clip_count=preview.accepted_clip_count,
+ total_duration_sec=preview.total_duration_sec,
+ snapshot_dir=str(final_dir),
+ manifest_path=str(final_dir / MANIFEST_FILENAME),
+ )
+
+
+def _summary_from_snapshot(run_id: str, snapshot_dir: Path) -> CanonicalExportSummaryView | None:
+ metadata_path = snapshot_dir / METADATA_FILENAME
+ try:
+ payload = json.loads(metadata_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ if not isinstance(payload, dict):
+ return None
+ export_id = payload.get("export_id")
+ project_id = payload.get("project_id")
+ created_at = payload.get("created_at")
+ accepted_clip_count = payload.get("accepted_clip_count")
+ total_duration_sec = payload.get("total_duration_sec")
+ if not isinstance(export_id, str) or not isinstance(project_id, str) or not isinstance(created_at, str):
+ return None
+ if not isinstance(accepted_clip_count, int) or not isinstance(total_duration_sec, (int, float)):
+ return None
+ return CanonicalExportSummaryView(
+ export_id=export_id,
+ run_id=run_id,
+ project_id=project_id,
+ created_at=created_at,
+ accepted_clip_count=accepted_clip_count,
+ total_duration_sec=round(float(total_duration_sec), 6),
+ snapshot_dir=str(snapshot_dir),
+ manifest_path=str(snapshot_dir / MANIFEST_FILENAME),
+ )
+
+
+def list_canonical_exports(repository: Any, run_id: str) -> list[CanonicalExportSummaryView]:
+ with Session(repository.engine) as session:
+ run = session.get(ProcessingRun, run_id)
+ if run is None:
+ raise KeyError("Dataset run not found")
+
+ run_root = _run_root(repository, run)
+ exports_root = _canonical_exports_root(run_root)
+ if not exports_root.exists():
+ return []
+ summaries: list[CanonicalExportSummaryView] = []
+ for child in exports_root.iterdir():
+ if not child.is_dir() or child.name.startswith(".tmp_"):
+ continue
+ summary = _summary_from_snapshot(run_id, child)
+ if summary is not None:
+ summaries.append(summary)
+ return sorted(summaries, key=lambda item: item.created_at, reverse=True)
diff --git a/backend/app/clip_lab_audio_ops.py b/backend/app/clip_lab_audio_ops.py
index 80b38d4..9862102 100644
--- a/backend/app/clip_lab_audio_ops.py
+++ b/backend/app/clip_lab_audio_ops.py
@@ -76,6 +76,13 @@ def _manifest_wav_path(run_root: Path, manifest_row: dict[str, Any]) -> Path:
return run_root / str(manifest_row["audio_path"])
+NATIVE_EXPORT_CLIPS_DIR = "artifacts/native_export_clips"
+
+
+def _native_export_wav_path(run_root: Path, clip_id: str) -> Path:
+ return run_root / NATIVE_EXPORT_CLIPS_DIR / f"{clip_id}.wav"
+
+
def _source_identity(manifest_row: dict[str, Any], *, clip_id: str) -> str:
source_sha = resolve_manifest_source_audio_hash(manifest_row, clip_id=clip_id)
if source_sha is None:
@@ -705,6 +712,9 @@ def resolve_revision_media_bytes(
raise ClipLabRevisionNotFoundError(f"revision key {revision_key!r} is not current for clip {clip_id}")
source_sha = _source_identity(manifest_row, clip_id=clip_id)
if revision_key == source_sha:
+ native_wav = _native_export_wav_path(run_root, clip_id)
+ if native_wav.is_file():
+ return native_wav.read_bytes()
return _manifest_wav_path(run_root, manifest_row).read_bytes()
cache_path = render_cache_path(run_root, clip_id, revision_key)
if not cache_path.is_file():
diff --git a/backend/app/dataset_runs.py b/backend/app/dataset_runs.py
index 525bb15..20efe14 100644
--- a/backend/app/dataset_runs.py
+++ b/backend/app/dataset_runs.py
@@ -299,8 +299,10 @@ def create_dataset_run(repository: Any, project_id: str, request: DatasetRunCrea
raise KeyError(f"Source recordings not found: {', '.join(missing)}")
if not selected_ids:
raise ValueError("Dataset run requires at least one source recording")
- if not request.single_speaker and len(selected_ids) != 1:
- raise ValueError("Multi-speaker diarization currently supports exactly one source WAV per run")
+ # Multi-file diarization is supported (option C): the worker concatenates
+ # the per-source analysis variants into a single timeline, diarizes once
+ # (so speaker identity is consistent across files), then remaps regions
+ # back per source. No per-run WAV-count limit.
reserved = sorted(RESERVED_WORKER_CONFIG_KEYS.intersection(request.config))
if reserved:
raise ValueError(f"Dataset run config contains backend-controlled keys: {', '.join(reserved)}")
diff --git a/backend/app/main.py b/backend/app/main.py
index 25c1bd4..d26f1e9 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -8,6 +8,12 @@
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, Response
+from .canonical_export import (
+ CanonicalExportConflictError,
+ create_canonical_export,
+ list_canonical_exports,
+ preview_canonical_export,
+)
from .reference_clip_candidates import mark_dataset_clip_as_reference_candidate
from .dataset_worker_client import run_dataset_worker_preflight
from .defaults import resolve_asr_device_and_compute_type, resolve_whisper_model
@@ -54,6 +60,8 @@
)
from .native_cliplab import NativeClipLabStore
from .models import (
+ CanonicalExportPreviewView,
+ CanonicalExportSummaryView,
DatasetClipLabClipView,
DatasetClipLabAudioOperationRequest,
DatasetClipLabAudioStackRequest,
@@ -74,8 +82,6 @@
DatasetRunView,
DatasetSlicerResultsView,
DatasetSlicerRerunRequest,
- ExportPreview,
- ExportRun,
ImportBatchCreate,
MarkReferenceClipCandidateRequest,
ProcessingJobView,
@@ -351,6 +357,42 @@ def read_dataset_clip_lab(run_id: str) -> DatasetClipLabView:
raise HTTPException(status_code=503, detail=str(exc)) from exc
+@app.get("/api/dataset-runs/{run_id}/canonical-export-preview", response_model=CanonicalExportPreviewView)
+def read_canonical_export_preview(run_id: str) -> CanonicalExportPreviewView:
+ try:
+ return preview_canonical_export(repository, run_id)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ except CanonicalExportConflictError as exc:
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
+ except ClipLabValidationError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except ClipLabStateError as exc:
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
+
+
+@app.post("/api/dataset-runs/{run_id}/canonical-exports", response_model=CanonicalExportSummaryView)
+def post_canonical_export(run_id: str) -> CanonicalExportSummaryView:
+ try:
+ return create_canonical_export(repository, run_id)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ except CanonicalExportConflictError as exc:
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
+ except ClipLabValidationError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except ClipLabStateError as exc:
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
+
+
+@app.get("/api/dataset-runs/{run_id}/canonical-exports", response_model=list[CanonicalExportSummaryView])
+def read_canonical_exports(run_id: str) -> list[CanonicalExportSummaryView]:
+ try:
+ return list_canonical_exports(repository, run_id)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+
+
@app.post(
"/api/dataset-runs/{run_id}/clips/{clip_id}/audio/operations",
response_model=DatasetClipLabClipView,
@@ -694,22 +736,6 @@ def get_source_recording_artifact(recording_id: str) -> SourceRecordingArtifactV
raise HTTPException(status_code=404, detail="Source recording not found") from exc
-@app.get("/api/projects/{project_id}/export-preview", response_model=ExportPreview)
-def get_export_preview(project_id: str) -> ExportPreview:
- try:
- return repository.get_export_preview(project_id)
- except KeyError as exc:
- raise HTTPException(status_code=404, detail="Project not found") from exc
-
-
-@app.get("/api/projects/{project_id}/exports", response_model=list[ExportRun])
-def list_export_runs(project_id: str) -> list[ExportRun]:
- try:
- return repository.list_export_runs(project_id)
- except KeyError as exc:
- raise HTTPException(status_code=404, detail="Project not found") from exc
-
-
@app.post("/api/projects/{project_id}/media-cleanup")
def cleanup_project_media(project_id: str) -> dict[str, object]:
try:
@@ -819,16 +845,6 @@ def redo_slice(clip_id: str) -> dict[str, object]:
raise HTTPException(status_code=400, detail=str(exc)) from exc
-@app.post("/api/projects/{project_id}/export", response_model=ExportRun)
-def export_project(project_id: str) -> ExportRun:
- try:
- return repository.export_project(project_id)
- except KeyError as exc:
- raise HTTPException(status_code=404, detail="Project not found") from exc
- except ValueError as exc:
- raise HTTPException(status_code=400, detail=str(exc)) from exc
-
-
@app.post("/api/recordings/{recording_id}/jobs/transcription", response_model=ProcessingJobView)
def enqueue_source_transcription(
recording_id: str,
diff --git a/backend/app/models.py b/backend/app/models.py
index a063680..03fc221 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -293,7 +293,6 @@ class ImportBatch(SQLModel, table=True):
active_preparation_job_id: str | None = None
recordings: list["SourceRecording"] = Relationship(back_populates="batch", cascade_delete=True)
- exports: list["ExportRun"] = Relationship(back_populates="batch", cascade_delete=True)
class SourceRecording(SQLModel, table=True):
@@ -913,6 +912,32 @@ class DatasetClipLabView(SQLModel):
clips: list[DatasetClipLabClipView] = Field(default_factory=list)
+class CanonicalExportBlockedReasonView(SQLModel):
+ clip_id: str
+ reasons: list[str] = Field(default_factory=list)
+
+
+class CanonicalExportPreviewView(SQLModel):
+ run_id: str
+ accepted_clip_count: int = 0
+ total_duration_sec: float = 0.0
+ original_audio_count: int = 0
+ edited_audio_count: int = 0
+ blocked_clip_count: int = 0
+ blocked_reasons: list[CanonicalExportBlockedReasonView] = Field(default_factory=list)
+
+
+class CanonicalExportSummaryView(SQLModel):
+ export_id: str
+ run_id: str
+ project_id: str
+ created_at: str
+ accepted_clip_count: int
+ total_duration_sec: float
+ snapshot_dir: str
+ manifest_path: str
+
+
class DatasetClipLabPatchRequest(SQLModel):
expected_manifest_sha256: str
expected_clip_version: int
@@ -1011,29 +1036,6 @@ def duration_s(self) -> float:
return self.num_samples / self.sample_rate if self.sample_rate else 0.0
-class ExportRun(SQLModel, table=True):
- """Project-level export history for the current UI."""
-
- id: str = Field(primary_key=True)
- batch_id: str = Field(foreign_key="importbatch.id")
- status: JobStatus = Field(default=JobStatus.PENDING, sa_column=Column(sql_enum(JobStatus)))
- output_root: str
- manifest_path: str
- accepted_clip_count: int = 0
- failed_clip_count: int = 0
- created_at: datetime = Field(default_factory=utc_now)
- completed_at: datetime | None = None
-
- batch: ImportBatch | None = Relationship(back_populates="exports")
-
-
-class ExportPreview(SQLModel):
- project_id: str
- manifest_path: str
- accepted_slice_count: int
- lines: list[str]
-
-
class ImportBatchCreate(SQLModel):
id: str
name: str
@@ -1044,7 +1046,6 @@ class ProjectSummary(SQLModel):
name: str
created_at: datetime
updated_at: datetime
- export_status: JobStatus | None = None
active_prepared_output_group_id: str | None = None
active_preparation_job_id: str | None = None
diff --git a/backend/app/repository.py b/backend/app/repository.py
index c669bec..ee0c77a 100644
--- a/backend/app/repository.py
+++ b/backend/app/repository.py
@@ -45,8 +45,6 @@
load_cutpoint_windows,
)
from .models import (
- ExportRun,
- ExportPreview,
ImportBatch,
ImportBatchCreate,
JobKind,
@@ -827,45 +825,6 @@ def list_source_recording_jobs(self, recording_id: str) -> list[ProcessingJobVie
).all()
return [self._processing_job_view(job) for job in jobs]
- def list_export_runs(self, project_id: str) -> list[ExportRun]:
- with self._session() as session:
- self._get_batch(session, project_id)
- runs = session.exec(
- select(ExportRun).where(ExportRun.batch_id == project_id).order_by(ExportRun.created_at)
- ).all()
- return [self._normalize_export_run(run) for run in runs]
-
- def get_export_preview(self, project_id: str) -> ExportPreview:
- with self._session() as session:
- batch = self._get_batch(session, project_id)
- return ExportPreview(
- project_id=batch.id,
- manifest_path=f"exports/{batch.id}/dataset.list",
- accepted_slice_count=0,
- lines=[],
- )
-
-
- def export_project(self, project_id: str) -> ExportRun:
- with self._session() as session:
- self._get_batch(session, project_id)
- export_id = self._new_id("export")
- output_root = self.exports_root / project_id / export_id
- manifest_path = output_root / "dataset.list"
- export_run = ExportRun(
- id=export_id,
- batch_id=project_id,
- status=JobStatus.COMPLETED,
- output_root=str(output_root),
- manifest_path=str(manifest_path),
- accepted_clip_count=0,
- completed_at=utc_now(),
- )
- session.add(export_run)
- session.commit()
- return self._normalize_export_run(export_run)
-
-
def get_source_recording_window_media_path(
self,
recording_id: str,
@@ -927,7 +886,6 @@ def delete_project(self, project_id: str) -> dict[str, int | str]:
session.exec(delete(SourceRecordingArtifact).where(SourceRecordingArtifact.source_recording_id.in_(recording_ids)))
session.exec(delete(ProcessingJob).where(ProcessingJob.source_recording_id.in_(recording_ids)))
session.exec(delete(SourceRecording).where(SourceRecording.id.in_(recording_ids)))
- session.exec(delete(ExportRun).where(ExportRun.batch_id == project_id))
session.exec(delete(ImportBatch).where(ImportBatch.id == project_id))
session.commit()
@@ -1936,7 +1894,6 @@ def _seed_from_legacy_json(self, session: Session) -> None:
source_recordings: dict[tuple[str, str], SourceRecording] = {}
projects = legacy.get("projects", {})
clips_by_project = legacy.get("clips_by_project", {})
- exports_by_project = legacy.get("exports_by_project", {})
source_audio_overrides = self._legacy_seed_source_audio_overrides(clips_by_project)
for project_id, batch_payload in projects.items():
@@ -1995,24 +1952,6 @@ def _seed_from_legacy_json(self, session: Session) -> None:
source_recordings[source_key] = source_recording
session.flush()
- for export_payload in exports_by_project.get(batch.id, []):
- session.add(
- ExportRun(
- id=export_payload["id"],
- batch_id=batch.id,
- status=self._job_status_from_legacy(export_payload["status"]),
- output_root=export_payload["output_root"],
- manifest_path=export_payload["manifest_path"],
- accepted_clip_count=export_payload["accepted_clip_count"],
- failed_clip_count=export_payload["failed_clip_count"],
- created_at=datetime.fromisoformat(export_payload["created_at"].replace("Z", "+00:00")),
- completed_at=(
- datetime.fromisoformat(export_payload["completed_at"].replace("Z", "+00:00"))
- if export_payload.get("completed_at")
- else None
- ),
- )
- )
def _seed_demo(self, session: Session) -> None:
batch = ImportBatch(id="phase1-demo", name="Phase 1 Demo Project")
@@ -2080,34 +2019,16 @@ def _coerce_datetime(self, value: datetime | str | None) -> datetime:
def _project_summary(self, session: Session, batch: ImportBatch) -> ProjectSummary:
created_at = self._as_utc(batch.created_at)
updated_at = created_at
- latest_export = session.exec(
- select(ExportRun).where(ExportRun.batch_id == batch.id).order_by(ExportRun.created_at.desc())
- ).first()
- export_status = None
- if latest_export is not None:
- export_status = latest_export.status
- updated_at = max(
- updated_at,
- self._as_utc(latest_export.completed_at or latest_export.created_at),
- )
return ProjectSummary(
id=batch.id,
name=batch.name,
created_at=created_at,
updated_at=updated_at,
- export_status=export_status,
active_prepared_output_group_id=batch.active_prepared_output_group_id,
active_preparation_job_id=batch.active_preparation_job_id,
)
- def _normalize_export_run(self, run: ExportRun) -> ExportRun:
- run.created_at = self._as_utc(run.created_at)
- if run.completed_at is not None:
- run.completed_at = self._as_utc(run.completed_at)
- return run
-
-
def _source_recording_view(
self,
@@ -4505,15 +4426,6 @@ def _job_status_to_api(self, status: JobStatus) -> str:
}
return mapping[status]
- def _export_status_from_job(self, status: JobStatus) -> str:
- mapping = {
- JobStatus.PENDING: "export_in_progress",
- JobStatus.RUNNING: "export_in_progress",
- JobStatus.COMPLETED: "export_succeeded",
- JobStatus.FAILED: "export_failed",
- }
- return mapping[status]
-
_repository_instance: SQLiteRepository | None = None
diff --git a/backend/tests/test_api_integration.py b/backend/tests/test_api_integration.py
index 46aa785..f01b5f2 100644
--- a/backend/tests/test_api_integration.py
+++ b/backend/tests/test_api_integration.py
@@ -37,14 +37,8 @@ def test_project_routes_return_seeded_demo_project(self) -> None:
self.assertGreaterEqual(len(recordings), 1)
self.assertEqual(recordings[0]["slice_count"], 0)
- preview_response = self.client.get("/api/projects/phase1-demo/export-preview")
- self.assertEqual(preview_response.status_code, 200)
- preview = preview_response.json()
- self.assertEqual(preview["project_id"], "phase1-demo")
- self.assertEqual(preview["accepted_slice_count"], 0)
-
def test_missing_project_route_returns_404(self) -> None:
- response = self.client.get("/api/projects/missing-project/export-preview")
+ response = self.client.get("/api/projects/missing-project")
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["detail"], "Project not found")
diff --git a/backend/tests/test_clip_lab_routes.py b/backend/tests/test_clip_lab_routes.py
index 605daa4..2853ab9 100644
--- a/backend/tests/test_clip_lab_routes.py
+++ b/backend/tests/test_clip_lab_routes.py
@@ -12,14 +12,29 @@
import numpy as np
+from fastapi import HTTPException
from fastapi.testclient import TestClient
from sqlmodel import Session
from app.clip_lab_audio import MAX_INSERT_SILENCE_SEC
-from app.clip_lab_state import ClipLabStateError, compute_content_hash, compute_manifest_sha256
+from app.clip_lab_state import (
+ ClipLabStateError,
+ compute_content_hash,
+ compute_manifest_sha256,
+ save_clip_lab_state,
+)
from app.dataset_runs import create_dataset_run
-from app.main import app, patch_dataset_clip_lab_route, read_dataset_clip_lab
+from app.main import (
+ app,
+ patch_dataset_clip_lab_route,
+ post_dataset_clip_audio_operation_route,
+ post_canonical_export,
+ read_canonical_exports,
+ read_canonical_export_preview,
+ read_dataset_clip_lab,
+)
from app.models import (
+ DatasetClipLabAudioOperationRequest,
DatasetClipLabClipView,
DatasetClipLabPatchRequest,
DatasetClipLabPipelineFindingView,
@@ -178,6 +193,405 @@ def test_get_clip_lab_returns_manifest_clips(self) -> None:
self.assertTrue(payload["qc_available"])
self.assertEqual(len(payload["clips"]), 4)
+ def test_canonical_export_preview_allows_zero_accepted(self) -> None:
+ with self.repo():
+ response = read_canonical_export_preview(self.run.id)
+
+ self.assertEqual(response.accepted_clip_count, 0)
+ self.assertEqual(response.blocked_clip_count, 0)
+
+ def test_create_canonical_export_rejects_zero_accepted(self) -> None:
+ with self.repo():
+ with self.assertRaises(HTTPException) as exc_info:
+ post_canonical_export(self.run.id)
+ self.assertEqual(exc_info.exception.status_code, 409)
+
+ def test_create_canonical_export_writes_manifest_snapshot(self) -> None:
+ with self.repo():
+ accept_response = patch_dataset_clip_lab_route(
+ self.run.id,
+ "candidate_review_clip_000001",
+ DatasetClipLabPatchRequest(
+ expected_manifest_sha256=self.manifest_sha,
+ expected_clip_version=0,
+ review_status="accepted",
+ transcript_override="Final transcript.",
+ reviewer_tags=["good energy"],
+ ),
+ )
+ self.assertEqual(accept_response.review_status, "accepted")
+
+ export_response = post_canonical_export(self.run.id)
+
+ export_dir = Path(export_response.snapshot_dir)
+ self.assertTrue((export_dir / "speechcraft_dataset.jsonl").is_file())
+ self.assertTrue((export_dir / "speechcraft_export.json").is_file())
+ self.assertTrue((export_dir / "export_report.json").is_file())
+
+ rows = [
+ json.loads(line)
+ for line in (export_dir / "speechcraft_dataset.jsonl").read_text(encoding="utf-8").splitlines()
+ if line.strip()
+ ]
+ self.assertEqual(len(rows), 1)
+ row = rows[0]
+ self.assertEqual(row["schema_version"], 1)
+ self.assertEqual(row["clip_id"], "candidate_review_clip_000001")
+ self.assertEqual(row["transcript"], "Final transcript.")
+ self.assertEqual(row["lineage"]["source_clip_id"], "candidate_review_clip_000001")
+ self.assertEqual(row["lineage"]["parent_clip_ids"], [])
+ self.assertEqual(row["review"]["status"], "accepted")
+ self.assertEqual(row["review"]["reviewer_tags"], ["good energy"])
+ self.assertEqual(row["audio"]["kind"], "candidate_original")
+ self.assertEqual(row["audio"]["source_audio_sha256"], row["audio"]["sha256"])
+ self.assertIn("candidate_review_clips/candidate_review_clip_000001.wav", row["audio"]["path"])
+ metadata = json.loads((export_dir / "speechcraft_export.json").read_text(encoding="utf-8"))
+ self.assertEqual(metadata["path_mode"], "snapshot_relative")
+ self.assertEqual(metadata["audio_storage_mode"], "project_artifact_reference")
+ self.assertFalse(metadata["portable"])
+ self.assertEqual(export_dir.parent, self.run_root / "artifacts" / "canonical_exports")
+
+ def test_create_canonical_export_uses_rendered_revision_for_accepted_audio_edit(self) -> None:
+ with self.repo():
+ edited = post_dataset_clip_audio_operation_route(
+ self.run.id,
+ "candidate_review_clip_000001",
+ DatasetClipLabAudioOperationRequest(
+ expected_manifest_sha256=self.manifest_sha,
+ expected_clip_version=0,
+ operation={
+ "kind": "insert_silence",
+ "at_sample": 10,
+ "duration_samples": 16,
+ },
+ ),
+ )
+ self.assertEqual(edited.effective_audio_kind, "rendered_revision")
+ accepted = patch_dataset_clip_lab_route(
+ self.run.id,
+ "candidate_review_clip_000001",
+ DatasetClipLabPatchRequest(
+ expected_manifest_sha256=self.manifest_sha,
+ expected_clip_version=edited.clip_version,
+ review_status="accepted",
+ ),
+ )
+ self.assertEqual(accepted.review_status, "accepted")
+
+ export_response = post_canonical_export(self.run.id)
+
+ rows = [
+ json.loads(line)
+ for line in (Path(export_response.snapshot_dir) / "speechcraft_dataset.jsonl")
+ .read_text(encoding="utf-8")
+ .splitlines()
+ if line.strip()
+ ]
+ row = rows[0]
+ self.assertEqual(row["audio"]["kind"], "rendered_revision")
+ self.assertIn("clip_lab_renders", row["audio"]["path"])
+ self.assertIsInstance(row["audio"]["audio_revision_hash"], str)
+
+ def test_canonical_export_preview_blocks_pending_rendered_audio(self) -> None:
+ manifest = json.loads((self.run_root / "artifacts" / "candidate_review_manifest.json").read_text(encoding="utf-8"))
+ clip_row = next(row for row in manifest if row["id"] == "candidate_review_clip_000001")
+ source_sha = str(clip_row["audio_sha256"])
+ audio_revision_hash = "ab" * 32
+ accepted_hash = compute_content_hash(
+ manifest_transcript=str(clip_row["training_text"]),
+ transcript_override=None,
+ audio_revision_hash=audio_revision_hash,
+ base_audio_hash=source_sha,
+ )
+ save_clip_lab_state(
+ self.run_root,
+ {
+ "schema_version": 1,
+ "stage": "clip_lab_state",
+ "candidate_manifest_sha256": self.manifest_sha,
+ "updated_at": "2026-07-08T00:00:00Z",
+ "clips": {
+ "candidate_review_clip_000001": {
+ "clip_version": 1,
+ "review_status": "accepted",
+ "accepted_content_hash": accepted_hash,
+ "accepted_at": "2026-07-08T00:00:00Z",
+ "reviewer_tags": [],
+ "updated_at": "2026-07-08T00:00:00Z",
+ "audio_edit": {
+ "schema_version": 1,
+ "source_audio_sha256": source_sha,
+ "source_sample_rate_hz": SAMPLE_RATE,
+ "ops": [{"kind": "insert_silence", "at_sample": 10, "duration_samples": 16}],
+ "redo_ops": [],
+ "audio_revision_hash": audio_revision_hash,
+ "rendered_audio_sha256": None,
+ "render_status": "pending",
+ },
+ }
+ },
+ },
+ )
+
+ with self.repo():
+ preview = read_canonical_export_preview(self.run.id)
+
+ self.assertEqual(preview.accepted_clip_count, 1)
+ self.assertEqual(preview.blocked_clip_count, 1)
+ self.assertIn("rendered_audio_not_ready", preview.blocked_reasons[0].reasons)
+
+ def test_canonical_export_preview_blocks_failed_rendered_audio(self) -> None:
+ manifest = json.loads((self.run_root / "artifacts" / "candidate_review_manifest.json").read_text(encoding="utf-8"))
+ clip_row = next(row for row in manifest if row["id"] == "candidate_review_clip_000001")
+ source_sha = str(clip_row["audio_sha256"])
+ audio_revision_hash = "cd" * 32
+ accepted_hash = compute_content_hash(
+ manifest_transcript=str(clip_row["training_text"]),
+ transcript_override=None,
+ audio_revision_hash=audio_revision_hash,
+ base_audio_hash=source_sha,
+ )
+ save_clip_lab_state(
+ self.run_root,
+ {
+ "schema_version": 1,
+ "stage": "clip_lab_state",
+ "candidate_manifest_sha256": self.manifest_sha,
+ "updated_at": "2026-07-08T00:00:00Z",
+ "clips": {
+ "candidate_review_clip_000001": {
+ "clip_version": 1,
+ "review_status": "accepted",
+ "accepted_content_hash": accepted_hash,
+ "accepted_at": "2026-07-08T00:00:00Z",
+ "reviewer_tags": [],
+ "updated_at": "2026-07-08T00:00:00Z",
+ "audio_edit": {
+ "schema_version": 1,
+ "source_audio_sha256": source_sha,
+ "source_sample_rate_hz": SAMPLE_RATE,
+ "ops": [{"kind": "insert_silence", "at_sample": 10, "duration_samples": 16}],
+ "redo_ops": [],
+ "audio_revision_hash": audio_revision_hash,
+ "rendered_audio_sha256": None,
+ "render_status": "failed",
+ },
+ }
+ },
+ },
+ )
+
+ with self.repo():
+ preview = read_canonical_export_preview(self.run.id)
+
+ self.assertEqual(preview.accepted_clip_count, 1)
+ self.assertEqual(preview.blocked_clip_count, 1)
+ self.assertIn("rendered_audio_not_ready", preview.blocked_reasons[0].reasons)
+
+ def test_canonical_export_preview_blocks_missing_rendered_wav(self) -> None:
+ manifest = json.loads((self.run_root / "artifacts" / "candidate_review_manifest.json").read_text(encoding="utf-8"))
+ clip_row = next(row for row in manifest if row["id"] == "candidate_review_clip_000001")
+ source_sha = str(clip_row["audio_sha256"])
+ audio_revision_hash = "ef" * 32
+ rendered_sha = "11" * 32
+ accepted_hash = compute_content_hash(
+ manifest_transcript=str(clip_row["training_text"]),
+ transcript_override=None,
+ audio_revision_hash=audio_revision_hash,
+ base_audio_hash=source_sha,
+ )
+ save_clip_lab_state(
+ self.run_root,
+ {
+ "schema_version": 1,
+ "stage": "clip_lab_state",
+ "candidate_manifest_sha256": self.manifest_sha,
+ "updated_at": "2026-07-08T00:00:00Z",
+ "clips": {
+ "candidate_review_clip_000001": {
+ "clip_version": 1,
+ "review_status": "accepted",
+ "accepted_content_hash": accepted_hash,
+ "accepted_at": "2026-07-08T00:00:00Z",
+ "reviewer_tags": [],
+ "updated_at": "2026-07-08T00:00:00Z",
+ "audio_edit": {
+ "schema_version": 1,
+ "source_audio_sha256": source_sha,
+ "source_sample_rate_hz": SAMPLE_RATE,
+ "ops": [{"kind": "insert_silence", "at_sample": 10, "duration_samples": 16}],
+ "redo_ops": [],
+ "audio_revision_hash": audio_revision_hash,
+ "rendered_audio_sha256": rendered_sha,
+ "render_status": "ready",
+ },
+ }
+ },
+ },
+ )
+
+ with self.repo():
+ preview = read_canonical_export_preview(self.run.id)
+
+ self.assertEqual(preview.blocked_clip_count, 1)
+ self.assertIn("rendered_audio_missing", preview.blocked_reasons[0].reasons)
+
+ def test_canonical_export_preview_blocks_rendered_hash_mismatch(self) -> None:
+ manifest = json.loads((self.run_root / "artifacts" / "candidate_review_manifest.json").read_text(encoding="utf-8"))
+ clip_row = next(row for row in manifest if row["id"] == "candidate_review_clip_000001")
+ source_sha = str(clip_row["audio_sha256"])
+ audio_revision_hash = "12" * 32
+ accepted_hash = compute_content_hash(
+ manifest_transcript=str(clip_row["training_text"]),
+ transcript_override=None,
+ audio_revision_hash=audio_revision_hash,
+ base_audio_hash=source_sha,
+ )
+ render_path = self.run_root / "artifacts" / "clip_lab_renders" / "candidate_review_clip_000001" / f"{audio_revision_hash}.wav"
+ render_path.parent.mkdir(parents=True, exist_ok=True)
+ render_path.write_bytes(b"not-the-expected-hash")
+ save_clip_lab_state(
+ self.run_root,
+ {
+ "schema_version": 1,
+ "stage": "clip_lab_state",
+ "candidate_manifest_sha256": self.manifest_sha,
+ "updated_at": "2026-07-08T00:00:00Z",
+ "clips": {
+ "candidate_review_clip_000001": {
+ "clip_version": 1,
+ "review_status": "accepted",
+ "accepted_content_hash": accepted_hash,
+ "accepted_at": "2026-07-08T00:00:00Z",
+ "reviewer_tags": [],
+ "updated_at": "2026-07-08T00:00:00Z",
+ "audio_edit": {
+ "schema_version": 1,
+ "source_audio_sha256": source_sha,
+ "source_sample_rate_hz": SAMPLE_RATE,
+ "ops": [{"kind": "insert_silence", "at_sample": 10, "duration_samples": 16}],
+ "redo_ops": [],
+ "audio_revision_hash": audio_revision_hash,
+ "rendered_audio_sha256": "22" * 32,
+ "render_status": "ready",
+ },
+ }
+ },
+ },
+ )
+
+ with self.repo():
+ preview = read_canonical_export_preview(self.run.id)
+
+ self.assertEqual(preview.blocked_clip_count, 1)
+ self.assertIn("rendered_audio_hash_mismatch", preview.blocked_reasons[0].reasons)
+
+ def test_canonical_export_preview_blocks_candidate_hash_mismatch(self) -> None:
+ with self.repo():
+ accepted = patch_dataset_clip_lab_route(
+ self.run.id,
+ "candidate_review_clip_000001",
+ DatasetClipLabPatchRequest(
+ expected_manifest_sha256=self.manifest_sha,
+ expected_clip_version=0,
+ review_status="accepted",
+ ),
+ )
+ self.assertEqual(accepted.review_status, "accepted")
+
+ candidate_wav = self.run_root / "artifacts" / "candidate_review_clips" / "candidate_review_clip_000001.wav"
+ candidate_wav.write_bytes(b"mutated-audio")
+
+ with self.repo():
+ preview = read_canonical_export_preview(self.run.id)
+
+ self.assertEqual(preview.accepted_clip_count, 1)
+ self.assertEqual(preview.blocked_clip_count, 1)
+ self.assertIn("candidate_audio_hash_mismatch", preview.blocked_reasons[0].reasons)
+
+ def test_canonical_export_preview_rejects_stale_state(self) -> None:
+ save_clip_lab_state(
+ self.run_root,
+ {
+ "schema_version": 1,
+ "stage": "clip_lab_state",
+ "candidate_manifest_sha256": "stale-manifest-sha",
+ "updated_at": "2026-07-08T00:00:00Z",
+ "clips": {},
+ },
+ )
+
+ with self.repo():
+ with self.assertRaises(HTTPException) as exc_info:
+ read_canonical_export_preview(self.run.id)
+ self.assertEqual(exc_info.exception.status_code, 409)
+
+ def test_canonical_export_create_rejects_invalid_state(self) -> None:
+ save_clip_lab_state(
+ self.run_root,
+ {
+ "schema_version": 1,
+ "stage": "clip_lab_state",
+ "candidate_manifest_sha256": self.manifest_sha,
+ "updated_at": "2026-07-08T00:00:00Z",
+ "clips": {
+ "candidate_review_clip_000001": {
+ "clip_version": 1,
+ "review_status": "accepted",
+ "reviewer_tags": ["Accepted"],
+ }
+ },
+ },
+ )
+
+ with self.repo():
+ with self.assertRaises(HTTPException) as exc_info:
+ post_canonical_export(self.run.id)
+ self.assertEqual(exc_info.exception.status_code, 409)
+
+ def test_list_canonical_exports_returns_newest_first_and_skips_corrupt_dirs(self) -> None:
+ exports_root = self.run_root / "artifacts" / "canonical_exports"
+ older = exports_root / "canonical_export_2026-07-08_010101_aaaaaaaa"
+ newer = exports_root / "canonical_export_2026-07-08_020202_bbbbbbbb"
+ corrupt = exports_root / "canonical_export_corrupt"
+ older.mkdir(parents=True, exist_ok=True)
+ newer.mkdir(parents=True, exist_ok=True)
+ corrupt.mkdir(parents=True, exist_ok=True)
+ (older / "speechcraft_export.json").write_text(
+ json.dumps(
+ {
+ "schema_version": 1,
+ "export_id": older.name,
+ "run_id": self.run.id,
+ "project_id": "project-1",
+ "created_at": "2026-07-08T01:01:01Z",
+ "accepted_clip_count": 2,
+ "total_duration_sec": 12.5,
+ }
+ ),
+ encoding="utf-8",
+ )
+ (newer / "speechcraft_export.json").write_text(
+ json.dumps(
+ {
+ "schema_version": 1,
+ "export_id": newer.name,
+ "run_id": self.run.id,
+ "project_id": "project-1",
+ "created_at": "2026-07-08T02:02:02Z",
+ "accepted_clip_count": 3,
+ "total_duration_sec": 18.0,
+ }
+ ),
+ encoding="utf-8",
+ )
+ (corrupt / "speechcraft_export.json").write_text("{bad json", encoding="utf-8")
+
+ with self.repo():
+ exports = read_canonical_exports(self.run.id)
+
+ self.assertEqual([item.export_id for item in exports], [newer.name, older.name])
+
def test_patch_transcript_override_then_get(self) -> None:
with self.repo():
patch_response = self.client.patch(
diff --git a/backend/tests/test_dataset_run_routes.py b/backend/tests/test_dataset_run_routes.py
index 8d81639..411b250 100644
--- a/backend/tests/test_dataset_run_routes.py
+++ b/backend/tests/test_dataset_run_routes.py
@@ -114,12 +114,10 @@ def test_create_list_get_and_log_routes_serialize(self) -> None:
self.assertEqual(log.text, "worker output")
self.assertTrue(create.call_args.args[2].single_speaker)
- def test_create_route_surfaces_multi_speaker_validation_errors(self) -> None:
+ def test_create_route_surfaces_validation_errors(self) -> None:
with patch(
"app.main.create_dataset_run",
- side_effect=ValueError(
- "Multi-speaker diarization currently supports exactly one source WAV per run"
- ),
+ side_effect=ValueError("Dataset run requires at least one source recording"),
):
with self.assertRaises(HTTPException) as exc:
create_project_dataset_run(
@@ -128,7 +126,7 @@ def test_create_route_surfaces_multi_speaker_validation_errors(self) -> None:
)
self.assertEqual(exc.exception.status_code, 400)
- self.assertIn("exactly one source WAV", str(exc.exception.detail))
+ self.assertIn("at least one source recording", str(exc.exception.detail))
def test_slicer_rerun_and_results_routes_serialize(self) -> None:
run = run_view()
diff --git a/backend/tests/test_dataset_runs.py b/backend/tests/test_dataset_runs.py
index 5f3ae32..9fd1309 100644
--- a/backend/tests/test_dataset_runs.py
+++ b/backend/tests/test_dataset_runs.py
@@ -235,12 +235,17 @@ def test_create_run_rejects_multi_speaker_multi_source_until_cross_file_identity
)
session.commit()
- with self.assertRaisesRegex(ValueError, "exactly one source WAV"):
- create_dataset_run(
- self.repository,
- "project-1",
- DatasetRunCreateRequest(single_speaker=False, source_recording_ids=["recording-1", "recording-2"]),
- )
+ # Option C: multi-file diarization is supported (worker concatenates the
+ # per-source analysis variants, diarizes once, remaps regions per file).
+ run = create_dataset_run(
+ self.repository,
+ "project-1",
+ DatasetRunCreateRequest(single_speaker=False, source_recording_ids=["recording-1", "recording-2"]),
+ )
+ self.assertFalse(run.input_summary["single_speaker"])
+ self.assertEqual(
+ run.input_summary["source_recording_ids"], ["recording-1", "recording-2"]
+ )
def test_refresh_indexes_rejected_manifest_and_completed_status(self) -> None:
run = create_dataset_run(self.repository, "project-1", DatasetRunCreateRequest())
diff --git a/backend/tests/test_repository_media.py b/backend/tests/test_repository_media.py
index 8214994..6651d2c 100644
--- a/backend/tests/test_repository_media.py
+++ b/backend/tests/test_repository_media.py
@@ -278,7 +278,6 @@ def test_legacy_seed_imports_all_projects_without_source_collisions(self) -> Non
"id": "project-a",
"name": "Project A",
"status": "ready",
- "export_status": "idle",
"created_at": "2026-03-18T15:16:30Z",
"updated_at": "2026-03-18T15:16:30Z",
},
@@ -286,7 +285,6 @@ def test_legacy_seed_imports_all_projects_without_source_collisions(self) -> Non
"id": "project-b",
"name": "Project B",
"status": "ready",
- "export_status": "idle",
"created_at": "2026-03-18T15:16:30Z",
"updated_at": "2026-03-18T15:16:30Z",
},
@@ -336,7 +334,6 @@ def test_seed_from_legacy_json_uses_real_audio_for_one_to_one_source_recordings(
"id": "project-a",
"name": "Project A",
"status": "ready",
- "export_status": "idle",
"created_at": "2026-03-18T15:16:30Z",
"updated_at": "2026-03-18T15:16:30Z",
}
@@ -387,7 +384,6 @@ def test_migrate_legacy_seed_source_recordings_backfills_real_audio(self) -> Non
"id": "project-a",
"name": "Project A",
"status": "ready",
- "export_status": "idle",
"created_at": "2026-03-18T15:16:30Z",
"updated_at": "2026-03-18T15:16:30Z",
}
diff --git a/frontend/bun.lock b/frontend/bun.lock
index 4df5d3c..b94b34e 100644
--- a/frontend/bun.lock
+++ b/frontend/bun.lock
@@ -3,144 +3,617 @@
"configVersion": 1,
"workspaces": {
"": {
- "name": "speechcraft-frontend",
+ "name": "speechcraft-web",
"dependencies": {
- "@wavesurfer/react": "^1.0.12",
- "lucide-react": "^0.577.0",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
+ "@radix-ui/react-accordion": "^1.2.11",
+ "@radix-ui/react-alert-dialog": "^1.1.14",
+ "@radix-ui/react-avatar": "^1.1.10",
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-collapsible": "^1.1.12",
+ "@radix-ui/react-context-menu": "^2.2.15",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-hover-card": "^1.1.15",
+ "@radix-ui/react-icons": "^1.3.2",
+ "@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-navigation-menu": "^1.2.13",
+ "@radix-ui/react-popover": "^1.1.14",
+ "@radix-ui/react-progress": "^1.1.7",
+ "@radix-ui/react-radio-group": "^1.3.7",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-separator": "^1.1.8",
+ "@radix-ui/react-slider": "^1.3.5",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-switch": "^1.2.5",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-toast": "^1.2.14",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@tanstack/react-query": "^5.95.2",
+ "@tanstack/react-virtual": "^3.13.23",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "1.1.1",
+ "date-fns": "^4.1.0",
+ "embla-carousel-react": "^8.6.0",
+ "framer-motion": "^12.34.0",
+ "input-otp": "^1.4.2",
+ "lucide-react": "^0.562.0",
+ "next": "16.2.1",
+ "next-themes": "^0.4.6",
+ "nuqs": "^2.8.9",
+ "react": "^19.2.0",
+ "react-day-picker": "9.8.0",
+ "react-dom": "^19.2.0",
+ "react-dropzone": "^15.0.0",
+ "react-hotkeys-hook": "^5.2.4",
+ "react-icons": "^5.5.0",
+ "react-number-format": "^5.4.4",
+ "recharts": "^3.6.0",
+ "tailwind-merge": "3.4.0",
+ "vaul": "^1.1.2",
"wavesurfer.js": "^7.12.1",
},
"devDependencies": {
- "@types/react": "^18.3.12",
- "@types/react-dom": "^18.3.1",
- "@vitejs/plugin-react": "^6.0.1",
- "typescript": "^5.6.3",
- "vite": "^8.0.1",
+ "@types/node": "^22.10.0",
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "autoprefixer": "^10.4.20",
+ "bun-types": "1.3.0",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^3.4.13",
+ "tailwindcss-animate": "^1.0.7",
+ "typescript": "^5.7.0",
},
},
},
"packages": {
- "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
+ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
- "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
+ "@date-fns/tz": ["@date-fns/tz@1.2.0", "", {}, "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg=="],
- "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
+ "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
- "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
+ "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
- "@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
+ "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
- "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
+ "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="],
- "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
+ "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
- "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
+ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
- "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
+ "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
- "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
+ "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
- "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
+ "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
- "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
+ "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
- "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
+ "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
- "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
+ "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
- "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
+ "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
- "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
+ "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
- "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
+ "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
- "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
+ "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
- "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
+ "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
- "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
+ "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
- "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
+ "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
- "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+ "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
- "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
+ "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
- "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
+ "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
- "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
+ "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
- "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
+ "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
- "@wavesurfer/react": ["@wavesurfer/react@1.0.12", "", { "peerDependencies": { "react": "^18.2.0 || ^19.0.0", "wavesurfer.js": ">=7.7.14" } }, "sha512-BNHpz2ryKNVvJdxB47pCPUsNCsjb2pZRysg82M5djIiw0vsiSJwdlt5jaAfDo3vd5IWrcoK9OiPQKO9ZEVNpDQ=="],
+ "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
+
+ "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
+
+ "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
+
+ "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
+
+ "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
+
+ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@next/env": ["@next/env@16.2.1", "", {}, "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg=="],
+
+ "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q=="],
+
+ "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA=="],
+
+ "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw=="],
+
+ "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ=="],
+
+ "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg=="],
+
+ "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg=="],
+
+ "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA=="],
+
+ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg=="],
+
+ "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
+
+ "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
+
+ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
+
+ "@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="],
+
+ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.5", "", {}, "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg=="],
+
+ "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collapsible": "1.1.16", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-BpZJNmetujnGgUI6OX0jEhEmlA46WPqgub8Rv09Kyquwd0cc1ndMKpiPYCjmBU6KSSRPAMtgLpEoZSG/tdNIWQ=="],
+
+ "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dialog": "1.1.19", "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FA7n1f6D/DwGE0+AWxiY5LacNbbExQuEgMubeG06idEaH+mSLuf9dp/qBNqOnvbTQ+4gZ2ue1RATF1Ub91Mg5g=="],
+
+ "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A=="],
+
+ "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.2.2", "", { "dependencies": { "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sST0qh8GzOB7besQ3tMLWLyngnRuSk0gc/Hm+667KYKQFCt6Y6ZXv25WlqM7dIDK54ULCh5+CHmk4LIolzfz+A=="],
+
+ "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JroKHfQBfh+fDuzpPsBC+pESkhuq8ql4hljTguz8MWnS35cISr3d/Jhl9kYrB44FlDtxCArYdDvTx+BSsJ64rQ=="],
+
+ "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-opfXRe6nnzyGmCDPx+l1Aqo/RbqWtQal2FnsBqF9hhePp6j0LsRoBaRxcMOlTv+uYTJVtWYZKg9t9wTe+BA/ZA=="],
+
+ "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.12", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q=="],
+
+ "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="],
+
+ "@radix-ui/react-context": ["@radix-ui/react-context@1.2.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg=="],
+
+ "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-menu": "2.1.20", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-PS+gKE0z2prJ74Y0sM+brAGK4mYOHIR7TlcV5EJgUQ6E0xMvyswkK2X4yRqyganrzsRL+WCSKAPu0NQITICRWg=="],
+
+ "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg=="],
+
+ "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA=="],
+
+ "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-effect-event": "0.0.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA=="],
+
+ "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-menu": "2.1.20", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-slfm+rRaZRuQBvHq60lXvSVUPhid0IPtjSZzIuUlWZMUs01iYZNlGS3mJgRD3ChLQVBAYlKiL/tFyWGX+dz8Xw=="],
+
+ "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="],
+
+ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.12", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A=="],
+
+ "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2KTgMLQtKvicznQgbindEI2RZ3QbDIwU5gabjUPwFJsormjGDz+rUvO4NANmYwzEEpTcTONUt33vBHIfTIVSfw=="],
+
+ "@radix-ui/react-icons": ["@radix-ui/react-icons@1.3.2", "", { "peerDependencies": { "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" } }, "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g=="],
+
+ "@radix-ui/react-id": ["@radix-ui/react-id@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA=="],
+
+ "@radix-ui/react-label": ["@radix-ui/react-label@2.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ=="],
+
+ "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.15", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VsUrXxFe9d2ScbZF0fR/oPR1+qjyeLs5p0jzG8h90puMoA9bq4SirYlXbE+USRg9Q2qTeJSFNqjw2nts8jJe4w=="],
+
+ "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-K9HiuxZ6xCwSaHcIuUpxyhy4w5gpwzWjh9dHTSbMN3Ix4qAyVObS9RlU3zMycb0PO3v9Tpk0BXMwWvXOUbVXew=="],
+
+ "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jkrTdQVxnIB8fpn0NyyxW9CTB5aCXZZelVz5z+Xmii6g5WxMqS3fInNslZ63puP39+Puu4jYohUK31y3dT87gQ=="],
+
+ "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.3", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g=="],
+
+ "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.13", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA=="],
+
+ "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.7", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA=="],
+
+ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.7", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ=="],
+
+ "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.12", "", { "dependencies": { "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ZPHyI0JyzoH/rP0tq2uRaIZTj/4s8+kAbqPz+e2N8+ejHvwPJ889dHhqn+vh7PNvNeq+boAoH9yzqeoShzwF2w=="],
+
+ "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.15", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WwZFjWV4s3aC1QtR3k04R+oANHtX2q6fgKlc7MCEiDNlnTxCZ3H8k3mHtEgVlOejystwk1WQgarQhNOQZ2bK1g=="],
+
+ "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg=="],
+
+ "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.14", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bBODCWZK7JTbQLHs0uIP4f73wIWatakK4OS33UzkR1x897wu0PuO658a3f+6P2GEGyDzGYMuHRatMVoAk9WZTw=="],
+
+ "@radix-ui/react-select": ["@radix-ui/react-select@2.3.3", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-L5RQTXz6Anxsf9CCv+pTgiAsUpyVj7rJxsGtmhFaEOJ++cVfXucv4qWfsIO0AIB4NAhi3yovWGVMKKS1Xf1Wrg=="],
+
+ "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ=="],
+
+ "@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.3", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-CWVVj+XaTom0SKCqw1EUgb0NuiLwS+N3OFG73mVEezKEjgNIvZiu0EevMelSSU+CbX3owbqJweG2gPU31WGC5A=="],
+
+ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
+
+ "@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1+mlB4/lxJfk5tgJ4g+R5mUCbRpPE1T9+UsEyeLYbGgMtwiMgmuTnfKz4Mw1nHALHjuwyxw4MLd4cSHn6pNSlQ=="],
+
+ "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.15", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nRyXnrAVCwjeXcHbvEbLS6ndbTeKHG1RqCP4A8Gw5L4cemDzPXdD8rAmr6wet0v57R69wGvuIIsFjHSVkZiMzQ=="],
+
+ "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-SxfVZfVOibWKWdkf0Xx1awW2d09fQu4V4PXDY1j5hi4MVf7MWdJZqTBJMa1KWtOr1S6GGtCk02nniZ0Iia+dHw=="],
+
+ "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-U3HoftgWnmla78vzQbLvKKb7bUYJxoiiqYFzp1wu/TBMyDqMZSuCl3aRICsD6EfVEwcJD2mumGDGUXLFVqQHKA=="],
+
+ "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="],
+
+ "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.3", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA=="],
+
+ "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.3", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA=="],
+
+ "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A=="],
+
+ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="],
+
+ "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw=="],
+
+ "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.2", "", { "dependencies": { "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw=="],
+
+ "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w=="],
+
+ "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw=="],
+
+ "@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="],
+
+ "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="],
+
+ "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
+
+ "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
+
+ "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
+
+ "@tanstack/query-core": ["@tanstack/query-core@5.101.2", "", {}, "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw=="],
+
+ "@tanstack/react-query": ["@tanstack/react-query@5.101.2", "", { "dependencies": { "@tanstack/query-core": "5.101.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg=="],
+
+ "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.5", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A=="],
+
+ "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
+
+ "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
+
+ "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
+
+ "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
+
+ "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
+
+ "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
+
+ "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
+
+ "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
+
+ "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
+
+ "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
+
+ "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="],
+
+ "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
+
+ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+
+ "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
+
+ "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
+
+ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
+
+ "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
+
+ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
+
+ "attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="],
+
+ "autoprefixer": ["autoprefixer@10.5.2", "", { "dependencies": { "browserslist": "^4.28.4", "caniuse-lite": "^1.0.30001799", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q=="],
+
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="],
+
+ "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
+
+ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
+
+ "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="],
+
+ "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
+
+ "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
+
+ "caniuse-lite": ["caniuse-lite@1.0.30001805", "", {}, "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA=="],
+
+ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
+
+ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+
+ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
+
+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
+ "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
+
+ "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
+
+ "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+ "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
+
+ "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
+
+ "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
+
+ "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
+
+ "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
+
+ "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
+
+ "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
+
+ "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
+
+ "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
+
+ "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
+
+ "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
+
+ "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="],
+
+ "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
+
+ "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
+
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
+
+ "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
+
+ "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
+
+ "electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="],
+
+ "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
+
+ "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="],
+
+ "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
+
+ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+
+ "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="],
+
+ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
+
+ "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
+
+ "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
+
+ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
+
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+ "file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="],
+
+ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
+
+ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
+
+ "framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
+
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
- "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
+ "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
- "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
+ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
- "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
+ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
- "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
+ "immer": ["immer@11.1.11", "", {}, "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw=="],
- "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
+ "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="],
- "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
+ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
- "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
+ "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
- "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
+ "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
- "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
+ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
- "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
+ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
- "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
+ "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
+
+ "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
+
+ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
- "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
+ "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
- "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
+ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
- "lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="],
+ "lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
+
+ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
+
+ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
+
+ "motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="],
+
+ "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
+
+ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
+
+ "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
+
+ "next": ["next@16.2.1", "", { "dependencies": { "@next/env": "16.2.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.1", "@next/swc-darwin-x64": "16.2.1", "@next/swc-linux-arm64-gnu": "16.2.1", "@next/swc-linux-arm64-musl": "16.2.1", "@next/swc-linux-x64-gnu": "16.2.1", "@next/swc-linux-x64-musl": "16.2.1", "@next/swc-win32-arm64-msvc": "16.2.1", "@next/swc-win32-x64-msvc": "16.2.1", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q=="],
+
+ "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
+
+ "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
+
+ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
+
+ "nuqs": ["nuqs@2.9.0", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^5 || ^6 || ^7 || ^8", "react-router-dom": "^5 || ^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-1ckQBhVHaQYjLZ3kWhhH40A32lPJ7TNTQMa2T+SUvl5azyVt7swCQfUXt9xOXJV3YidWq8VHIHcMzVAJ0knRsw=="],
+
+ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
+
+ "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
- "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
- "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+ "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
- "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
+ "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
- "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
+ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
- "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
+ "postcss": ["postcss@8.5.17", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w=="],
- "rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
+ "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
- "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
+ "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
+
+ "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
+
+ "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
+
+ "postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="],
+
+ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
+
+ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
+
+ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
+
+ "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
+
+ "react-day-picker": ["react-day-picker@9.8.0", "", { "dependencies": { "@date-fns/tz": "1.2.0", "date-fns": "4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-E0yhhg7R+pdgbl/2toTb0xBhsEAtmAx1l7qjIWYfcxOy8w4rTSVfbtBoSzVVhPwKP/5E9iL38LivzoE3AQDhCQ=="],
+
+ "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
+
+ "react-dropzone": ["react-dropzone@15.0.0", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg=="],
+
+ "react-hotkeys-hook": ["react-hotkeys-hook@5.3.3", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-aswgyWUnE25hmhzHTfKDmKzsaSE5DJ4LKaU/o6rQSXkDd/1Bh9TfAFQbHkf6fLy11HvlYkp+cDDarGdhmCDhoQ=="],
+
+ "react-icons": ["react-icons@5.7.0", "", { "peerDependencies": { "react": "*" } }, "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw=="],
+
+ "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
+
+ "react-number-format": ["react-number-format@5.4.5", "", { "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw=="],
+
+ "react-redux": ["react-redux@9.3.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="],
+
+ "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
+
+ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
+
+ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
+
+ "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
+
+ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
+
+ "recharts": ["recharts@3.9.2", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^11.1.8", "react-redux": "8.x.x || 9.x.x", "reselect": "5.2.0", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw=="],
+
+ "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
+
+ "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
+
+ "reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="],
+
+ "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
+
+ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
+
+ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
+
+ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
+
+ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
+
+ "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
- "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
+
+ "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
+
+ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
+
+ "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
+
+ "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
+
+ "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
+
+ "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
+
+ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
+
+ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
+
+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+
+ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
+
+ "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
- "vite": ["vite@8.0.1", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw=="],
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
+
+ "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
+
+ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
+
+ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
+
+ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+
+ "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
+
+ "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
+
+ "wavesurfer.js": ["wavesurfer.js@7.12.10", "", {}, "sha512-M3AC5biFmvfy7Oxe8FN6I88H0z1c8Vuz81N6Oq2CY4kOumNDb6U1/0qg4pomjP619vJqdWADlgUcpOKvfGifng=="],
+
+ "@reduxjs/toolkit/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
+ "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+
+ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+
+ "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
- "wavesurfer.js": ["wavesurfer.js@7.12.4", "", {}, "sha512-b/+XnWfJejNdvNUmtm4M5QzQepHhUbTo+62wYybwdV1B/Sn9vHhgb1xckRm0rGY2ZefJwLkE7lYcKnLfIia4cQ=="],
+ "react-day-picker/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
- "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
+ "tinyglobby/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
}
}
diff --git a/frontend/index.html b/frontend/index.html
deleted file mode 100644
index fe2073e..0000000
--- a/frontend/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
- Speechcraft
-
-
-
-
-
-
-
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
new file mode 100644
index 0000000..b6ab13b
--- /dev/null
+++ b/frontend/next.config.ts
@@ -0,0 +1,22 @@
+import type { NextConfig } from "next";
+
+// Same-origin proxy to speechcraft's local FastAPI backend so the browser
+// hits /sc-api/* (no CORS) and audio range requests pass through cleanly.
+const speechcraftBackend =
+ process.env.SPEECHCRAFT_BACKEND_URL || "http://127.0.0.1:8010";
+
+const nextConfig: NextConfig = {
+ reactStrictMode: true,
+ // Local single-user tool: keep builds unblocked while we iterate.
+ typescript: { ignoreBuildErrors: true },
+ async rewrites() {
+ return [
+ {
+ source: "/sc-api/:path*",
+ destination: `${speechcraftBackend}/:path*`,
+ },
+ ];
+ },
+};
+
+export default nextConfig;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
deleted file mode 100644
index 27ec6b4..0000000
--- a/frontend/package-lock.json
+++ /dev/null
@@ -1,2196 +0,0 @@
-{
- "name": "speechcraft-frontend",
- "version": "0.1.0",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "speechcraft-frontend",
- "version": "0.1.0",
- "dependencies": {
- "@wavesurfer/react": "^1.0.12",
- "lucide-react": "^0.577.0",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
- "wavesurfer.js": "^7.12.1"
- },
- "devDependencies": {
- "@testing-library/jest-dom": "^6.9.1",
- "@testing-library/react": "^16.3.2",
- "@types/react": "^18.3.12",
- "@types/react-dom": "^18.3.1",
- "@vitejs/plugin-react": "^6.0.1",
- "jsdom": "^29.0.1",
- "typescript": "^5.6.3",
- "vite": "^8.0.1",
- "vitest": "^4.1.0"
- }
- },
- "node_modules/@adobe/css-tools": {
- "version": "4.4.4",
- "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
- "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@asamuzakjp/css-color": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
- "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@csstools/css-calc": "^3.1.1",
- "@csstools/css-color-parser": "^4.0.2",
- "@csstools/css-parser-algorithms": "^4.0.0",
- "@csstools/css-tokenizer": "^4.0.0",
- "lru-cache": "^11.2.6"
- },
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
- }
- },
- "node_modules/@asamuzakjp/dom-selector": {
- "version": "7.0.4",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz",
- "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@asamuzakjp/nwsapi": "^2.3.9",
- "bidi-js": "^1.0.3",
- "css-tree": "^3.2.1",
- "is-potential-custom-element-name": "^1.0.1",
- "lru-cache": "^11.2.7"
- },
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
- }
- },
- "node_modules/@asamuzakjp/nwsapi": {
- "version": "2.3.9",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
- "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@babel/code-frame": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
- "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-validator-identifier": "^7.28.5",
- "js-tokens": "^4.0.0",
- "picocolors": "^1.1.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
- "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/runtime": {
- "version": "7.29.2",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
- "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@bramus/specificity": {
- "version": "2.4.2",
- "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
- "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "css-tree": "^3.0.0"
- },
- "bin": {
- "specificity": "bin/cli.js"
- }
- },
- "node_modules/@csstools/color-helpers": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
- "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT-0",
- "engines": {
- "node": ">=20.19.0"
- }
- },
- "node_modules/@csstools/css-calc": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
- "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=20.19.0"
- },
- "peerDependencies": {
- "@csstools/css-parser-algorithms": "^4.0.0",
- "@csstools/css-tokenizer": "^4.0.0"
- }
- },
- "node_modules/@csstools/css-color-parser": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
- "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "@csstools/color-helpers": "^6.0.2",
- "@csstools/css-calc": "^3.1.1"
- },
- "engines": {
- "node": ">=20.19.0"
- },
- "peerDependencies": {
- "@csstools/css-parser-algorithms": "^4.0.0",
- "@csstools/css-tokenizer": "^4.0.0"
- }
- },
- "node_modules/@csstools/css-parser-algorithms": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
- "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=20.19.0"
- },
- "peerDependencies": {
- "@csstools/css-tokenizer": "^4.0.0"
- }
- },
- "node_modules/@csstools/css-syntax-patches-for-csstree": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz",
- "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT-0",
- "peerDependencies": {
- "css-tree": "^3.2.1"
- },
- "peerDependenciesMeta": {
- "css-tree": {
- "optional": true
- }
- }
- },
- "node_modules/@csstools/css-tokenizer": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
- "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=20.19.0"
- }
- },
- "node_modules/@emnapi/core": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
- "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/wasi-threads": "1.2.0",
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.9.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
- "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/wasi-threads": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
- "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@exodus/bytes": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
- "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
- },
- "peerDependencies": {
- "@noble/hashes": "^1.8.0 || ^2.0.0"
- },
- "peerDependenciesMeta": {
- "@noble/hashes": {
- "optional": true
- }
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@napi-rs/wasm-runtime": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
- "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/core": "^1.7.1",
- "@emnapi/runtime": "^1.7.1",
- "@tybys/wasm-util": "^0.10.1"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Brooooooklyn"
- }
- },
- "node_modules/@oxc-project/types": {
- "version": "0.120.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz",
- "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/Boshen"
- }
- },
- "node_modules/@rolldown/binding-android-arm64": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
- "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz",
- "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz",
- "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz",
- "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz",
- "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz",
- "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz",
- "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz",
- "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz",
- "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz",
- "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz",
- "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz",
- "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz",
- "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==",
- "cpu": [
- "wasm32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@napi-rs/wasm-runtime": "^1.1.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz",
- "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz",
- "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-rc.7",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
- "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@standard-schema/spec": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
- "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@testing-library/dom": {
- "version": "10.4.1",
- "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
- "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@babel/code-frame": "^7.10.4",
- "@babel/runtime": "^7.12.5",
- "@types/aria-query": "^5.0.1",
- "aria-query": "5.3.0",
- "dom-accessibility-api": "^0.5.9",
- "lz-string": "^1.5.0",
- "picocolors": "1.1.1",
- "pretty-format": "^27.0.2"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@testing-library/jest-dom": {
- "version": "6.9.1",
- "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
- "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@adobe/css-tools": "^4.4.0",
- "aria-query": "^5.0.0",
- "css.escape": "^1.5.1",
- "dom-accessibility-api": "^0.6.3",
- "picocolors": "^1.1.1",
- "redent": "^3.0.0"
- },
- "engines": {
- "node": ">=14",
- "npm": ">=6",
- "yarn": ">=1"
- }
- },
- "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
- "version": "0.6.3",
- "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
- "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@testing-library/react": {
- "version": "16.3.2",
- "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
- "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.12.5"
- },
- "engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "@testing-library/dom": "^10.0.0",
- "@types/react": "^18.0.0 || ^19.0.0",
- "@types/react-dom": "^18.0.0 || ^19.0.0",
- "react": "^18.0.0 || ^19.0.0",
- "react-dom": "^18.0.0 || ^19.0.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
- "node_modules/@tybys/wasm-util": {
- "version": "0.10.1",
- "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
- "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@types/aria-query": {
- "version": "5.0.4",
- "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
- "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/chai": {
- "version": "5.2.3",
- "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
- "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/deep-eql": "*",
- "assertion-error": "^2.0.1"
- }
- },
- "node_modules/@types/deep-eql": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
- "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/estree": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
- "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/prop-types": {
- "version": "15.7.15",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/react": {
- "version": "18.3.28",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@types/prop-types": "*",
- "csstype": "^3.2.2"
- }
- },
- "node_modules/@types/react-dom": {
- "version": "18.3.7",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "peerDependencies": {
- "@types/react": "^18.0.0"
- }
- },
- "node_modules/@vitejs/plugin-react": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
- "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@rolldown/pluginutils": "1.0.0-rc.7"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "peerDependencies": {
- "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
- "babel-plugin-react-compiler": "^1.0.0",
- "vite": "^8.0.0"
- },
- "peerDependenciesMeta": {
- "@rolldown/plugin-babel": {
- "optional": true
- },
- "babel-plugin-react-compiler": {
- "optional": true
- }
- }
- },
- "node_modules/@vitest/expect": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
- "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@standard-schema/spec": "^1.1.0",
- "@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.0",
- "@vitest/utils": "4.1.0",
- "chai": "^6.2.2",
- "tinyrainbow": "^3.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/mocker": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz",
- "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/spy": "4.1.0",
- "estree-walker": "^3.0.3",
- "magic-string": "^0.30.21"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "msw": "^2.4.9",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
- },
- "peerDependenciesMeta": {
- "msw": {
- "optional": true
- },
- "vite": {
- "optional": true
- }
- }
- },
- "node_modules/@vitest/pretty-format": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz",
- "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tinyrainbow": "^3.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/runner": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz",
- "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/utils": "4.1.0",
- "pathe": "^2.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/snapshot": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz",
- "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/pretty-format": "4.1.0",
- "@vitest/utils": "4.1.0",
- "magic-string": "^0.30.21",
- "pathe": "^2.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/spy": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz",
- "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/utils": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz",
- "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/pretty-format": "4.1.0",
- "convert-source-map": "^2.0.0",
- "tinyrainbow": "^3.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@wavesurfer/react": {
- "version": "1.0.12",
- "resolved": "https://registry.npmjs.org/@wavesurfer/react/-/react-1.0.12.tgz",
- "integrity": "sha512-BNHpz2ryKNVvJdxB47pCPUsNCsjb2pZRysg82M5djIiw0vsiSJwdlt5jaAfDo3vd5IWrcoK9OiPQKO9ZEVNpDQ==",
- "license": "BSD-3-Clause",
- "peerDependencies": {
- "react": "^18.2.0 || ^19.0.0",
- "wavesurfer.js": ">=7.7.14"
- }
- },
- "node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/ansi-styles": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
- "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/aria-query": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
- "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "dequal": "^2.0.3"
- }
- },
- "node_modules/assertion-error": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
- "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/bidi-js": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
- "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "require-from-string": "^2.0.2"
- }
- },
- "node_modules/chai": {
- "version": "6.2.2",
- "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
- "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/convert-source-map": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
- "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/css-tree": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
- "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mdn-data": "2.27.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
- }
- },
- "node_modules/css.escape": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
- "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/csstype": {
- "version": "3.2.3",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/data-urls": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
- "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "whatwg-mimetype": "^5.0.0",
- "whatwg-url": "^16.0.0"
- },
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
- }
- },
- "node_modules/decimal.js": {
- "version": "10.6.0",
- "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
- "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/dequal": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
- "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/detect-libc": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/dom-accessibility-api": {
- "version": "0.5.16",
- "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
- "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/entities": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
- "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=0.12"
- },
- "funding": {
- "url": "https://github.com/fb55/entities?sponsor=1"
- }
- },
- "node_modules/es-module-lexer": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
- "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/estree-walker": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
- "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0"
- }
- },
- "node_modules/expect-type": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
- "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=12.0.0"
- }
- },
- "node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/html-encoding-sniffer": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
- "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@exodus/bytes": "^1.6.0"
- },
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
- }
- },
- "node_modules/indent-string": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
- "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/is-potential-custom-element-name": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
- "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/js-tokens": {
- "version": "4.0.0",
- "license": "MIT"
- },
- "node_modules/jsdom": {
- "version": "29.0.1",
- "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz",
- "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@asamuzakjp/css-color": "^5.0.1",
- "@asamuzakjp/dom-selector": "^7.0.3",
- "@bramus/specificity": "^2.4.2",
- "@csstools/css-syntax-patches-for-csstree": "^1.1.1",
- "@exodus/bytes": "^1.15.0",
- "css-tree": "^3.2.1",
- "data-urls": "^7.0.0",
- "decimal.js": "^10.6.0",
- "html-encoding-sniffer": "^6.0.0",
- "is-potential-custom-element-name": "^1.0.1",
- "lru-cache": "^11.2.7",
- "parse5": "^8.0.0",
- "saxes": "^6.0.0",
- "symbol-tree": "^3.2.4",
- "tough-cookie": "^6.0.1",
- "undici": "^7.24.5",
- "w3c-xmlserializer": "^5.0.0",
- "webidl-conversions": "^8.0.1",
- "whatwg-mimetype": "^5.0.0",
- "whatwg-url": "^16.0.1",
- "xml-name-validator": "^5.0.0"
- },
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
- },
- "peerDependencies": {
- "canvas": "^3.0.0"
- },
- "peerDependenciesMeta": {
- "canvas": {
- "optional": true
- }
- }
- },
- "node_modules/lightningcss": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
- "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
- "dev": true,
- "license": "MPL-2.0",
- "dependencies": {
- "detect-libc": "^2.0.3"
- },
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- },
- "optionalDependencies": {
- "lightningcss-android-arm64": "1.32.0",
- "lightningcss-darwin-arm64": "1.32.0",
- "lightningcss-darwin-x64": "1.32.0",
- "lightningcss-freebsd-x64": "1.32.0",
- "lightningcss-linux-arm-gnueabihf": "1.32.0",
- "lightningcss-linux-arm64-gnu": "1.32.0",
- "lightningcss-linux-arm64-musl": "1.32.0",
- "lightningcss-linux-x64-gnu": "1.32.0",
- "lightningcss-linux-x64-musl": "1.32.0",
- "lightningcss-win32-arm64-msvc": "1.32.0",
- "lightningcss-win32-x64-msvc": "1.32.0"
- }
- },
- "node_modules/lightningcss-android-arm64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
- "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-arm64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
- "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-x64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
- "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-freebsd-x64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
- "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm-gnueabihf": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
- "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-gnu": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
- "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-musl": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
- "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-gnu": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
- "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-musl": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
- "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-arm64-msvc": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
- "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-x64-msvc": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
- "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/loose-envify": {
- "version": "1.4.0",
- "license": "MIT",
- "dependencies": {
- "js-tokens": "^3.0.0 || ^4.0.0"
- },
- "bin": {
- "loose-envify": "cli.js"
- }
- },
- "node_modules/lru-cache": {
- "version": "11.2.7",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
- "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/lucide-react": {
- "version": "0.577.0",
- "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz",
- "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
- "license": "ISC",
- "peerDependencies": {
- "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- }
- },
- "node_modules/lz-string": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
- "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "lz-string": "bin/bin.js"
- }
- },
- "node_modules/magic-string": {
- "version": "0.30.21",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
- "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.5"
- }
- },
- "node_modules/mdn-data": {
- "version": "2.27.1",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
- "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
- "dev": true,
- "license": "CC0-1.0"
- },
- "node_modules/min-indent": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
- "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
- "engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
- }
- },
- "node_modules/obug": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
- "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
- "dev": true,
- "funding": [
- "https://github.com/sponsors/sxzz",
- "https://opencollective.com/debug"
- ],
- "license": "MIT"
- },
- "node_modules/parse5": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
- "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "entities": "^6.0.0"
- },
- "funding": {
- "url": "https://github.com/inikulin/parse5?sponsor=1"
- }
- },
- "node_modules/pathe": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
- "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/picocolors": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/postcss": {
- "version": "8.5.8",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
- "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.11",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/pretty-format": {
- "version": "27.5.1",
- "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
- "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1",
- "ansi-styles": "^5.0.0",
- "react-is": "^17.0.1"
- },
- "engines": {
- "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
- }
- },
- "node_modules/punycode": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
- "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/react": {
- "version": "18.3.1",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "loose-envify": "^1.1.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/react-dom": {
- "version": "18.3.1",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "loose-envify": "^1.1.0",
- "scheduler": "^0.23.2"
- },
- "peerDependencies": {
- "react": "^18.3.1"
- }
- },
- "node_modules/react-is": {
- "version": "17.0.2",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
- "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/redent": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
- "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "indent-string": "^4.0.0",
- "strip-indent": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/require-from-string": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
- "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/rolldown": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz",
- "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@oxc-project/types": "=0.120.0",
- "@rolldown/pluginutils": "1.0.0-rc.10"
- },
- "bin": {
- "rolldown": "bin/cli.mjs"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.0.0-rc.10",
- "@rolldown/binding-darwin-arm64": "1.0.0-rc.10",
- "@rolldown/binding-darwin-x64": "1.0.0-rc.10",
- "@rolldown/binding-freebsd-x64": "1.0.0-rc.10",
- "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10",
- "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10",
- "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10",
- "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10",
- "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10",
- "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10",
- "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10",
- "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10",
- "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10",
- "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10",
- "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10"
- }
- },
- "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-rc.10",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz",
- "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/saxes": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
- "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "xmlchars": "^2.2.0"
- },
- "engines": {
- "node": ">=v12.22.7"
- }
- },
- "node_modules/scheduler": {
- "version": "0.23.2",
- "license": "MIT",
- "dependencies": {
- "loose-envify": "^1.1.0"
- }
- },
- "node_modules/siginfo": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
- "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/stackback": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
- "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/std-env": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
- "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/strip-indent": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
- "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "min-indent": "^1.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/symbol-tree": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
- "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/tinybench": {
- "version": "2.9.0",
- "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
- "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/tinyexec": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
- "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/tinyglobby": {
- "version": "0.2.15",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
- "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fdir": "^6.5.0",
- "picomatch": "^4.0.3"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/SuperchupuDev"
- }
- },
- "node_modules/tinyrainbow": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
- "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/tldts": {
- "version": "7.0.27",
- "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz",
- "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tldts-core": "^7.0.27"
- },
- "bin": {
- "tldts": "bin/cli.js"
- }
- },
- "node_modules/tldts-core": {
- "version": "7.0.27",
- "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz",
- "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/tough-cookie": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
- "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "tldts": "^7.0.5"
- },
- "engines": {
- "node": ">=16"
- }
- },
- "node_modules/tr46": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
- "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "punycode": "^2.3.1"
- },
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/tslib": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
- "license": "0BSD",
- "optional": true
- },
- "node_modules/typescript": {
- "version": "5.9.3",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/undici": {
- "version": "7.24.5",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz",
- "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=20.18.1"
- }
- },
- "node_modules/vite": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
- "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "lightningcss": "^1.32.0",
- "picomatch": "^4.0.3",
- "postcss": "^8.5.8",
- "rolldown": "1.0.0-rc.10",
- "tinyglobby": "^0.2.15"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^20.19.0 || >=22.12.0",
- "@vitejs/devtools": "^0.1.0",
- "esbuild": "^0.27.0",
- "jiti": ">=1.21.0",
- "less": "^4.0.0",
- "sass": "^1.70.0",
- "sass-embedded": "^1.70.0",
- "stylus": ">=0.54.8",
- "sugarss": "^5.0.0",
- "terser": "^5.16.0",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "@vitejs/devtools": {
- "optional": true
- },
- "esbuild": {
- "optional": true
- },
- "jiti": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "sass-embedded": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "terser": {
- "optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
- }
- }
- },
- "node_modules/vitest": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz",
- "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/expect": "4.1.0",
- "@vitest/mocker": "4.1.0",
- "@vitest/pretty-format": "4.1.0",
- "@vitest/runner": "4.1.0",
- "@vitest/snapshot": "4.1.0",
- "@vitest/spy": "4.1.0",
- "@vitest/utils": "4.1.0",
- "es-module-lexer": "^2.0.0",
- "expect-type": "^1.3.0",
- "magic-string": "^0.30.21",
- "obug": "^2.1.1",
- "pathe": "^2.0.3",
- "picomatch": "^4.0.3",
- "std-env": "^4.0.0-rc.1",
- "tinybench": "^2.9.0",
- "tinyexec": "^1.0.2",
- "tinyglobby": "^0.2.15",
- "tinyrainbow": "^3.0.3",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0",
- "why-is-node-running": "^2.3.0"
- },
- "bin": {
- "vitest": "vitest.mjs"
- },
- "engines": {
- "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "@edge-runtime/vm": "*",
- "@opentelemetry/api": "^1.9.0",
- "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.0",
- "@vitest/browser-preview": "4.1.0",
- "@vitest/browser-webdriverio": "4.1.0",
- "@vitest/ui": "4.1.0",
- "happy-dom": "*",
- "jsdom": "*",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0"
- },
- "peerDependenciesMeta": {
- "@edge-runtime/vm": {
- "optional": true
- },
- "@opentelemetry/api": {
- "optional": true
- },
- "@types/node": {
- "optional": true
- },
- "@vitest/browser-playwright": {
- "optional": true
- },
- "@vitest/browser-preview": {
- "optional": true
- },
- "@vitest/browser-webdriverio": {
- "optional": true
- },
- "@vitest/ui": {
- "optional": true
- },
- "happy-dom": {
- "optional": true
- },
- "jsdom": {
- "optional": true
- },
- "vite": {
- "optional": false
- }
- }
- },
- "node_modules/w3c-xmlserializer": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
- "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "xml-name-validator": "^5.0.0"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/wavesurfer.js": {
- "version": "7.12.1",
- "resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.1.tgz",
- "integrity": "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg==",
- "license": "BSD-3-Clause",
- "peer": true
- },
- "node_modules/webidl-conversions": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
- "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/whatwg-mimetype": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
- "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/whatwg-url": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
- "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@exodus/bytes": "^1.11.0",
- "tr46": "^6.0.0",
- "webidl-conversions": "^8.0.1"
- },
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
- }
- },
- "node_modules/why-is-node-running": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
- "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "siginfo": "^2.0.0",
- "stackback": "0.0.2"
- },
- "bin": {
- "why-is-node-running": "cli.js"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/xml-name-validator": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
- "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/xmlchars": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
- "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
- "dev": true,
- "license": "MIT"
- }
- }
-}
diff --git a/frontend/package.json b/frontend/package.json
index 8face48..193e443 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,30 +1,72 @@
{
"name": "speechcraft-frontend",
- "private": true,
"version": "0.1.0",
- "type": "module",
+ "private": true,
"scripts": {
- "dev": "vite --host 127.0.0.1",
- "build": "tsc -b && vite build",
- "preview": "vite preview",
- "test": "vitest run"
+ "dev": "next dev -p 3002",
+ "build": "next build",
+ "start": "next start -p 3002",
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
- "@wavesurfer/react": "^1.0.12",
- "lucide-react": "^0.577.0",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
+ "@radix-ui/react-accordion": "^1.2.11",
+ "@radix-ui/react-alert-dialog": "^1.1.14",
+ "@radix-ui/react-avatar": "^1.1.10",
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-collapsible": "^1.1.12",
+ "@radix-ui/react-context-menu": "^2.2.15",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-hover-card": "^1.1.15",
+ "@radix-ui/react-icons": "^1.3.2",
+ "@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-navigation-menu": "^1.2.13",
+ "@radix-ui/react-popover": "^1.1.14",
+ "@radix-ui/react-progress": "^1.1.7",
+ "@radix-ui/react-radio-group": "^1.3.7",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-separator": "^1.1.8",
+ "@radix-ui/react-slider": "^1.3.5",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-switch": "^1.2.5",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-toast": "^1.2.14",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@tanstack/react-query": "^5.95.2",
+ "@tanstack/react-virtual": "^3.13.23",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "1.1.1",
+ "date-fns": "^4.1.0",
+ "embla-carousel-react": "^8.6.0",
+ "framer-motion": "^12.34.0",
+ "input-otp": "^1.4.2",
+ "lucide-react": "^0.562.0",
+ "next": "16.2.1",
+ "next-themes": "^0.4.6",
+ "nuqs": "^2.8.9",
+ "react": "^19.2.0",
+ "react-day-picker": "9.8.0",
+ "react-dom": "^19.2.0",
+ "react-dropzone": "^15.0.0",
+ "react-hotkeys-hook": "^5.2.4",
+ "react-icons": "^5.5.0",
+ "react-number-format": "^5.4.4",
+ "recharts": "^3.6.0",
+ "tailwind-merge": "3.4.0",
+ "vaul": "^1.1.2",
"wavesurfer.js": "^7.12.1"
},
"devDependencies": {
- "@testing-library/jest-dom": "^6.9.1",
- "@testing-library/react": "^16.3.2",
- "@types/react": "^18.3.12",
- "@types/react-dom": "^18.3.1",
- "@vitejs/plugin-react": "^6.0.1",
- "jsdom": "^29.0.1",
- "typescript": "^5.6.3",
- "vite": "^8.0.1",
- "vitest": "^4.1.0"
+ "@types/node": "^22.10.0",
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "autoprefixer": "^10.4.20",
+ "bun-types": "1.3.0",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^3.4.13",
+ "tailwindcss-animate": "^1.0.7",
+ "typescript": "^5.7.0"
}
}
diff --git a/frontend/postcss.config.cjs b/frontend/postcss.config.cjs
new file mode 100644
index 0000000..12a703d
--- /dev/null
+++ b/frontend/postcss.config.cjs
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
deleted file mode 100644
index 27ba32f..0000000
--- a/frontend/src/App.tsx
+++ /dev/null
@@ -1,603 +0,0 @@
-import { useEffect, useMemo, useReducer, useState, type ReactNode } from "react";
-import { Settings2 } from "lucide-react";
-import { ApiError, fetchProjects } from "./api";
-import {
- PipelineProvider,
- initialPipelineSelection,
- pipelineSelectionReducer,
- type PipelineSelectionState,
- type PipelineStage,
-} from "./pipeline/PipelineContext";
-import ExportPage from "./pages/ExportPage";
-import IngestPage from "./pages/IngestPage";
-import LabelPage from "./pages/LabelPage";
-import OverviewPage from "./pages/OverviewPage";
-import ProcessingPage from "./pages/ProcessingPage";
-import QcPage from "./pages/QcPage";
-import ReferencePage from "./pages/ReferencePage";
-import SpeakersPage from "./pages/SpeakersPage";
-import SlicerPage from "./pages/SlicerPage";
-import type { ClipLabItemRef, Project } from "./types";
-
-type AppStep = PipelineStage | "reference";
-type ProjectLoadStatus = "loading" | "ready" | "error";
-
-type AppRoute = {
- step: AppStep;
- projectId: string | null;
- clipItem: ClipLabItemRef | null;
-};
-
-type AppLocationState = {
- route: AppRoute;
- pipelineSelection: PipelineSelectionState;
-};
-
-type StepDefinition = {
- id: AppStep;
- label: string;
- shortLabel: string;
- glyph: string;
- tone: string;
-};
-
-type PageHeaderContent = {
- eyebrow: string;
- title: string;
- description: string;
-};
-
-const stepDefinitions: StepDefinition[] = [
- { id: "ingest", label: "Ingest", shortLabel: "In", glyph: "I", tone: "Sources first" },
- { id: "overview", label: "Overview", shortLabel: "Ov", glyph: "O", tone: "Raw recordings and prep" },
- { id: "speakers", label: "Speakers", shortLabel: "Sp", glyph: "S", tone: "Detect and choose the target voice" },
- { id: "processing", label: "Processing", shortLabel: "Pr", glyph: "P", tone: "Align the selected speaker" },
- { id: "slicer", label: "Slicer", shortLabel: "Sl", glyph: "C", tone: "Generate candidate review clips" },
- { id: "qc", label: "QC", shortLabel: "QC", glyph: "Q", tone: "Machine triage for one run" },
- { id: "lab", label: "Lab", shortLabel: "La", glyph: "L", tone: "Human review and override" },
- { id: "export", label: "Export", shortLabel: "Ex", glyph: "E", tone: "Emit training-ready data" },
-];
-
-function getErrorMessage(error: unknown, fallback: string): string {
- if (error instanceof ApiError) {
- return error.message;
- }
-
- if (error instanceof Error && error.message.trim()) {
- return error.message;
- }
-
- return fallback;
-}
-
-function isAppStep(value: string | null): value is AppStep {
- return value === "reference" || stepDefinitions.some((step) => step.id === value);
-}
-
-function readPipelineSelectionFromSearch(
- searchParams: URLSearchParams,
- step: AppStep,
-): PipelineSelectionState {
- const processingRunId = searchParams.get("processingRun")?.trim() || null;
- const speakersRunId = searchParams.get("speakersRun")?.trim() || null;
- const runId = searchParams.get("run")?.trim() || null;
-
- const selectedSpeakersRunId = step === "speakers" ? speakersRunId : null;
- const selectedSlicerDatasetRunId = step === "slicer" ? runId : null;
- const selectedQcDatasetRunId = step === "qc" ? runId : null;
- const selectedLabDatasetRunId = step === "lab" ? runId : null;
-
- return {
- selectedSpeakersRunId,
- selectedProcessingRunId: processingRunId,
- selectedSlicerDatasetRunId,
- selectedQcDatasetRunId,
- selectedLabDatasetRunId,
- };
-}
-
-function readLocationState(): AppLocationState {
- const path = window.location.pathname.replace(/^\/+|\/+$/g, "");
- const legacyStepMap: Partial> = {
- enhance: "overview",
- segment: "slicer",
- label: "lab",
- train: "export",
- deploy: "export",
- };
- const maybeStep = path.length > 0 ? legacyStepMap[path] ?? path : "ingest";
- const step = isAppStep(maybeStep) ? maybeStep : "ingest";
- const searchParams = new URLSearchParams(window.location.search);
- const projectId = searchParams.get("project")?.trim() ?? null;
- const clipId = searchParams.get("clip_id")?.trim() ?? null;
- const clipItem = clipId ? ({ id: clipId } satisfies ClipLabItemRef) : null;
-
- return {
- route: {
- step,
- projectId: projectId && projectId.length > 0 ? projectId : null,
- clipItem,
- },
- pipelineSelection: readPipelineSelectionFromSearch(searchParams, step),
- };
-}
-
-function writeRouteToLocation(
- route: AppRoute,
- pipelineSelection: PipelineSelectionState,
- replace = false,
-) {
- const url = new URL(window.location.origin);
- url.pathname = route.step === "ingest" ? "/" : `/${route.step}`;
- if (route.projectId) {
- url.searchParams.set("project", route.projectId);
- } else {
- url.searchParams.delete("project");
- }
- if (route.clipItem) {
- url.searchParams.set("clip_id", route.clipItem.id);
- } else {
- url.searchParams.delete("clip_id");
- }
-
- if (route.step === "speakers" && pipelineSelection.selectedSpeakersRunId) {
- url.searchParams.set("speakersRun", pipelineSelection.selectedSpeakersRunId);
- } else {
- url.searchParams.delete("speakersRun");
- }
-
- if (route.step === "processing" && pipelineSelection.selectedProcessingRunId) {
- url.searchParams.set("processingRun", pipelineSelection.selectedProcessingRunId);
- } else {
- url.searchParams.delete("processingRun");
- }
-
- if (route.step === "slicer" && pipelineSelection.selectedSlicerDatasetRunId) {
- url.searchParams.set("run", pipelineSelection.selectedSlicerDatasetRunId);
- } else if (route.step === "qc" && pipelineSelection.selectedQcDatasetRunId) {
- url.searchParams.set("run", pipelineSelection.selectedQcDatasetRunId);
- } else if (route.step === "lab" && pipelineSelection.selectedLabDatasetRunId) {
- url.searchParams.set("run", pipelineSelection.selectedLabDatasetRunId);
- } else {
- url.searchParams.delete("run");
- }
-
- if (replace) {
- window.history.replaceState({}, "", url);
- return;
- }
-
- window.history.pushState({}, "", url);
-}
-
-function getStepIndex(step: AppStep): number {
- return stepDefinitions.findIndex((entry) => entry.id === step);
-}
-
-function getPageHeaderContent(step: AppStep, activeProject: Project | null): PageHeaderContent {
- if (step === "ingest") {
- return {
- eyebrow: "Step 01",
- title: activeProject?.name ?? "No project selected",
- description:
- "Ingest is the new home base: set up model access, point Speechcraft at source folders, and define where assets will sync next.",
- };
- }
-
- if (step === "overview") {
- return {
- eyebrow: "Step 02",
- title: "Overview",
- description: "Inspect imported raw recordings and manage explicit preparation state.",
- };
- }
-
- if (step === "speakers") {
- return {
- eyebrow: "Step 03",
- title: "Speakers",
- description: "Run speech detection and diarization, audition samples, and choose the target voice.",
- };
- }
-
- if (step === "slicer") {
- return {
- eyebrow: "Step 05",
- title: "Slicer",
- description: "Generate candidate review clips from aligned runs and tune SafeCutPoint assembly.",
- };
- }
-
- if (step === "processing") {
- return {
- eyebrow: "Step 04",
- title: "Processing",
- description: "Process the chosen speaker through buffers, ASR, MFA, and alignment QC before slicing.",
- };
- }
-
- if (step === "qc") {
- return {
- eyebrow: "Step 06",
- title: "QC",
- description: "Run machine triage for one selected slicer run without claiming human approval.",
- };
- }
-
- if (step === "lab") {
- return {
- eyebrow: "Step 07",
- title: activeProject?.name ?? "Lab",
- description: "Manual slice review, transcript repair, and human overrides live here.",
- };
- }
-
- if (step === "reference") {
- return {
- eyebrow: "Reference",
- title: activeProject?.name ?? "Reference Workstation",
- description:
- "Reference remains available as its existing workstation, outside the current sprint path.",
- };
- }
-
- return {
- eyebrow: "Step 08",
- title: "Export",
- description: "Emit the selected reviewed or machine-triaged dataset for downstream training.",
- };
-}
-
-export default function App() {
- const [route, setRoute] = useState(() => readLocationState().route);
- const [projectLoadStatus, setProjectLoadStatus] = useState("loading");
- const [projectLoadError, setProjectLoadError] = useState(null);
- const [projects, setProjects] = useState([]);
- const [pageHeaderActions, setPageHeaderActions] = useState(null);
- const [pipelineSelection, dispatchPipelineSelection] = useReducer(
- pipelineSelectionReducer,
- undefined,
- () => readLocationState().pipelineSelection,
- );
-
- useEffect(() => {
- const handlePopState = () => {
- const nextLocationState = readLocationState();
- setRoute(nextLocationState.route);
- dispatchPipelineSelection({ type: "replace", state: nextLocationState.pipelineSelection });
- };
-
- window.addEventListener("popstate", handlePopState);
- return () => {
- window.removeEventListener("popstate", handlePopState);
- };
- }, []);
-
- async function loadProjects(preferredProjectId?: string | null, routeSnapshot = route) {
- setProjectLoadStatus("loading");
- setProjectLoadError(null);
-
- try {
- const nextProjects = await fetchProjects();
- const sortedProjects = [...nextProjects].sort(
- (left, right) => new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime(),
- );
- const fallbackProjectId = sortedProjects[0]?.id ?? null;
- const selectedProjectId =
- preferredProjectId && nextProjects.some((project) => project.id === preferredProjectId)
- ? preferredProjectId
- : routeSnapshot.projectId && nextProjects.some((project) => project.id === routeSnapshot.projectId)
- ? routeSnapshot.projectId
- : fallbackProjectId;
-
- setProjects(sortedProjects);
- setProjectLoadStatus("ready");
-
- if (selectedProjectId !== routeSnapshot.projectId) {
- dispatchPipelineSelection({ type: "reset" });
- const nextRoute = { ...routeSnapshot, projectId: selectedProjectId };
- setRoute(nextRoute);
- writeRouteToLocation(nextRoute, initialPipelineSelection, true);
- }
- } catch (error) {
- setProjects([]);
- setProjectLoadStatus("error");
- dispatchPipelineSelection({ type: "reset" });
- setProjectLoadError(
- getErrorMessage(error, "The app could not load the project list from the backend."),
- );
- }
- }
-
- useEffect(() => {
- void loadProjects(route.projectId);
- }, []);
-
- const activeProject = useMemo(
- () => projects.find((project) => project.id === route.projectId) ?? null,
- [projects, route.projectId],
- );
- const activeStepIndex = getStepIndex(route.step);
- const visibleStepIndex = activeStepIndex >= 0 ? activeStepIndex : 0;
- const pageHeaderContent = useMemo(
- () => getPageHeaderContent(route.step, activeProject),
- [route.step, activeProject],
- );
-
- useEffect(() => {
- setPageHeaderActions(null);
- }, [route.step]);
-
- function navigate(nextStep: AppStep, nextProjectId = route.projectId) {
- let nextPipelineSelection = pipelineSelection;
- if ((nextProjectId ?? null) !== route.projectId) {
- nextPipelineSelection = initialPipelineSelection;
- dispatchPipelineSelection({ type: "reset" });
- }
-
- const nextRoute = { step: nextStep, projectId: nextProjectId ?? null, clipItem: route.clipItem };
- setRoute(nextRoute);
- writeRouteToLocation(nextRoute, nextPipelineSelection);
- }
-
- function handleProjectChange(nextProjectId: string) {
- dispatchPipelineSelection({ type: "reset" });
- const nextRoute = {
- step: route.step,
- projectId: nextProjectId,
- clipItem: route.clipItem,
- };
- setRoute(nextRoute);
- writeRouteToLocation(nextRoute, initialPipelineSelection);
- }
-
- function selectProcessingRun(runId: string | null) {
- const nextPipelineSelection: PipelineSelectionState = {
- ...pipelineSelection,
- selectedProcessingRunId: runId,
- };
- dispatchPipelineSelection({ type: "select-processing-run", runId });
- writeRouteToLocation(route, nextPipelineSelection, true);
- }
-
- function selectSpeakersRun(runId: string | null) {
- const nextPipelineSelection: PipelineSelectionState = {
- ...pipelineSelection,
- selectedSpeakersRunId: runId,
- };
- dispatchPipelineSelection({ type: "select-speakers-run", runId });
- writeRouteToLocation(route, nextPipelineSelection, true);
- }
-
- function selectSlicerDatasetRun(runId: string | null) {
- const nextPipelineSelection: PipelineSelectionState = {
- ...pipelineSelection,
- selectedSlicerDatasetRunId: runId,
- };
- dispatchPipelineSelection({ type: "select-slicer-dataset-run", runId });
- writeRouteToLocation(route, nextPipelineSelection, true);
- }
-
- function selectQcDatasetRun(runId: string | null) {
- const nextPipelineSelection: PipelineSelectionState = {
- ...pipelineSelection,
- selectedQcDatasetRunId: runId,
- };
- dispatchPipelineSelection({ type: "select-qc-dataset-run", runId });
- writeRouteToLocation(route, nextPipelineSelection, true);
- }
-
- function selectLabDatasetRun(runId: string | null) {
- const nextPipelineSelection: PipelineSelectionState = {
- ...pipelineSelection,
- selectedLabDatasetRunId: runId,
- };
- dispatchPipelineSelection({ type: "select-lab-dataset-run", runId });
- writeRouteToLocation(route, nextPipelineSelection, true);
- }
-
- function openSlicerWithRun(runId: string) {
- const nextPipelineSelection: PipelineSelectionState = {
- ...pipelineSelection,
- selectedSlicerDatasetRunId: runId,
- };
- dispatchPipelineSelection({ type: "select-slicer-dataset-run", runId });
- const nextRoute = { step: "slicer" as const, projectId: route.projectId, clipItem: route.clipItem };
- setRoute(nextRoute);
- writeRouteToLocation(nextRoute, nextPipelineSelection);
- }
-
- function openProcessingWithRun(runId: string) {
- const nextPipelineSelection: PipelineSelectionState = {
- ...pipelineSelection,
- selectedProcessingRunId: runId,
- };
- dispatchPipelineSelection({ type: "select-processing-run", runId });
- const nextRoute = { step: "processing" as const, projectId: route.projectId, clipItem: route.clipItem };
- setRoute(nextRoute);
- writeRouteToLocation(nextRoute, nextPipelineSelection);
- }
-
- function handleImportComplete(projectId: string) {
- dispatchPipelineSelection({ type: "reset" });
- const nextRoute = { step: "overview" as const, projectId, clipItem: null };
- setRoute(nextRoute);
- writeRouteToLocation(nextRoute, initialPipelineSelection, true);
- void loadProjects(projectId, nextRoute);
- }
-
- const pageProps = {
- activeProject,
- projectLoadStatus,
- projectLoadError,
- onRetryProjects: () => void loadProjects(route.projectId),
- };
-
- let pageContent = null;
-
- if (route.step === "ingest") {
- pageContent = ;
- } else if (route.step === "overview") {
- pageContent = ;
- } else if (route.step === "speakers") {
- pageContent = (
- navigate("processing")}
- onOpenProcessingWithRun={openProcessingWithRun}
- />
- );
- } else if (route.step === "processing") {
- pageContent = (
- navigate("speakers")}
- onOpenSlicerWithRun={openSlicerWithRun}
- />
- );
- } else if (route.step === "slicer") {
- pageContent = navigate("qc")} />;
- } else if (route.step === "qc") {
- pageContent = (
-
- );
- } else if (route.step === "lab") {
- pageContent = (
- {
- const nextRoute = { ...route, clipItem };
- setRoute(nextRoute);
- writeRouteToLocation(nextRoute, pipelineSelection, true);
- }}
- onHeaderActionsChange={setPageHeaderActions}
- />
- );
- } else if (route.step === "reference") {
- pageContent = ;
- } else {
- pageContent = ;
- }
-
- return (
-
-
-
-
-
Speechcraft
-
Dataset Workstation
-
-
-
-
-
{pageHeaderContent.eyebrow}
-
{pageHeaderContent.title}
-
-
{pageHeaderContent.description}
-
-
-
-
- {pageHeaderActions ?
{pageHeaderActions}
: null}
-
-
-
-
-
-
-
-
-
-
- {projectLoadError ? {projectLoadError}
: null}
- {
- dispatchPipelineSelection({ type: "reset" });
- writeRouteToLocation(route, initialPipelineSelection, true);
- }}
- >
- {pageContent}
-
-
-
-
-
- {activeProject ? (
-
- ) : null}
-
- );
-}
diff --git a/frontend/src/ErrorBoundary.tsx b/frontend/src/ErrorBoundary.tsx
deleted file mode 100644
index d2806db..0000000
--- a/frontend/src/ErrorBoundary.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { Component, type ReactNode } from "react";
-
-type ErrorBoundaryProps = {
- children: ReactNode;
- fallback: ReactNode;
- resetKey?: string;
-};
-
-type ErrorBoundaryState = {
- hasError: boolean;
-};
-
-export default class ErrorBoundary extends Component<
- ErrorBoundaryProps,
- ErrorBoundaryState
-> {
- state: ErrorBoundaryState = {
- hasError: false,
- };
-
- static getDerivedStateFromError(): ErrorBoundaryState {
- return { hasError: true };
- }
-
- override componentDidCatch(error: unknown) {
- console.error("Speechcraft UI error boundary caught an exception", error);
- }
-
- override componentDidUpdate(prevProps: ErrorBoundaryProps) {
- if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) {
- this.setState({ hasError: false });
- }
- }
-
- override render() {
- if (this.state.hasError) {
- return this.props.fallback;
- }
-
- return this.props.children;
- }
-}
diff --git a/frontend/src/WaveformPane.test.tsx b/frontend/src/WaveformPane.test.tsx
deleted file mode 100644
index e8fc5ad..0000000
--- a/frontend/src/WaveformPane.test.tsx
+++ /dev/null
@@ -1,452 +0,0 @@
-import { act, render, waitFor } from "@testing-library/react";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-type DeferredLoad = {
- resolve: () => void;
- reject: (error: Error) => void;
-};
-
-const deferredLoads: DeferredLoad[] = [];
-const waveSurferLoad = vi.fn(() =>
- new Promise((resolve, reject) => {
- deferredLoads.push({
- resolve: () => resolve(),
- reject,
- });
- }),
-);
-const waveSurferDestroy = vi.fn();
-const waveSurferOn = vi.fn();
-const waveSurferOnce = vi.fn();
-const waveSurferSeekTo = vi.fn();
-const waveSurferGetDuration = vi.fn(() => 2);
-const waveSurferGetCurrentTime = vi.fn(() => 0);
-const waveSurferGetWrapper = vi.fn(() => ({
- parentElement: null,
- scrollWidth: 100,
-}));
-
-vi.mock("wavesurfer.js", () => ({
- default: {
- create: vi.fn(() => ({
- load: waveSurferLoad,
- destroy: waveSurferDestroy,
- on: waveSurferOn,
- once: waveSurferOnce,
- seekTo: waveSurferSeekTo,
- getDuration: waveSurferGetDuration,
- getCurrentTime: waveSurferGetCurrentTime,
- getWrapper: waveSurferGetWrapper,
- isPlaying: vi.fn(() => false),
- zoom: vi.fn(),
- })),
- },
-}));
-
-const enableDragSelection = vi.fn();
-
-vi.mock("wavesurfer.js/dist/plugins/regions.esm.js", () => ({
- default: {
- create: vi.fn(() => ({
- enableDragSelection,
- on: vi.fn(),
- getRegions: vi.fn(() => []),
- addRegion: vi.fn(),
- })),
- },
-}));
-
-import WaveformPane from "./WaveformPane";
-
-async function resolveAllPendingLoads() {
- const pending = [...deferredLoads];
- await act(async () => {
- for (const load of pending) {
- load.resolve();
- }
- await Promise.resolve();
- });
-}
-
-describe("WaveformPane", () => {
- beforeEach(() => {
- deferredLoads.length = 0;
- waveSurferLoad.mockClear();
- waveSurferOnce.mockClear();
- enableDragSelection.mockClear();
- });
-
- afterEach(() => {
- vi.clearAllMocks();
- });
-
- it("does not load dataset audio before peaks resolve on first mount", async () => {
- const revisionKey = "rev-a";
- const peaks = [0.1, 0.2, 0.3];
-
- const { rerender } = render(
- {}}
- onCursorChange={() => {}}
- />,
- );
-
- await act(async () => {
- await Promise.resolve();
- });
- expect(waveSurferLoad).not.toHaveBeenCalled();
-
- rerender(
- {}}
- onCursorChange={() => {}}
- />,
- );
-
- await waitFor(() => {
- expect(waveSurferLoad).toHaveBeenCalledTimes(1);
- });
- await resolveAllPendingLoads();
- expect(waveSurferLoad).toHaveBeenCalledWith(
- "http://127.0.0.1:8010/media/a.wav",
- [peaks],
- 2,
- );
- });
-
- it("blocks idle dataset mounts until peaks are ready", async () => {
- render(
- {}}
- onCursorChange={() => {}}
- />,
- );
-
- await act(async () => {
- await Promise.resolve();
- });
- expect(waveSurferLoad).not.toHaveBeenCalled();
- });
-
- it("loads each revision once when switching clips", async () => {
- const peaksA = [0.1, 0.2];
- const peaksB = [0.3, 0.4];
-
- const { rerender } = render(
- {}}
- onCursorChange={() => {}}
- />,
- );
-
- rerender(
- {}}
- onCursorChange={() => {}}
- />,
- );
-
- await waitFor(() => {
- expect(waveSurferLoad).toHaveBeenCalledTimes(1);
- });
- await resolveAllPendingLoads();
-
- rerender(
- {}}
- onCursorChange={() => {}}
- />,
- );
-
- await act(async () => {
- await Promise.resolve();
- });
- expect(waveSurferLoad).toHaveBeenCalledTimes(1);
-
- rerender(
- {}}
- onCursorChange={() => {}}
- />,
- );
-
- await waitFor(() => {
- expect(waveSurferLoad).toHaveBeenCalledTimes(2);
- });
- await resolveAllPendingLoads();
- expect(waveSurferLoad).toHaveBeenLastCalledWith(
- "http://127.0.0.1:8010/media/b.wav",
- [peaksB],
- 3,
- );
- });
-
- it("falls back to one audio-only load when dataset peaks fail", async () => {
- const { rerender } = render(
- {}}
- onCursorChange={() => {}}
- />,
- );
-
- rerender(
- {}}
- onCursorChange={() => {}}
- />,
- );
-
- await waitFor(() => {
- expect(waveSurferLoad).toHaveBeenCalledTimes(1);
- });
- await resolveAllPendingLoads();
- expect(waveSurferLoad).toHaveBeenCalledWith(
- "http://127.0.0.1:8010/media/a.wav",
- undefined,
- 2,
- );
-
- rerender(
- {}}
- onCursorChange={() => {}}
- />,
- );
-
- await act(async () => {
- await Promise.resolve();
- });
- expect(waveSurferLoad).toHaveBeenCalledTimes(1);
- });
-
- it("does not reload when only the playhead changes", async () => {
- const revisionKey = "rev-a";
- const peaks = [0.5, 0.4];
-
- const { rerender } = render(
- {}}
- onCursorChange={() => {}}
- />,
- );
-
- await waitFor(() => {
- expect(waveSurferLoad).toHaveBeenCalledTimes(1);
- });
- await resolveAllPendingLoads();
-
- rerender(
- {}}
- onCursorChange={() => {}}
- />,
- );
-
- await act(async () => {
- await Promise.resolve();
- });
-
- expect(waveSurferLoad).toHaveBeenCalledTimes(1);
- });
-
- it("ignores stale load completion when an older revision resolves late", async () => {
- const peaksA = [0.1, 0.2];
- const peaksB = [0.3, 0.4];
-
- const { rerender } = render(
- {}}
- onCursorChange={() => {}}
- />,
- );
-
- await waitFor(() => {
- expect(waveSurferLoad).toHaveBeenCalledTimes(1);
- });
- const loadA = deferredLoads[0];
- expect(loadA).toBeDefined();
-
- rerender(
- {}}
- onCursorChange={() => {}}
- />,
- );
-
- await act(async () => {
- await Promise.resolve();
- });
- expect(waveSurferLoad).toHaveBeenCalledTimes(1);
-
- rerender(
- {}}
- onCursorChange={() => {}}
- />,
- );
-
- await waitFor(() => {
- expect(waveSurferLoad).toHaveBeenCalledTimes(2);
- });
- const loadB = deferredLoads[1];
- expect(loadB).toBeDefined();
-
- await act(async () => {
- loadB.resolve();
- await Promise.resolve();
- });
-
- await act(async () => {
- loadA.resolve();
- await Promise.resolve();
- });
-
- rerender(
- {}}
- onCursorChange={() => {}}
- />,
- );
-
- await act(async () => {
- await Promise.resolve();
- });
-
- expect(waveSurferLoad).toHaveBeenCalledTimes(2);
- expect(waveSurferLoad).toHaveBeenLastCalledWith(
- "http://127.0.0.1:8010/media/b.wav",
- [peaksB],
- 3,
- );
- });
-});
diff --git a/frontend/src/WaveformPane.tsx b/frontend/src/WaveformPane.tsx
deleted file mode 100644
index 0bf72a2..0000000
--- a/frontend/src/WaveformPane.tsx
+++ /dev/null
@@ -1,463 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import WaveSurfer from "wavesurfer.js";
-import RegionsPlugin from "wavesurfer.js/dist/plugins/regions.esm.js";
-
-type WaveformPaneProps = {
- audioUrl: string;
- durationSeconds: number;
- peaks: number[] | null;
- loadRevisionKey?: string | null;
- peaksLoadState?: "idle" | "loading" | "ready" | "failed";
- requirePeaksBeforeLoad?: boolean;
- desiredCursorSeconds?: number;
- selectionStart: number;
- selectionEnd: number;
- onSelectionChange: (start: number, end: number) => void;
- onCursorChange: (time: number) => void;
- onHoverTimeChange?: (time: number | null) => void;
- onReady?: (instance: WaveSurfer | null) => void;
- onPlayingChange?: (isPlaying: boolean) => void;
-};
-
-export default function WaveformPane({
- audioUrl,
- durationSeconds,
- peaks,
- loadRevisionKey = null,
- peaksLoadState = "idle",
- requirePeaksBeforeLoad = false,
- desiredCursorSeconds = 0,
- selectionStart,
- selectionEnd,
- onSelectionChange,
- onCursorChange,
- onHoverTimeChange,
- onReady,
- onPlayingChange,
-}: WaveformPaneProps) {
- const precision = 10000;
- const roundTime = (value: number): number => Math.round(value * precision) / precision;
- const containerRef = useRef(null);
- const waveSurferRef = useRef(null);
- const regionsRef = useRef(null);
- const zoomRef = useRef(90);
- const isPointerDownRef = useRef(false);
- const pointerStartXRef = useRef(null);
- const pointerStartTimeRef = useRef(null);
- const draggedThisGestureRef = useRef(false);
- const selectionChangeRef = useRef(onSelectionChange);
- const cursorChangeRef = useRef(onCursorChange);
- const hoverTimeChangeRef = useRef(onHoverTimeChange);
- const readyRef = useRef(onReady);
- const playingChangeRef = useRef(onPlayingChange);
- const lastAudioUrlRef = useRef(null);
- const lastLoadedRevisionRef = useRef(null);
- const loadGenerationRef = useRef(0);
- const peaksRef = useRef(peaks);
- const desiredCursorRef = useRef(desiredCursorSeconds);
- const [audioState, setAudioState] = useState<"loading" | "ready" | "error">("loading");
- const [audioError, setAudioError] = useState(null);
-
- function isAbortLikeError(error: unknown): boolean {
- const message =
- error instanceof Error
- ? error.message
- : typeof error === "string"
- ? error
- : "";
- const normalized = message.toLowerCase();
- return (
- normalized.includes("abort") ||
- normalized.includes("aborted") ||
- normalized.includes("user aborted") ||
- normalized.includes("cancel")
- );
- }
-
- useEffect(() => {
- selectionChangeRef.current = onSelectionChange;
- cursorChangeRef.current = onCursorChange;
- hoverTimeChangeRef.current = onHoverTimeChange;
- readyRef.current = onReady;
- playingChangeRef.current = onPlayingChange;
- }, [onSelectionChange, onCursorChange, onHoverTimeChange, onReady, onPlayingChange]);
-
- useEffect(() => {
- desiredCursorRef.current = desiredCursorSeconds;
- }, [desiredCursorSeconds]);
-
- useEffect(() => {
- peaksRef.current = peaks;
- }, [peaks]);
-
- useEffect(() => {
- lastLoadedRevisionRef.current = null;
- }, [loadRevisionKey, audioUrl]);
-
- useEffect(() => {
- if (!containerRef.current) {
- return;
- }
-
- const regions = RegionsPlugin.create();
- const waveSurfer = WaveSurfer.create({
- container: containerRef.current,
- height: 220,
- normalize: true,
- waveColor: "#78d2cd",
- progressColor: "#3c8d9f",
- cursorColor: "#f3a545",
- cursorWidth: 2,
- minPxPerSec: zoomRef.current,
- dragToSeek: false,
- interact: true,
- autoScroll: false,
- autoCenter: false,
- hideScrollbar: false,
- plugins: [regions],
- });
-
- waveSurferRef.current = waveSurfer;
- regionsRef.current = regions;
- readyRef.current?.(waveSurfer);
-
- const timeAtClientX = (clientX: number): number | null => {
- const wrapper = waveSurfer.getWrapper();
- const scrollContainer = wrapper.parentElement;
- const duration = waveSurfer.getDuration();
- if (!scrollContainer || duration <= 0 || wrapper.scrollWidth <= 0) {
- return null;
- }
-
- const viewport = scrollContainer.getBoundingClientRect();
- const localX = Math.max(0, Math.min(clientX - viewport.left, viewport.width));
- const absoluteX = Math.max(
- 0,
- Math.min(scrollContainer.scrollLeft + localX, wrapper.scrollWidth),
- );
- return roundTime((absoluteX / wrapper.scrollWidth) * duration);
- };
-
- const handleTimeUpdate = (time: number) => {
- if (!waveSurfer.isPlaying()) {
- return;
- }
- cursorChangeRef.current(Number(time.toFixed(2)));
-
- // Audacity-like page scrolling: jump viewport when playhead exits visible window.
- const scrollContainer = waveSurfer.getWrapper().parentElement;
- const duration = waveSurfer.getDuration();
- if (!scrollContainer || duration <= 0) {
- return;
- }
-
- const totalWidth = waveSurfer.getWrapper().scrollWidth;
- const viewportWidth = scrollContainer.clientWidth;
- if (totalWidth <= viewportWidth) {
- return;
- }
-
- const currentPx = (time / duration) * totalWidth;
- const viewStart = scrollContainer.scrollLeft;
- const viewEnd = viewStart + viewportWidth;
-
- if (currentPx > viewEnd - 2) {
- const nextPageStart =
- Math.floor(currentPx / viewportWidth) * viewportWidth;
- scrollContainer.scrollLeft = Math.min(
- nextPageStart,
- Math.max(totalWidth - viewportWidth, 0),
- );
- } else if (currentPx < viewStart) {
- const prevPageStart =
- Math.floor(currentPx / viewportWidth) * viewportWidth;
- scrollContainer.scrollLeft = Math.max(prevPageStart, 0);
- }
- };
- const handleInteraction = () => {
- if (draggedThisGestureRef.current) {
- return;
- }
- cursorChangeRef.current(roundTime(waveSurfer.getCurrentTime()));
- };
- const handleSeeking = (time: number) => {
- if (draggedThisGestureRef.current) {
- return;
- }
- cursorChangeRef.current(roundTime(time));
- };
- const handleClick = () => {
- if (draggedThisGestureRef.current) {
- return;
- }
- const time = roundTime(waveSurfer.getCurrentTime());
- cursorChangeRef.current(time);
- selectionChangeRef.current(time, time);
- };
-
- waveSurfer.on("timeupdate", handleTimeUpdate);
- waveSurfer.on("interaction", handleInteraction);
- waveSurfer.on("seeking", handleSeeking);
- waveSurfer.on("click", handleClick);
- waveSurfer.on("play", () => playingChangeRef.current?.(true));
- waveSurfer.on("pause", () => playingChangeRef.current?.(false));
- waveSurfer.on("finish", () => playingChangeRef.current?.(false));
- waveSurfer.on("ready", () => {
- setAudioState("ready");
- setAudioError(null);
- });
- waveSurfer.on("error", (error) => {
- if (isAbortLikeError(error)) {
- return;
- }
- lastLoadedRevisionRef.current = null;
- const message =
- error instanceof Error
- ? error.message
- : typeof error === "string" && error
- ? error
- : "Audio failed to load for this clip.";
- setAudioState("error");
- setAudioError(message);
- playingChangeRef.current?.(false);
- });
-
- regions.on("region-created", (region: any) => {
- for (const candidate of regions.getRegions()) {
- if (candidate.id !== region.id) {
- candidate.remove();
- }
- }
- selectionChangeRef.current(
- roundTime(region.start),
- roundTime(region.end),
- );
- });
-
- regions.on("region-updated", (region: any) => {
- selectionChangeRef.current(
- roundTime(region.start),
- roundTime(region.end),
- );
- });
-
- const handlePointerDown = (event: PointerEvent) => {
- isPointerDownRef.current = true;
- pointerStartXRef.current = event.clientX;
- pointerStartTimeRef.current = timeAtClientX(event.clientX);
- draggedThisGestureRef.current = false;
- };
-
- const handlePointerMove = (event: PointerEvent) => {
- const hoverTime = timeAtClientX(event.clientX);
- if (hoverTime !== null) {
- hoverTimeChangeRef.current?.(hoverTime);
- }
-
- if (!isPointerDownRef.current || pointerStartXRef.current === null) {
- return;
- }
- if (Math.abs(event.clientX - pointerStartXRef.current) > 4) {
- draggedThisGestureRef.current = true;
- }
- };
-
- const handlePointerUp = (event: PointerEvent) => {
- const didDrag = draggedThisGestureRef.current;
- const startTime = pointerStartTimeRef.current;
- const endTime = timeAtClientX(event.clientX);
-
- isPointerDownRef.current = false;
- pointerStartXRef.current = null;
- pointerStartTimeRef.current = null;
-
- if (didDrag && startTime !== null && endTime !== null) {
- selectionChangeRef.current(
- Math.min(startTime, endTime),
- Math.max(startTime, endTime),
- );
- setTimeout(() => {
- draggedThisGestureRef.current = false;
- }, 120);
- return;
- }
-
- draggedThisGestureRef.current = false;
- };
-
- const handleWheel = (event: WheelEvent) => {
- const scrollContainer = waveSurfer.getWrapper().parentElement;
-
- if (event.ctrlKey) {
- event.preventDefault();
- const delta = event.deltaY < 0 ? 36 : -36;
- zoomRef.current = Math.max(30, Math.min(1000, zoomRef.current + delta));
- waveSurfer.zoom(zoomRef.current);
- return;
- }
-
- if (event.shiftKey && scrollContainer) {
- event.preventDefault();
- scrollContainer.scrollLeft += event.deltaY;
- }
- };
- const handlePointerLeave = () => {
- hoverTimeChangeRef.current?.(null);
- };
-
- containerRef.current.addEventListener("wheel", handleWheel, { passive: false });
- containerRef.current.addEventListener("pointerdown", handlePointerDown);
- containerRef.current.addEventListener("pointermove", handlePointerMove);
- containerRef.current.addEventListener("pointerup", handlePointerUp as EventListener);
- containerRef.current.addEventListener("pointercancel", handlePointerUp as EventListener);
- containerRef.current.addEventListener("pointerleave", handlePointerLeave);
-
- return () => {
- containerRef.current?.removeEventListener("wheel", handleWheel);
- containerRef.current?.removeEventListener("pointerdown", handlePointerDown);
- containerRef.current?.removeEventListener("pointermove", handlePointerMove);
- containerRef.current?.removeEventListener("pointerup", handlePointerUp as EventListener);
- containerRef.current?.removeEventListener("pointercancel", handlePointerUp as EventListener);
- containerRef.current?.removeEventListener("pointerleave", handlePointerLeave);
- readyRef.current?.(null);
- waveSurfer.destroy();
- waveSurferRef.current = null;
- regionsRef.current = null;
- };
- }, []);
-
- useEffect(() => {
- const waveSurfer = waveSurferRef.current;
- if (!waveSurfer || !audioUrl) {
- return;
- }
-
- const revisionKey = loadRevisionKey ?? audioUrl;
- const waitForPeaks =
- requirePeaksBeforeLoad
- && peaksLoadState !== "ready"
- && peaksLoadState !== "failed";
- if (peaksLoadState === "loading" || waitForPeaks) {
- loadGenerationRef.current += 1;
- setAudioState("loading");
- setAudioError(null);
- return;
- }
-
- if (lastLoadedRevisionRef.current === revisionKey) {
- return;
- }
-
- const isClipChange = lastAudioUrlRef.current !== audioUrl;
- lastAudioUrlRef.current = audioUrl;
- const targetTime = isClipChange
- ? 0
- : Math.max(0, Math.min(desiredCursorRef.current, durationSeconds));
- const seekOnReady = () => {
- const duration = waveSurfer.getDuration();
- if (duration > 0) {
- waveSurfer.seekTo(Math.max(0, Math.min(targetTime / duration, 1)));
- }
- cursorChangeRef.current(roundTime(targetTime));
- };
- waveSurfer.once("ready", seekOnReady);
- setAudioState("loading");
- setAudioError(null);
-
- const currentPeaks = peaksRef.current;
- const peaksArg =
- peaksLoadState === "ready" && currentPeaks && currentPeaks.length > 0
- ? [currentPeaks]
- : undefined;
-
- const generation = ++loadGenerationRef.current;
- const revisionKeyAtStart = revisionKey;
-
- void waveSurfer
- .load(
- audioUrl,
- peaksArg,
- durationSeconds > 0 ? durationSeconds : undefined,
- )
- .then(() => {
- if (
- generation !== loadGenerationRef.current
- || revisionKeyAtStart !== (loadRevisionKey ?? audioUrl)
- ) {
- return;
- }
- lastLoadedRevisionRef.current = revisionKeyAtStart;
- })
- .catch((error) => {
- if (generation !== loadGenerationRef.current) {
- return;
- }
- if (!isAbortLikeError(error)) {
- lastLoadedRevisionRef.current = null;
- }
- });
- }, [audioUrl, durationSeconds, loadRevisionKey, peaksLoadState, requirePeaksBeforeLoad]);
-
- useEffect(() => {
- const regions = regionsRef.current;
- if (!regions) {
- return;
- }
-
- const start = Math.min(selectionStart, selectionEnd);
- const end = Math.max(selectionStart, selectionEnd);
- const currentRegion = regions.getRegions()[0];
-
- if (end <= start + 0.01) {
- if (currentRegion) {
- currentRegion.remove();
- }
- return;
- }
-
- if (!currentRegion) {
- regions.addRegion({
- start,
- end,
- color: "rgba(247, 203, 104, 0.2)",
- drag: true,
- resize: true,
- });
- return;
- }
-
- if (
- Math.abs(currentRegion.start - start) > 0.02 ||
- Math.abs(currentRegion.end - end) > 0.02
- ) {
- currentRegion.setOptions({ start, end });
- }
- }, [selectionStart, selectionEnd]);
-
- return (
-
-
- {audioState !== "ready" ? (
-
-
- {audioState === "loading"
- ? peaksLoadState === "loading"
- ? "Loading waveform..."
- : "Loading audio..."
- : "Audio unavailable"}
-
-
- {audioState === "loading"
- ? peaksLoadState === "loading"
- ? "Fetching cached peaks before decoding clip audio."
- : "Fetching clip audio and preparing the waveform."
- : audioError ?? "The backend could not decode this clip."}
-
-
- ) : null}
-
- );
-}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
deleted file mode 100644
index 4f4e13a..0000000
--- a/frontend/src/api.ts
+++ /dev/null
@@ -1,775 +0,0 @@
-import type {
- ClipLabItem,
- DatasetClipLabClipRow,
- DatasetClipLabAudioOperationRequest,
- DatasetClipLabAudioStackRequest,
- DatasetClipLabPatchRequest,
- DatasetClipLabView,
- DatasetWaveformPeaksPayload,
- DatasetPreflight,
- DatasetExportResults,
- DatasetQcFinalizeRequest,
- DatasetQcFinalizeResponse,
- DatasetQcPayload,
- DatasetSpeakerResults,
- DatasetRun,
- DatasetRunCreateRequest,
- DatasetRunLog,
- DatasetSpeakerSelection,
- DatasetSlicerResults,
- ExportPreview,
- ExportRun,
- ImportBatch,
- MediaCleanupResult,
- ProjectAlignmentSettings,
- PreparationSettings,
- ProjectRecordingJobsRun,
- ProjectPreparationRun,
- ProjectTranscriptionSettings,
- ProcessingJob,
- ReferenceAssetDetail,
- ReferenceAssetSummary,
- ReferenceClipCandidate,
- ReferenceCandidate,
- ReferenceRunRerankResponse,
- ReferenceRun,
- ReviewStatus,
- Slice,
- SliceSummary,
- SourceRecording,
- SourceRecordingQueue,
- WaveformPeaks,
-} from "./types";
-
-export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8010";
-
-export class ApiError extends Error {
- readonly status: number;
- readonly url: string;
-
- constructor(message: string, status: number, url: string) {
- super(message);
- this.name = "ApiError";
- this.status = status;
- this.url = url;
- }
-}
-
-function backendUnreachableMessage(): string {
- return `Backend API is unreachable at ${API_BASE}. Restart make dev-backend, then refresh.`;
-}
-
-async function requestJson(input: string, init?: RequestInit): Promise {
- let response: Response;
- try {
- response = await fetch(input, init);
- } catch (error) {
- if (error instanceof TypeError) {
- throw new ApiError(backendUnreachableMessage(), 0, input);
- }
- throw error;
- }
- return await parseJson(response);
-}
-
-async function parseJson(response: Response): Promise {
- if (!response.ok) {
- let message = `Request failed: ${response.status}`;
-
- try {
- const payload = (await response.json()) as { detail?: string; message?: string };
- message = payload.detail ?? payload.message ?? message;
- } catch {
- try {
- const text = await response.text();
- if (text.trim()) {
- message = text.trim();
- }
- } catch {
- // Ignore secondary parse failures and keep the status message.
- }
- }
-
- throw new ApiError(message, response.status, response.url);
- }
-
- return (await response.json()) as T;
-}
-
-export function buildVariantAudioUrl(variantId: string): string {
- return `${API_BASE}/media/variants/${variantId}.wav`;
-}
-
-export function resolveApiUrl(pathOrUrl: string): string {
- if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
- return pathOrUrl;
- }
- return `${API_BASE}${pathOrUrl}`;
-}
-
-export function buildSliceAudioUrl(sliceId: string, revision?: string): string {
- const url = new URL(`${API_BASE}/media/slices/${sliceId}.wav`);
- if (revision) {
- url.searchParams.set("rev", revision);
- }
- return url.toString();
-}
-
-export function buildReferenceVariantAudioUrl(variantId: string): string {
- return `${API_BASE}/media/reference-variants/${variantId}.wav`;
-}
-
-export function buildReferenceCandidateAudioUrl(runId: string, candidateId: string): string {
- return `${API_BASE}/media/reference-candidates/${runId}/${candidateId}.wav`;
-}
-
-export async function fetchHealthStrict(): Promise<{ status: string }> {
- return await requestJson<{ status: string }>(`${API_BASE}/healthz`);
-}
-
-export async function fetchProjects(): Promise {
- return await requestJson(`${API_BASE}/api/projects`);
-}
-
-export async function fetchProject(projectId: string): Promise {
- return await requestJson(`${API_BASE}/api/projects/${projectId}`);
-}
-
-export async function createImportBatch(payload: { id: string; name: string }): Promise {
- return await requestJson(`${API_BASE}/api/import-batches`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(payload),
- });
-}
-
-export async function deleteProject(
- projectId: string,
-): Promise<{ project_id: string; deleted_file_count: number }> {
- return await requestJson<{ project_id: string; deleted_file_count: number }>(`${API_BASE}/api/projects/${projectId}`, {
- method: "DELETE",
- });
-}
-
-export function uploadProjectSourceRecording(
- projectId: string,
- file: File,
- onProgress: (progress: number) => void,
-): Promise {
- const url = `${API_BASE}/api/projects/${projectId}/source-recordings/upload`;
- return new Promise((resolve, reject) => {
- const request = new XMLHttpRequest();
- request.open("POST", url);
- request.upload.onprogress = (event) => {
- if (!event.lengthComputable || event.total <= 0) {
- return;
- }
- onProgress(Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100))));
- };
- request.onload = () => {
- let payload: unknown = null;
- try {
- payload = request.responseText ? JSON.parse(request.responseText) : null;
- } catch {
- payload = null;
- }
- if (request.status >= 200 && request.status < 300 && payload) {
- onProgress(100);
- resolve(payload as SourceRecording);
- return;
- }
- const message =
- payload && typeof payload === "object" && "detail" in payload
- ? String((payload as { detail?: unknown }).detail)
- : `Upload failed: ${request.status}`;
- reject(new ApiError(message, request.status, url));
- };
- request.onerror = () => {
- reject(new ApiError("Upload failed before the server responded.", request.status || 0, url));
- };
- const formData = new FormData();
- formData.append("file", file, file.name);
- request.send(formData);
- });
-}
-
-export async function fetchProjectSlices(projectId: string): Promise {
- const response = await fetch(`${API_BASE}/api/projects/${projectId}/slices`);
- return await parseJson(response);
-}
-
-export async function fetchProjectSourceRecordings(projectId: string): Promise {
- return await requestJson(`${API_BASE}/api/projects/${projectId}/source-recordings`);
-}
-
-export async function fetchDatasetPreflight(options?: {
- artifactRoot?: string;
- asrModel?: string;
- asrModelPath?: string;
- asrCacheDir?: string;
- asrDevice?: string;
- asrComputeType?: string;
-}): Promise {
- const url = new URL(`${API_BASE}/api/system/preflight`);
- if (options?.artifactRoot) url.searchParams.set("artifact_root", options.artifactRoot);
- if (options?.asrModel) url.searchParams.set("asr_model", options.asrModel);
- if (options?.asrModelPath) url.searchParams.set("asr_model_path", options.asrModelPath);
- if (options?.asrCacheDir) url.searchParams.set("asr_cache_dir", options.asrCacheDir);
- if (options?.asrDevice) url.searchParams.set("asr_device", options.asrDevice);
- if (options?.asrComputeType) url.searchParams.set("asr_compute_type", options.asrComputeType);
- return await requestJson(url.toString());
-}
-
-export async function fetchProjectDatasetRuns(projectId: string): Promise {
- return await requestJson(`${API_BASE}/api/projects/${projectId}/dataset-runs`);
-}
-
-export async function createProjectDatasetRun(
- projectId: string,
- payload: DatasetRunCreateRequest,
-): Promise {
- return await requestJson(`${API_BASE}/api/projects/${projectId}/dataset-runs`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
-}
-
-export async function startDatasetRun(runId: string): Promise {
- return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/start`, { method: "POST" });
-}
-
-export async function refreshDatasetRun(runId: string): Promise {
- return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/refresh`, { method: "POST" });
-}
-
-export async function fetchDatasetRunLog(runId: string): Promise {
- return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/log`);
-}
-
-export async function fetchDatasetSpeakerResults(runId: string): Promise {
- return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/speakers`);
-}
-
-export async function saveDatasetSpeakerSelection(
- runId: string,
- targetSpeakerId: string,
-): Promise {
- return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/speaker-selection`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ target_speaker_id: targetSpeakerId }),
- });
-}
-
-export async function resumeDatasetRunProcessing(
- runId: string,
- stopAfter: "buffers" | "normalization" | "mfa" | "alignment_qc" = "alignment_qc",
-): Promise {
- return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/resume-processing`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ stop_after: stopAfter }),
- });
-}
-
-export async function rerunDatasetSlicer(runId: string, config: Record): Promise {
- return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/slicer-rerun`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ config }),
- });
-}
-
-export async function fetchDatasetSlicerResults(runId: string): Promise {
- return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/slicer-results`);
-}
-
-export async function rerunDatasetNativeExport(runId: string, config: Record = {}): Promise {
- return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/export-rerun`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ config }),
- });
-}
-
-export async function fetchDatasetExportResults(runId: string): Promise {
- return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/export-results`);
-}
-
-export async function fetchDatasetQc(runId: string): Promise {
- return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/qc`);
-}
-
-export async function fetchDatasetClipLab(runId: string): Promise {
- return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/clip-lab`);
-}
-
-export async function patchDatasetClipLabClip(
- runId: string,
- clipId: string,
- payload: DatasetClipLabPatchRequest,
-): Promise {
- return await requestJson(
- `${API_BASE}/api/dataset-runs/${runId}/clips/${encodeURIComponent(clipId)}/clip-lab`,
- {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- },
- );
-}
-
-export async function appendDatasetAudioOperation(
- runId: string,
- clipId: string,
- payload: DatasetClipLabAudioOperationRequest,
-): Promise {
- return await requestJson(
- `${API_BASE}/api/dataset-runs/${runId}/clips/${encodeURIComponent(clipId)}/audio/operations`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- },
- );
-}
-
-export async function undoDatasetAudioOperation(
- runId: string,
- clipId: string,
- payload: DatasetClipLabAudioStackRequest,
-): Promise {
- return await requestJson(
- `${API_BASE}/api/dataset-runs/${runId}/clips/${encodeURIComponent(clipId)}/audio/undo`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- },
- );
-}
-
-export async function redoDatasetAudioOperation(
- runId: string,
- clipId: string,
- payload: DatasetClipLabAudioStackRequest,
-): Promise {
- return await requestJson(
- `${API_BASE}/api/dataset-runs/${runId}/clips/${encodeURIComponent(clipId)}/audio/redo`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- },
- );
-}
-
-export async function fetchDatasetClipLabWaveformPeaks(
- peaksPath: string,
-): Promise {
- const url = peaksPath.startsWith("http") ? peaksPath : `${API_BASE}${peaksPath}`;
- return await requestJson(url);
-}
-
-export async function finalizeDatasetQc(
- runId: string,
- payload: DatasetQcFinalizeRequest,
-): Promise {
- return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/qc/finalize`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
-}
-
-export async function generateDatasetQcScores(
- runId: string,
- options?: { force?: boolean },
-): Promise {
- return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/qc/generate`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ force: options?.force ?? false }),
- });
-}
-
-export function buildCandidateReviewAudioUrl(runId: string, clipId: string): string {
- return `${API_BASE}/media/dataset-runs/${runId}/candidate-review/${clipId}.wav`;
-}
-
-export function buildNativeExportAudioUrl(runId: string, clipId: string): string {
- return `${API_BASE}/media/dataset-runs/${runId}/native-export/${clipId}.wav`;
-}
-
-export function buildSpeakerSampleAudioUrl(runId: string, sampleId: string): string {
- return `${API_BASE}/media/dataset-runs/${runId}/speaker-samples/${sampleId}.wav`;
-}
-
-export async function fetchProjectReferenceAssets(
- projectId: string,
-): Promise {
- const response = await fetch(`${API_BASE}/api/projects/${projectId}/reference-assets`);
- return await parseJson(response);
-}
-
-export async function fetchProjectReferenceRuns(projectId: string): Promise {
- const response = await fetch(`${API_BASE}/api/projects/${projectId}/reference-runs`);
- return await parseJson(response);
-}
-
-export async function createReferenceRun(
- projectId: string,
- payload: {
- recording_ids: string[];
- mode?: "zero_shot" | "finetune" | "both";
- target_durations?: number[] | null;
- candidate_count_cap?: number;
- },
-): Promise {
- const response = await fetch(`${API_BASE}/api/projects/${projectId}/reference-runs`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(payload),
- });
- return await parseJson(response);
-}
-
-export async function fetchReferenceRun(runId: string): Promise {
- const response = await fetch(`${API_BASE}/api/reference-runs/${runId}`);
- return await parseJson(response);
-}
-
-export async function fetchReferenceRunCandidates(
- runId: string,
- options?: {
- offset?: number;
- limit?: number;
- query?: string;
- },
-): Promise {
- const url = new URL(`${API_BASE}/api/reference-runs/${runId}/candidates`);
- if (options?.offset !== undefined) {
- url.searchParams.set("offset", String(options.offset));
- }
- if (options?.limit !== undefined) {
- url.searchParams.set("limit", String(options.limit));
- }
- if (options?.query) {
- url.searchParams.set("query", options.query);
- }
- const response = await fetch(url.toString());
- return await parseJson(response);
-}
-
-export async function rerankReferenceRunCandidates(
- runId: string,
- payload: {
- positive_candidate_ids: string[];
- negative_candidate_ids: string[];
- mode?: string | null;
- },
-): Promise {
- const response = await fetch(`${API_BASE}/api/reference-runs/${runId}/rerank`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(payload),
- });
- return await parseJson(response);
-}
-
-export async function fetchReferenceAsset(assetId: string): Promise {
- const response = await fetch(`${API_BASE}/api/reference-assets/${assetId}`);
- return await parseJson(response);
-}
-
-export async function fetchProjectRecordings(projectId: string): Promise {
- const response = await fetch(`${API_BASE}/api/projects/${projectId}/recordings`);
- return await parseJson(response);
-}
-
-export async function runProjectTranscription(
- projectId: string,
- settings: ProjectTranscriptionSettings,
-): Promise {
- const response = await fetch(`${API_BASE}/api/projects/${projectId}/transcription`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(settings),
- });
- return await parseJson(response);
-}
-
-export async function runProjectAlignment(
- projectId: string,
- settings: ProjectAlignmentSettings,
-): Promise {
- const response = await fetch(`${API_BASE}/api/projects/${projectId}/alignment`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(settings),
- });
- return await parseJson(response);
-}
-
-export async function runProjectPreparation(
- projectId: string,
- settings: PreparationSettings,
-): Promise {
- const response = await fetch(`${API_BASE}/api/projects/${projectId}/preparation`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(settings),
- });
- return await parseJson(response);
-}
-
-export async function fetchProjectPreparationJobs(projectId: string): Promise {
- const response = await fetch(`${API_BASE}/api/projects/${projectId}/preparation-jobs`);
- return await parseJson(response);
-}
-
-export async function fetchProcessingJob(jobId: string): Promise {
- const response = await fetch(`${API_BASE}/api/jobs/${jobId}`);
- return await parseJson(response);
-}
-
-export async function fetchSliceDetail(sliceId: string): Promise {
- const response = await fetch(`${API_BASE}/api/slices/${sliceId}`);
- return await parseJson(response);
-}
-
-export async function fetchClipLabItem(sliceId: string): Promise {
- const response = await fetch(`${API_BASE}/api/slices/${sliceId}/clip-lab`);
- return await parseJson(response);
-}
-
-export async function fetchProjectExports(projectId: string): Promise {
- const response = await fetch(`${API_BASE}/api/projects/${projectId}/exports`);
- return await parseJson(response);
-}
-
-export async function fetchExportPreview(projectId: string): Promise {
- const response = await fetch(`${API_BASE}/api/projects/${projectId}/export-preview`);
- return await parseJson(response);
-}
-
-export async function runProjectExport(projectId: string): Promise {
- const response = await fetch(`${API_BASE}/api/projects/${projectId}/export`, {
- method: "POST",
- });
- return await parseJson(response);
-}
-
-export async function cleanupProjectMedia(projectId: string): Promise {
- const response = await fetch(`${API_BASE}/api/projects/${projectId}/media-cleanup`, {
- method: "POST",
- });
- return await parseJson(response);
-}
-
-export async function updateClipStatus(clipId: string, status: ReviewStatus): Promise {
- const response = await fetch(`${API_BASE}/api/clips/${clipId}/status`, {
- method: "PATCH",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ status }),
- });
- return await parseJson(response);
-}
-
-export async function updateClipTranscript(
- clipId: string,
- modifiedText: string,
-): Promise {
- const response = await fetch(`${API_BASE}/api/clips/${clipId}/transcript`, {
- method: "PATCH",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ modified_text: modifiedText }),
- });
- return await parseJson(response);
-}
-
-export async function updateClipTags(
- clipId: string,
- tags: { name: string; color: string }[],
-): Promise {
- const response = await fetch(`${API_BASE}/api/clips/${clipId}/tags`, {
- method: "PATCH",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ tags }),
- });
- return await parseJson(response);
-}
-
-export async function saveClipState(
- clipId: string,
- payload: {
- modified_text?: string | null;
- tags?: { name: string; color: string }[] | null;
- status?: ReviewStatus | null;
- message?: string | null;
- is_milestone?: boolean;
- },
-): Promise {
- const response = await fetch(`${API_BASE}/api/clips/${clipId}/save`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(payload),
- });
- return await parseJson(response);
-}
-
-export async function appendClipEdlOperation(
- clipId: string,
- payload: {
- op: string;
- range?: { start_seconds: number; end_seconds: number } | null;
- duration_seconds?: number | null;
- },
-): Promise {
- const response = await fetch(`${API_BASE}/api/clips/${clipId}/edl`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(payload),
- });
- return await parseJson(response);
-}
-
-export async function undoClip(clipId: string): Promise {
- const response = await fetch(`${API_BASE}/api/clips/${clipId}/undo`, {
- method: "POST",
- });
- return await parseJson(response);
-}
-
-export async function redoClip(clipId: string): Promise {
- const response = await fetch(`${API_BASE}/api/clips/${clipId}/redo`, {
- method: "POST",
- });
- return await parseJson(response);
-}
-
-export async function splitClip(clipId: string, splitAtSeconds: number): Promise {
- const response = await fetch(`${API_BASE}/api/clips/${clipId}/split`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ split_at_seconds: splitAtSeconds }),
- });
- return await parseJson(response);
-}
-
-export async function mergeWithNextClip(clipId: string): Promise {
- const response = await fetch(`${API_BASE}/api/clips/${clipId}/merge-next`, {
- method: "POST",
- });
- return await parseJson(response);
-}
-
-export async function runClipLabModel(
- clipId: string,
- generatorModel: string,
-): Promise {
- const response = await fetch(`${API_BASE}/api/clips/${clipId}/variants/run`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ generator_model: generatorModel }),
- });
- return await parseJson(response);
-}
-
-export async function setActiveVariant(
- clipId: string,
- activeVariantId: string,
-): Promise {
- const response = await fetch(`${API_BASE}/api/clips/${clipId}/active-variant`, {
- method: "PATCH",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ active_variant_id: activeVariantId }),
- });
- return await parseJson(response);
-}
-
-export async function fetchClipLabWaveformPeaks(
- sliceId: string,
- bins = 120,
-): Promise {
- const response = await fetch(`${API_BASE}/api/slices/${sliceId}/waveform-peaks?bins=${bins}`);
- return await parseJson(response);
-}
-
-export async function markDatasetClipAsReferenceCandidate(
- projectId: string,
- datasetRunId: string,
- payload: { clip_id: string; transcript_text: string },
-): Promise {
- const response = await fetch(
- `${API_BASE}/api/projects/${projectId}/dataset-runs/${datasetRunId}/reference-clip-candidates`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- },
- );
- return await parseJson(response);
-}
-
-export async function saveCurrentSliceAsReference(payload: {
- slice_id: string;
- name?: string | null;
- mood_label?: string | null;
-}): Promise {
- const response = await fetch(`${API_BASE}/api/reference-assets/from-slice`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(payload),
- });
- return await parseJson(response);
-}
-
-export async function promoteReferenceCandidate(payload: {
- run_id: string;
- candidate_id: string;
- source_start_seconds?: number | null;
- source_end_seconds?: number | null;
- name?: string | null;
- mood_label?: string | null;
-}): Promise {
- const response = await fetch(`${API_BASE}/api/reference-assets/from-candidate`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(payload),
- });
- return await parseJson(response);
-}
diff --git a/frontend/src/app/(login-chain)/layout.tsx b/frontend/src/app/(login-chain)/layout.tsx
new file mode 100644
index 0000000..ac586c6
--- /dev/null
+++ b/frontend/src/app/(login-chain)/layout.tsx
@@ -0,0 +1,47 @@
+import { Icons } from "@midday/ui/icons";
+import Link from "next/link";
+import { LoginVideoBackground } from "@/components/login-video-background";
+
+// Shared shell for the login/onboarding-teaser chain (/login through
+// /login6). Lives in a layout (not each page) so the video background +
+// testimonials persist across navigation between these pages instead of
+// unmounting and remounting (which restarted the video and reset the
+// testimonial rotation on every "Continue" click).
+// Chain steps read ?project=/&run= from the URL (useSearchParams) — render dynamically.
+export const dynamic = "force-dynamic";
+
+export default function LoginChainLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+ {/* Logo - Fixed position matching website header exactly */}
+
+
+ {/* Left Side - Video Background (persists across pages in this chain) */}
+
+
+ {/* Right Side - Per-page content */}
+
+
+ );
+}
diff --git a/frontend/src/app/(login-chain)/login/page.tsx b/frontend/src/app/(login-chain)/login/page.tsx
new file mode 100644
index 0000000..4977039
--- /dev/null
+++ b/frontend/src/app/(login-chain)/login/page.tsx
@@ -0,0 +1,28 @@
+import type { Metadata } from "next";
+import { ChainContinueButton } from "@/components/chain-continue-button";
+
+export const metadata: Metadata = {
+ title: "Speechcraft",
+};
+
+// Landing step of the setup wizard. No auth — this is a local single-user
+// tool, so there is no Supabase user, no waitlist gate. Just the entry point
+// into the ingest -> model -> voice -> transcription chain.
+export default function Page() {
+ return (
+ <>
+
+
+ Train better voice models.
+
+
+ The internal tool every voice AI lab built for themselves.
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/app/(login-chain)/login2/page.tsx b/frontend/src/app/(login-chain)/login2/page.tsx
new file mode 100644
index 0000000..f47cedc
--- /dev/null
+++ b/frontend/src/app/(login-chain)/login2/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from "next";
+import { IngestDropzone } from "@/components/ingest-dropzone";
+
+export const metadata: Metadata = {
+ title: "Login | Midday",
+};
+
+export default function Page() {
+ return ;
+}
diff --git a/frontend/src/app/(login-chain)/login3/page.tsx b/frontend/src/app/(login-chain)/login3/page.tsx
new file mode 100644
index 0000000..2cfcdba
--- /dev/null
+++ b/frontend/src/app/(login-chain)/login3/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from "next";
+import { ModelDownload } from "@/components/model-download";
+
+export const metadata: Metadata = {
+ title: "Login | Midday",
+};
+
+export default function Page() {
+ return ;
+}
diff --git a/frontend/src/app/(login-chain)/login4/page.tsx b/frontend/src/app/(login-chain)/login4/page.tsx
new file mode 100644
index 0000000..1405cf3
--- /dev/null
+++ b/frontend/src/app/(login-chain)/login4/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from "next";
+import { VoiceSelection } from "@/components/voice-selection";
+
+export const metadata: Metadata = {
+ title: "Login | Midday",
+};
+
+export default function Page() {
+ return ;
+}
diff --git a/frontend/src/app/(login-chain)/login5/page.tsx b/frontend/src/app/(login-chain)/login5/page.tsx
new file mode 100644
index 0000000..ac2ed56
--- /dev/null
+++ b/frontend/src/app/(login-chain)/login5/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from "next";
+import { TranscriptionProgress } from "@/components/transcription-progress";
+
+export const metadata: Metadata = {
+ title: "Login | Midday",
+};
+
+export default function Page() {
+ return ;
+}
diff --git a/frontend/src/app/(login-chain)/login6/page.tsx b/frontend/src/app/(login-chain)/login6/page.tsx
new file mode 100644
index 0000000..8b7149f
--- /dev/null
+++ b/frontend/src/app/(login-chain)/login6/page.tsx
@@ -0,0 +1,25 @@
+import type { Metadata } from "next";
+import { LoginContinueButton } from "@/components/login-continue-button";
+
+export const metadata: Metadata = {
+ title: "Login | Midday",
+};
+
+export default function Page() {
+ return (
+ <>
+
+
+ Train better voice models.
+
+
+ The internal tool every voice AI lab built for themselves.
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/app/(workstation)/lab/page.tsx b/frontend/src/app/(workstation)/lab/page.tsx
new file mode 100644
index 0000000..3864def
--- /dev/null
+++ b/frontend/src/app/(workstation)/lab/page.tsx
@@ -0,0 +1,19 @@
+import type { Metadata } from "next";
+import { Suspense } from "react";
+import { LabWorkstation } from "@/components/lab/lab-workstation";
+
+export const metadata: Metadata = {
+ title: "Clip Lab | Speechcraft",
+};
+
+// Inherently dynamic: reads URL search params and fetches live data from the
+// FastAPI backend. No value in static prerendering a local workstation page.
+export const dynamic = "force-dynamic";
+
+export default function LabPage() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/app/(workstation)/layout.tsx b/frontend/src/app/(workstation)/layout.tsx
new file mode 100644
index 0000000..3d2b344
--- /dev/null
+++ b/frontend/src/app/(workstation)/layout.tsx
@@ -0,0 +1,11 @@
+// Workstation shell. No sidebar and no auth (local single-user tool). The
+// logo, project switcher, export, and re-run all live in the Lab top-bar now;
+// the content fills the full width. QC and Lab are a feedback loop toggled
+// within the content, not peer destinations.
+export default function WorkstationLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return <>{children}>;
+}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
new file mode 100644
index 0000000..bce11df
--- /dev/null
+++ b/frontend/src/app/layout.tsx
@@ -0,0 +1,53 @@
+import "@/components/ui/globals.css";
+import { cn } from "@midday/ui/cn";
+import { Toaster } from "@midday/ui/toaster";
+import type { Metadata } from "next";
+import { Hedvig_Letters_Sans, Hedvig_Letters_Serif } from "next/font/google";
+import type { ReactNode } from "react";
+import { Providers } from "./providers";
+
+export const metadata: Metadata = {
+ title: "Speechcraft",
+ description: "Turn raw speech recordings into training-ready voice datasets.",
+};
+
+const hedvigSans = Hedvig_Letters_Sans({
+ weight: "400",
+ subsets: ["latin"],
+ display: "swap",
+ variable: "--font-hedvig-sans",
+});
+
+const hedvigSerif = Hedvig_Letters_Serif({
+ weight: "400",
+ subsets: ["latin"],
+ display: "swap",
+ variable: "--font-hedvig-serif",
+});
+
+export const viewport = {
+ width: "device-width",
+ initialScale: 1,
+ themeColor: [
+ { media: "(prefers-color-scheme: light)" },
+ { media: "(prefers-color-scheme: dark)" },
+ ],
+};
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
new file mode 100644
index 0000000..c8e3ea0
--- /dev/null
+++ b/frontend/src/app/page.tsx
@@ -0,0 +1,16 @@
+import Link from "next/link";
+
+// Placeholder root — replaced when the wizard landing (/login) is ported in
+// task 3. Exists so the scaffold boots and routes are reachable during setup.
+export default function Home() {
+ return (
+
+ Speechcraft
+ Scaffold online. Routes:
+
+
+ );
+}
diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx
new file mode 100644
index 0000000..281b42a
--- /dev/null
+++ b/frontend/src/app/providers.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ThemeProvider } from "next-themes";
+import { NuqsAdapter } from "nuqs/adapters/next/app";
+import { type ReactNode, useState } from "react";
+
+// The entire midday backend layer (Supabase, tRPC, i18n, analytics) is gone.
+// A single-user local tool needs only: URL state (nuqs), server-cache
+// (react-query, for the FastAPI reads), and theming.
+export function Providers({ children }: { children: ReactNode }) {
+ const [queryClient] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: { queries: { staleTime: 60_000, retry: 1 } },
+ }),
+ );
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/frontend/src/components/JobActivityPanel.tsx b/frontend/src/components/JobActivityPanel.tsx
deleted file mode 100644
index 59ff4a8..0000000
--- a/frontend/src/components/JobActivityPanel.tsx
+++ /dev/null
@@ -1,129 +0,0 @@
-export type JobActivityState = "idle" | "running" | "completed" | "failed";
-
-export type JobActivityLogLine = {
- id: string;
- timestamp: string;
- message: string;
-};
-
-export type JobActivity = {
- id: string;
- name: string;
- type: "preparation" | "processing" | "slicing" | "qc" | "export" | "mock";
- state: JobActivityState;
- startedAt?: string | null;
- completedAt?: string | null;
- progressLabel?: string | null;
- logs: JobActivityLogLine[];
-};
-
-type JobActivityPanelProps = {
- title?: string;
- job: JobActivity | null;
- maxLogLines?: number;
-};
-
-function formatTime(value?: string | null): string {
- if (!value) {
- return "Not started";
- }
-
- return new Date(value).toLocaleString([], {
- year: "numeric",
- month: "short",
- day: "2-digit",
- hour: "2-digit",
- minute: "2-digit",
- });
-}
-
-function getStateLabel(state: JobActivityState): string {
- if (state === "idle") {
- return "Idle";
- }
-
- if (state === "running") {
- return "Running";
- }
-
- if (state === "completed") {
- return "Completed";
- }
-
- return "Failed";
-}
-
-export default function JobActivityPanel({
- title = "Job activity",
- job,
- maxLogLines = 300,
-}: JobActivityPanelProps) {
- if (!job) {
- return (
-
-
- No long-running job is active for this page.
-
- );
- }
-
- const displayedLogs = job.logs.slice(-maxLogLines);
- const hiddenLogCount = Math.max(0, job.logs.length - displayedLogs.length);
-
- return (
-
-
-
-
{job.type}
-
{job.name}
-
-
{getStateLabel(job.state)}
-
-
-
-
-
- Started
- - {formatTime(job.startedAt)}
-
-
-
- Progress
- - {job.progressLabel ?? getStateLabel(job.state)}
-
-
-
- Finished
- - {formatTime(job.completedAt)}
-
-
-
-
- {displayedLogs.length > 0 ? (
- <>
- {hiddenLogCount > 0 ? (
-
- ...
- Showing latest {displayedLogs.length} lines; {hiddenLogCount} earlier lines hidden.
-
- ) : null}
- {displayedLogs.map((line) => (
-
- {line.timestamp}
- {line.message}
-
- ))}
- >
- ) : (
-
- --:--
- Waiting for backend log output.
-
- )}
-
-
- );
-}
diff --git a/frontend/src/components/chain-continue-button.tsx b/frontend/src/components/chain-continue-button.tsx
new file mode 100644
index 0000000..893c77f
--- /dev/null
+++ b/frontend/src/components/chain-continue-button.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { SubmitButton } from "@midday/ui/submit-button";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useState } from "react";
+import type { ReactNode } from "react";
+import { demoEnabled, withDemo } from "@/lib/demo";
+
+type Props = {
+ href: string;
+ label?: ReactNode;
+ icon?: ReactNode;
+};
+
+export function ChainContinueButton({
+ href,
+ label = "Continue",
+ icon,
+}: Props) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [isLoading, setLoading] = useState(false);
+
+ const handleContinue = () => {
+ setLoading(true);
+ router.push(withDemo(href, demoEnabled(searchParams)));
+ };
+
+ return (
+
+
+ {icon}
+ {label}
+
+
+ );
+}
diff --git a/frontend/src/components/ingest-dropzone.tsx b/frontend/src/components/ingest-dropzone.tsx
new file mode 100644
index 0000000..5dedbd5
--- /dev/null
+++ b/frontend/src/components/ingest-dropzone.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import { cn } from "@midday/ui/cn";
+import { Progress } from "@midday/ui/progress";
+import { SubmitButton } from "@midday/ui/submit-button";
+import { useToast } from "@midday/ui/use-toast";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useCallback, useRef, useState } from "react";
+import { type FileRejection, useDropzone } from "react-dropzone";
+import { SpeechcraftApiError } from "@/components/lab/speechcraft-write-api";
+import { demoEnabled, withDemo } from "@/lib/demo";
+import { createProject, makeProjectId, uploadRecording } from "./wizard/wizard-api";
+
+type Phase = "idle" | "uploading" | "error";
+
+export function IngestDropzone() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const demo = demoEnabled(searchParams);
+ const { toast } = useToast();
+ const [phase, setPhase] = useState("idle");
+ const [progress, setProgress] = useState(0);
+ const [current, setCurrent] = useState(0);
+ const [total, setTotal] = useState(0);
+ const startedRef = useRef(false);
+
+ const ingest = useCallback(
+ async (files: File[]) => {
+ if (startedRef.current || files.length === 0) return;
+ startedRef.current = true;
+
+ // Demo mode: skip the real upload entirely, just advance the flow.
+ if (demo) {
+ router.push(withDemo("/login3?project=demo-review", true));
+ return;
+ }
+
+ setPhase("uploading");
+ setTotal(files.length);
+ setCurrent(0);
+ setProgress(0);
+
+ const name =
+ files.length === 1
+ ? files[0]!.name.replace(/\.[a-z0-9]+$/i, "")
+ : `${files.length} recordings`;
+ const projectId = makeProjectId(files[0]!.name);
+
+ try {
+ await createProject(projectId, name);
+ for (let i = 0; i < files.length; i++) {
+ setCurrent(i + 1);
+ await uploadRecording(projectId, files[i]!, (pct) => {
+ // Aggregate progress across all files.
+ setProgress(Math.round(((i + pct / 100) / files.length) * 100));
+ });
+ }
+ setProgress(100);
+ router.push(withDemo(`/login3?project=${encodeURIComponent(projectId)}`, demo));
+ } catch (err) {
+ startedRef.current = false;
+ setPhase("error");
+ toast({
+ variant: "error",
+ duration: 4000,
+ title: "Upload failed",
+ description:
+ err instanceof SpeechcraftApiError ? err.detail : "Could not ingest the recordings.",
+ });
+ }
+ },
+ [router, toast, demo],
+ );
+
+ const onDropRejected = ([reject]: FileRejection[]) => {
+ if (reject?.errors.find(({ code }) => code === "file-invalid-type")) {
+ toast({ duration: 2500, variant: "error", title: "Only .wav files are supported." });
+ }
+ };
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop: (files) => void ingest(files),
+ onDropRejected,
+ accept: { "audio/wav": [".wav"], "audio/x-wav": [".wav"] },
+ disabled: phase === "uploading",
+ });
+
+ if (phase === "uploading") {
+ return (
+ <>
+
+
Ingesting your dataset
+
+ Uploading recording {current} of {total}…
+
+
+
+
+
+ {progress}%
+ {progress >= 100 ? "Finishing…" : "Streaming to backend"}
+
+
+ >
+ );
+ }
+
+ return (
+ evt.stopPropagation() })} className="relative">
+
+
Drop your .wav files here.
+
+
+
+
+
+
Ingest your dataset
+
+ {phase === "error"
+ ? "Something went wrong. Drag & drop your .wav files to try again."
+ : "Drag & drop or upload your .wav recordings."}
+
+
+
+
+ document.getElementById("upload-files")?.click()}
+ className="bg-primary px-6 py-4 text-secondary font-medium flex space-x-2 h-[40px] w-full"
+ isSubmitting={false}
+ >
+ Upload
+
+
+
+ );
+}
diff --git a/frontend/src/components/lab/boundary-tables.tsx b/frontend/src/components/lab/boundary-tables.tsx
new file mode 100644
index 0000000..7277179
--- /dev/null
+++ b/frontend/src/components/lab/boundary-tables.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@midday/ui/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@midday/ui/table";
+import { useMemo, useState } from "react";
+import {
+ bestRejected,
+ riskiestKept,
+ type KeptSort,
+ type ManualOverride,
+ type QcClip,
+ type RejectedSort,
+} from "./qc-logic";
+
+function formatScore(score: number | null): string {
+ return score === null ? "—" : score.toFixed(1);
+}
+
+function formatMargin(margin: number): string {
+ if (!Number.isFinite(margin)) return "—";
+ const sign = margin > 0 ? "+" : "";
+ return `${sign}${margin.toFixed(1)}`;
+}
+
+function truncate(text: string, max = 64): string {
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
+}
+
+type BoundaryTablesProps = {
+ clips: QcClip[];
+ transcriptThreshold: number;
+ speakerThreshold: number;
+ overrides?: Record;
+ limit?: number;
+};
+
+export function RiskiestKeptTable({
+ clips,
+ transcriptThreshold,
+ speakerThreshold,
+ overrides = {},
+ limit = 10,
+}: BoundaryTablesProps) {
+ const [sort, setSort] = useState("risk");
+
+ const rows = useMemo(
+ () =>
+ riskiestKept(clips, transcriptThreshold, speakerThreshold, sort, overrides).slice(0, limit),
+ [clips, transcriptThreshold, speakerThreshold, sort, overrides, limit],
+ );
+
+ return (
+
+
+
+
Riskiest kept
+
+ Accepted clips closest to failing — the ones worth a human ear.
+
+
+
+
+
+ {rows.length === 0 ? (
+
No accepted clips at these thresholds.
+ ) : (
+
+
+
+ Clip
+ Transcript
+ Speaker
+ Risk margin
+
+
+
+ {rows.map(({ clip, transcriptMargin, speakerMargin, riskMargin }) => (
+
+
+ {clip.clipId}
+ {clip.trainingText && (
+
+ {truncate(clip.trainingText)}
+
+ )}
+
+
+ {formatScore(clip.transcriptMatch)}
+ ({formatMargin(transcriptMargin)})
+
+
+ {formatScore(clip.speakerCheck)}
+ ({formatMargin(speakerMargin)})
+
+ {formatMargin(riskMargin)}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+export function BestRejectedTable({
+ clips,
+ transcriptThreshold,
+ speakerThreshold,
+ overrides = {},
+ limit = 10,
+}: BoundaryTablesProps) {
+ const [sort, setSort] = useState("closest");
+
+ const rows = useMemo(
+ () =>
+ bestRejected(clips, transcriptThreshold, speakerThreshold, sort, overrides).slice(0, limit),
+ [clips, transcriptThreshold, speakerThreshold, sort, overrides, limit],
+ );
+
+ return (
+
+
+
+
Best rejected
+
+ Rejected clips closest to passing — recoverable if thresholds relax.
+
+
+
+
+
+ {rows.length === 0 ? (
+
No rejected clips match this filter.
+ ) : (
+
+
+
+ Clip
+ Transcript
+ Speaker
+ Recovery gap
+
+
+
+ {rows.map(({ clip, transcriptGap, speakerGap, recoveryGap }) => (
+
+
+ {clip.clipId}
+ {clip.trainingText && (
+
+ {truncate(clip.trainingText)}
+
+ )}
+
+
+ {formatScore(clip.transcriptMatch)}
+ {transcriptGap > 0 && (
+ (-{transcriptGap.toFixed(1)})
+ )}
+
+
+ {formatScore(clip.speakerCheck)}
+ {speakerGap > 0 && (
+ (-{speakerGap.toFixed(1)})
+ )}
+
+ {recoveryGap.toFixed(1)}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/lab/clip-lab-panel.tsx b/frontend/src/components/lab/clip-lab-panel.tsx
new file mode 100644
index 0000000..2ce336b
--- /dev/null
+++ b/frontend/src/components/lab/clip-lab-panel.tsx
@@ -0,0 +1,244 @@
+"use client";
+
+import { Button } from "@midday/ui/button";
+import { cn } from "@midday/ui/cn";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@midday/ui/dropdown-menu";
+import { Icons } from "@midday/ui/icons";
+import { Separator } from "@midday/ui/separator";
+import { useCallback, useEffect, useRef, useState } from "react";
+import type WaveSurfer from "wavesurfer.js";
+import { type ClipEdit, type LabClip, formatClock, formatSeconds } from "./lab-data";
+import { WaveformPane } from "./waveform-pane";
+
+const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
+
+type ClipLabPanelProps = {
+ clip: LabClip;
+ canUndo: boolean;
+ canRedo: boolean;
+ onAccept: () => void;
+ onReject: () => void;
+ onAppendEdit: (edit: ClipEdit) => void;
+ onUndo: () => void;
+ onRedo: () => void;
+ onMarkReference: () => void;
+ onRunModel: () => void;
+};
+
+export function ClipLabPanel({
+ clip,
+ canUndo,
+ canRedo,
+ onAccept,
+ onReject,
+ onAppendEdit,
+ onUndo,
+ onRedo,
+ onMarkReference,
+ onRunModel,
+}: ClipLabPanelProps) {
+ const waveSurferRef = useRef(null);
+ const [playhead, setPlayhead] = useState(0);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [rate, setRate] = useState(1);
+ const [selectionStart, setSelectionStart] = useState(0);
+ const [selectionEnd, setSelectionEnd] = useState(0);
+ const [hover, setHover] = useState(null);
+
+ const duration = clip.durationSeconds;
+ const selLo = Math.min(selectionStart, selectionEnd);
+ const selHi = Math.max(selectionStart, selectionEnd);
+ const hasSelection = selHi - selLo > 0.02;
+
+ // Reset transient state on clip change.
+ useEffect(() => {
+ setPlayhead(0);
+ setSelectionStart(0);
+ setSelectionEnd(0);
+ setHover(null);
+ setIsPlaying(false);
+ }, [clip.id]);
+
+ const togglePlayback = useCallback(() => {
+ const ws = waveSurferRef.current;
+ if (!ws) return;
+ if (ws.isPlaying()) {
+ ws.pause();
+ return;
+ }
+ if (selHi - selLo > 0.02) {
+ void ws.play(selLo, selHi);
+ } else {
+ void ws.play();
+ }
+ }, [selLo, selHi]);
+
+ // Space toggles playback.
+ useEffect(() => {
+ const handler = (event: KeyboardEvent) => {
+ const target = event.target as HTMLElement | null;
+ if (
+ target &&
+ (target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.isContentEditable)
+ ) {
+ return;
+ }
+ if (event.code === "Space" && !event.metaKey && !event.ctrlKey) {
+ event.preventDefault();
+ togglePlayback();
+ }
+ };
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
+ }, [togglePlayback]);
+
+ const cycleRate = () => {
+ const idx = PLAYBACK_RATES.indexOf(rate as (typeof PLAYBACK_RATES)[number]);
+ const next = PLAYBACK_RATES[(idx + 1) % PLAYBACK_RATES.length]!;
+ setRate(next);
+ waveSurferRef.current?.setPlaybackRate(next, true);
+ };
+
+ const pausePlayback = () => waveSurferRef.current?.pause();
+
+ const insertSilence = () => {
+ pausePlayback();
+ onAppendEdit({ op: "insert_silence", startSeconds: playhead, durationSeconds: 0.2 });
+ };
+ const deleteSelection = () => {
+ pausePlayback();
+ onAppendEdit({ op: "delete_range", startSeconds: selLo, endSeconds: selHi });
+ setSelectionStart(0);
+ setSelectionEnd(0);
+ };
+ const splitClip = () => {
+ pausePlayback();
+ onAppendEdit({ op: "split", startSeconds: hasSelection ? selHi : Number((duration / 2).toFixed(2)) });
+ };
+ const mergeClip = () => {
+ pausePlayback();
+ onAppendEdit({ op: "merge_next" });
+ };
+
+ return (
+
+
+
+ Clip Lab
+
+
+ {formatSeconds(duration)}
+ {(clip.sampleRateHz / 1000).toFixed(0)} kHz
+ {clip.variant}
+
+
+
+
+
+
+
+
+
+
+
+
+ Mark as reference candidate
+
+ Run DeepFilterNet
+
+
+
+
+
+ {clip.audioUrl ? (
+
{
+ setSelectionStart(s);
+ setSelectionEnd(e);
+ }}
+ onCursorChange={setPlayhead}
+ onHoverChange={setHover}
+ onReady={(ws) => {
+ waveSurferRef.current = ws;
+ ws?.setPlaybackRate(rate, true);
+ }}
+ onPlayingChange={setIsPlaying}
+ />
+ ) : (
+
+ No audio for this clip.
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {hasSelection ? (
+
+ ) : null}
+
+
+
+ {hasSelection
+ ? `Sel ${formatClock(selLo)}–${formatClock(selHi)}`
+ : hover !== null
+ ? `Hover ${formatClock(hover)}`
+ : "Sel none"}
+
+
+ {formatClock(playhead)} / {formatClock(duration)}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/lab/clip-queue.tsx b/frontend/src/components/lab/clip-queue.tsx
new file mode 100644
index 0000000..770cb88
--- /dev/null
+++ b/frontend/src/components/lab/clip-queue.tsx
@@ -0,0 +1,261 @@
+"use client";
+
+import { Badge } from "@midday/ui/badge";
+import { Button } from "@midday/ui/button";
+import { cn } from "@midday/ui/cn";
+import { Icons } from "@midday/ui/icons";
+import { Input } from "@midday/ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "@midday/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@midday/ui/select";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import { useRef } from "react";
+import {
+ type LabClip,
+ type MachineBucket,
+ MACHINE_BUCKET_LABELS,
+ MACHINE_BUCKET_ORDER,
+ type ReviewStatus,
+ REVIEW_STATUS_ORDER,
+ type SortMode,
+ SORT_OPTIONS,
+ STATUS_LABELS,
+ formatSeconds,
+} from "./lab-data";
+import { StatusBadge } from "./status-badge";
+
+type ClipQueueProps = {
+ clips: LabClip[];
+ activeClipId: string | null;
+ search: string;
+ onSearchChange: (value: string) => void;
+ statuses: ReviewStatus[];
+ tags: string[];
+ availableTags: string[];
+ buckets: MachineBucket[];
+ onToggleStatus: (status: ReviewStatus) => void;
+ onToggleTag: (tag: string) => void;
+ onToggleBucket: (bucket: MachineBucket) => void;
+ onClearFilters: () => void;
+ sortMode: SortMode;
+ onSortModeChange: (mode: SortMode) => void;
+ onSelect: (id: string) => void;
+};
+
+export function ClipQueue({
+ clips,
+ activeClipId,
+ search,
+ onSearchChange,
+ statuses,
+ tags,
+ availableTags,
+ buckets,
+ onToggleStatus,
+ onToggleTag,
+ onToggleBucket,
+ onClearFilters,
+ sortMode,
+ onSortModeChange,
+ onSelect,
+}: ClipQueueProps) {
+ const scrollRef = useRef(null);
+ const activeFilterCount = statuses.length + tags.length + buckets.length;
+
+ const virtualizer = useVirtualizer({
+ count: clips.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 104,
+ overscan: 8,
+ });
+
+ const filterSummary =
+ activeFilterCount > 0
+ ? [
+ ...statuses.map((s) => STATUS_LABELS[s]),
+ ...buckets.map((b) => MACHINE_BUCKET_LABELS[b]),
+ ...tags,
+ ].join(", ")
+ : "All slices";
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/lab/dataset-health-page.tsx b/frontend/src/components/lab/dataset-health-page.tsx
new file mode 100644
index 0000000..5a6e983
--- /dev/null
+++ b/frontend/src/components/lab/dataset-health-page.tsx
@@ -0,0 +1,276 @@
+"use client";
+
+import { Button } from "@midday/ui/button";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@midday/ui/alert-dialog";
+import { useToast } from "@midday/ui/use-toast";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useMemo, useState } from "react";
+import { BestRejectedTable, RiskiestKeptTable } from "./boundary-tables";
+import { combinedSummary, histogram, unscoredCount, type QcClip } from "./qc-logic";
+import { fetchDatasetQc, type DatasetQcClipApi } from "./speechcraft-api";
+import { finalizeDatasetQc, SpeechcraftApiError } from "./speechcraft-write-api";
+import { ThresholdHistogramChart } from "./threshold-histogram-chart";
+
+function toQcClip(api: DatasetQcClipApi): QcClip {
+ return {
+ clipId: api.clip_id,
+ durationSec: api.duration_sec,
+ transcriptMatch: api.transcript_match,
+ speakerCheck: api.speaker_check,
+ reasonCodes: [
+ ...api.transcript_reason_codes,
+ ...api.speaker_reason_codes,
+ ...api.candidate_reason_codes,
+ ...api.qc_reason_codes,
+ ],
+ trainingText: api.training_text,
+ };
+}
+
+function formatDuration(seconds: number): string {
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.round(seconds % 60);
+ return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
+}
+
+const DEMO_CLIPS: QcClip[] = Array.from({ length: 140 }, (_, i) => {
+ const seed = (i * 9301 + 49297) % 233280;
+ const rand = seed / 233280;
+ const transcriptMatch = Math.max(0, Math.min(100, 60 + rand * 45 - 10));
+ const speakerCheck = Math.max(0, Math.min(100, 55 + ((seed * 3) % 233280) / 233280 * 45));
+ return {
+ clipId: `demo-clip-${i + 1}`,
+ durationSec: 3 + rand * 9,
+ transcriptMatch: i % 17 === 0 ? null : Number(transcriptMatch.toFixed(1)),
+ speakerCheck: i % 23 === 0 ? null : Number(speakerCheck.toFixed(1)),
+ reasonCodes: [],
+ trainingText: "The quick brown fox jumps over the lazy dog near the riverbank.",
+ };
+});
+
+export function DatasetHealthPage({
+ runId,
+ demo = false,
+}: {
+ runId: string | null;
+ demo?: boolean;
+}) {
+ const { toast } = useToast();
+ const queryClient = useQueryClient();
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [committing, setCommitting] = useState(false);
+
+ const { data, isLoading, error } = useQuery({
+ queryKey: ["sc-dataset-qc", runId],
+ queryFn: () => fetchDatasetQc(runId!),
+ enabled: !demo && !!runId,
+ staleTime: 30_000,
+ });
+
+ const clips = useMemo(
+ () => (demo ? DEMO_CLIPS : (data?.clips ?? []).map(toQcClip)),
+ [demo, data],
+ );
+
+ const defaults = data?.defaults ?? { transcript_match_threshold: 85, speaker_check_threshold: 70 };
+ const initialTranscript = data?.finalized_thresholds?.transcript_match_min ?? defaults.transcript_match_threshold;
+ const initialSpeaker = data?.finalized_thresholds?.speaker_check_min ?? defaults.speaker_check_threshold;
+
+ const [transcriptThreshold, setTranscriptThreshold] = useState(initialTranscript);
+ const [speakerThreshold, setSpeakerThreshold] = useState(initialSpeaker);
+
+ // Re-sync local thresholds once when the finalized state actually loads
+ // (demo mode has no async load, so it's already correct on first render).
+ const syncKey = demo ? "demo" : data ? "loaded" : "pending";
+ const [lastSyncKey, setLastSyncKey] = useState(null);
+ if (syncKey !== "pending" && syncKey !== lastSyncKey) {
+ setTranscriptThreshold(initialTranscript);
+ setSpeakerThreshold(initialSpeaker);
+ setLastSyncKey(syncKey);
+ }
+
+ const transcriptBins = useMemo(() => histogram(clips, (c) => c.transcriptMatch), [clips]);
+ const speakerBins = useMemo(() => histogram(clips, (c) => c.speakerCheck), [clips]);
+
+ const transcriptAccepted = useMemo(
+ () => clips.filter((c) => (c.transcriptMatch ?? -1) >= transcriptThreshold),
+ [clips, transcriptThreshold],
+ );
+ const speakerAccepted = useMemo(
+ () => clips.filter((c) => (c.speakerCheck ?? -1) >= speakerThreshold),
+ [clips, speakerThreshold],
+ );
+
+ const summary = useMemo(
+ () => combinedSummary(clips, transcriptThreshold, speakerThreshold),
+ [clips, transcriptThreshold, speakerThreshold],
+ );
+
+ const ready = demo || (data?.ready ?? false);
+ const qcNotReady = !demo && data && !data.ready;
+
+ const handleCommit = async () => {
+ if (!runId) return;
+ setCommitting(true);
+ try {
+ const result = await finalizeDatasetQc(runId, {
+ transcript_match_min: transcriptThreshold,
+ speaker_check_min: speakerThreshold,
+ });
+ toast({
+ title: "Thresholds committed",
+ description: `${result.summary.accepted_count} clips accepted (${formatDuration(result.summary.accepted_duration_sec)}). Export is now stale.`,
+ variant: "success",
+ duration: 4000,
+ });
+ queryClient.invalidateQueries({ queryKey: ["sc-dataset-qc", runId] });
+ queryClient.invalidateQueries({ queryKey: ["sc-cliplab", runId] });
+ } catch (err) {
+ toast({
+ title: "Commit failed",
+ description: err instanceof SpeechcraftApiError ? err.detail : String(err),
+ variant: "error",
+ duration: 4500,
+ });
+ } finally {
+ setCommitting(false);
+ setConfirmOpen(false);
+ }
+ };
+
+ if (!demo && !runId) {
+ return (
+
+
No dataset run selected yet.
+
+ );
+ }
+
+ if (!demo && isLoading) {
+ return (
+
+ Loading dataset health…
+
+ );
+ }
+
+ if (!demo && error) {
+ return (
+
+
+
Couldn't load QC
+
+ {error instanceof SpeechcraftApiError ? error.detail : String(error)}
+
+
+
+ );
+ }
+
+ if (qcNotReady) {
+ return (
+
+
+
QC hasn't run yet
+
+ {data?.missing_artifacts.length
+ ? `Missing: ${data.missing_artifacts.join(", ")}.`
+ : "Run transcript and speaker QC for this run to see dataset health."}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {summary.acceptedCount} of {clips.length} clips pass both gates at these thresholds ·{" "}
+ {formatDuration(summary.acceptedDurationSec)}
+ {data?.finalized && (
+
+ (committed: {data.finalized_thresholds?.transcript_match_min}/
+ {data.finalized_thresholds?.speaker_check_min})
+
+ )}
+
+
+
+
+
+ c.transcriptMatch)}
+ acceptedCount={transcriptAccepted.length}
+ acceptedDurationSec={transcriptAccepted.reduce((sum, c) => sum + c.durationSec, 0)}
+ />
+ c.speakerCheck)}
+ acceptedCount={speakerAccepted.length}
+ acceptedDurationSec={speakerAccepted.reduce((sum, c) => sum + c.durationSec, 0)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ Commit these thresholds?
+
+ This writes the verdict for every clip at transcript ≥ {transcriptThreshold} and
+ speaker ≥ {speakerThreshold}, and invalidates any existing export for this run.
+ Human overrides in Clip Lab still take priority.
+
+
+
+ Cancel
+ void handleCommit()}>
+ {committing ? "Committing…" : "Commit"}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/lab/diagnostics-drawer.tsx b/frontend/src/components/lab/diagnostics-drawer.tsx
new file mode 100644
index 0000000..a07a186
--- /dev/null
+++ b/frontend/src/components/lab/diagnostics-drawer.tsx
@@ -0,0 +1,201 @@
+"use client";
+
+import { Badge } from "@midday/ui/badge";
+import { Button } from "@midday/ui/button";
+import { cn } from "@midday/ui/cn";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@midday/ui/collapsible";
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@midday/ui/sheet";
+import { useQuery } from "@tanstack/react-query";
+import { useEffect, useState } from "react";
+import { fetchRunLog } from "./speechcraft-api";
+
+// Kind-A (process) observability. One run-scoped drawer: run status + the raw
+// backend log, shown fully. Auto-surfaces on failure. The optional `stages`
+// prop is for the wizard, where each step is a stage with its own status/log
+// (current expanded, prior collapsed); the Lab omits it and just shows the
+// run log. Backend run-log is a flat tail blob, so per-stage log text only
+// exists when a caller supplies it (e.g. per-ProcessingJob in the wizard).
+
+export type DiagnosticStage = {
+ key: string;
+ label: string;
+ status: "pending" | "running" | "completed" | "failed" | "idle" | string;
+ logText?: string;
+};
+
+const STATUS_TONE: Record = {
+ running: "text-blue-500 border-blue-500/40",
+ completed: "text-emerald-600 border-emerald-600/40",
+ failed: "text-red-500 border-red-500/40",
+ needs_review: "text-amber-500 border-amber-500/40",
+ pending: "text-muted-foreground",
+ idle: "text-muted-foreground",
+};
+
+function LogPane({ text, truncated }: { text: string; truncated?: boolean }) {
+ return (
+
+ {truncated ? (
+
+ Showing the latest portion of the log (truncated).
+
+ ) : null}
+
+ {text || "(empty)"}
+
+
+ );
+}
+
+function StageRow({ stage }: { stage: DiagnosticStage }) {
+ const [open, setOpen] = useState(
+ stage.status === "running" || stage.status === "failed",
+ );
+ return (
+
+
+
+
+
+ {stage.logText ? (
+
+ {stage.logText}
+
+ ) : (
+
+ No stage-specific log — see the full backend log below.
+
+ )}
+
+
+ );
+}
+
+export function DiagnosticsSheet({
+ runId,
+ runStatus,
+ runStage,
+ stages,
+ triggerClassName,
+}: {
+ runId: string | null;
+ runStatus?: string;
+ runStage?: string;
+ stages?: DiagnosticStage[];
+ triggerClassName?: string;
+}) {
+ const [open, setOpen] = useState(false);
+ const [autoOpenedFor, setAutoOpenedFor] = useState(null);
+
+ // Break-glass: surface automatically the first time a run fails.
+ useEffect(() => {
+ if (runStatus === "failed" && runId && autoOpenedFor !== runId) {
+ setOpen(true);
+ setAutoOpenedFor(runId);
+ }
+ }, [runStatus, runId, autoOpenedFor]);
+
+ const {
+ data: log,
+ isLoading,
+ refetch,
+ isFetching,
+ } = useQuery({
+ queryKey: ["sc-runlog", runId],
+ queryFn: () => fetchRunLog(runId!),
+ enabled: open && !!runId,
+ refetchInterval: open && runStatus === "running" ? 4000 : false,
+ });
+
+ return (
+
+
+
+
+
+
+ Diagnostics
+
+ {runId ?? "—"}
+ {runStage ? (
+ <>
+ ·
+ {runStage.replace(/_/g, " ")}
+ >
+ ) : null}
+ {runStatus ? (
+
+ {runStatus}
+
+ ) : null}
+
+
+
+ {stages && stages.length > 0 ? (
+ {stages.map((s) => )}
+ ) : null}
+
+
+
+ Backend log
+
+
+
+
+ {!runId ? (
+ No active run.
+ ) : isLoading ? (
+ Loading log…
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/lab/export-dialog.tsx b/frontend/src/components/lab/export-dialog.tsx
new file mode 100644
index 0000000..bb38f4c
--- /dev/null
+++ b/frontend/src/components/lab/export-dialog.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import { Button } from "@midday/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@midday/ui/dialog";
+import { Label } from "@midday/ui/label";
+import { RadioGroup, RadioGroupItem } from "@midday/ui/radio-group";
+import { Separator } from "@midday/ui/separator";
+import { Spinner } from "@midday/ui/spinner";
+import { Switch } from "@midday/ui/switch";
+import { useToast } from "@midday/ui/use-toast";
+import { useState } from "react";
+
+type ExportDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+};
+
+const FORMATS = [
+ {
+ value: "ljspeech",
+ label: "LJSpeech",
+ hint: "metadata.csv + wavs/ — the common single-speaker TTS layout.",
+ },
+ {
+ value: "jsonl",
+ label: "JSONL manifest",
+ hint: "One JSON line per clip (audio path, text, speaker, duration).",
+ },
+ {
+ value: "pairs",
+ label: "WAV + txt pairs",
+ hint: "A .wav and matching .txt transcript per accepted clip.",
+ },
+] as const;
+
+export function ExportDialog({ open, onOpenChange }: ExportDialogProps) {
+ const { toast, update } = useToast();
+ const [format, setFormat] = useState("ljspeech");
+ const [includeRejected, setIncludeRejected] = useState(false);
+ const [normalizeLoudness, setNormalizeLoudness] = useState(true);
+ const [isExporting, setIsExporting] = useState(false);
+
+ const startExport = () => {
+ setIsExporting(true);
+ onOpenChange(false);
+ const { id } = toast({
+ title: "Exporting dataset",
+ description: `Compiling accepted clips → ${format}.`,
+ variant: "progress",
+ progress: 0,
+ duration: Number.POSITIVE_INFINITY,
+ });
+ const started = Date.now();
+ const interval = setInterval(() => {
+ const pct = Math.min(100, Math.round(((Date.now() - started) / 4000) * 100));
+ update(id, { id, progress: pct });
+ if (pct >= 100) {
+ clearInterval(interval);
+ setIsExporting(false);
+ update(id, {
+ id,
+ title: "Export complete",
+ description: "Your dataset is ready in the project's exports folder.",
+ variant: "success",
+ duration: 3000,
+ });
+ }
+ }, 120);
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/lab/inspector-rail.tsx b/frontend/src/components/lab/inspector-rail.tsx
new file mode 100644
index 0000000..28dc208
--- /dev/null
+++ b/frontend/src/components/lab/inspector-rail.tsx
@@ -0,0 +1,242 @@
+"use client";
+
+import { Badge } from "@midday/ui/badge";
+import { Button } from "@midday/ui/button";
+import { cn } from "@midday/ui/cn";
+import { Progress } from "@midday/ui/progress";
+import { Separator } from "@midday/ui/separator";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@midday/ui/tabs";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@midday/ui/tooltip";
+import {
+ type LabClip,
+ type ReviewStatus,
+ MACHINE_BUCKET_LABELS,
+ REASON_CODE_LABELS,
+ REVIEW_STATUS_ORDER,
+ STATUS_LABELS,
+ formatDurationCompact,
+ formatSeconds,
+} from "./lab-data";
+import { StatusBadge } from "./status-badge";
+
+type Stats = {
+ total: number;
+ reviewed: number;
+ counts: Record;
+};
+
+type InspectorRailProps = {
+ clip: LabClip;
+ stats: Stats;
+ onStatusChange: (status: ReviewStatus) => void;
+ onSaveReference: () => void;
+};
+
+function StatRow({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+export function InspectorRail({
+ clip,
+ stats,
+ onStatusChange,
+ onSaveReference,
+}: InspectorRailProps) {
+ const reviewedPercent = stats.total > 0 ? (stats.reviewed / stats.total) * 100 : 0;
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/lab/keyboard-bar.tsx b/frontend/src/components/lab/keyboard-bar.tsx
new file mode 100644
index 0000000..a580fc2
--- /dev/null
+++ b/frontend/src/components/lab/keyboard-bar.tsx
@@ -0,0 +1,33 @@
+// Presentational keyboard legend, mirroring Midday's shortcut convention
+// (small bordered keycap pills, e.g. transaction-shortcuts.tsx). The actual
+// hotkeys are wired in LabWorkstation via react-hotkeys-hook.
+
+const SHORTCUTS: { keys: string[]; label: string }[] = [
+ { keys: ["Space"], label: "Play / pause" },
+ { keys: ["Enter"], label: "Accept & next" },
+ { keys: ["⇧", "Enter"], label: "Reject & next" },
+ { keys: ["↑", "↓"], label: "Prev / next clip" },
+ { keys: ["⌘", "Z"], label: "Undo" },
+];
+
+export function KeyboardBar() {
+ return (
+
+ {SHORTCUTS.map((shortcut) => (
+
+
+ {shortcut.keys.map((key) => (
+
+ {key}
+
+ ))}
+
+ {shortcut.label}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/lab/lab-data.ts b/frontend/src/components/lab/lab-data.ts
new file mode 100644
index 0000000..81d8a0b
--- /dev/null
+++ b/frontend/src/components/lab/lab-data.ts
@@ -0,0 +1,325 @@
+// Mock data + types + helpers for the Clip Lab workstation.
+//
+// This is the no-backend mock phase: clips, projects, peaks, QC scores are
+// all synthesized client-side so the full Lab UX can be built and felt
+// before the Python/FastAPI backend exists. Shapes intentionally mirror
+// speechcraft's real Lab data model (review status, QC buckets, revisions,
+// EDL edits, provenance) so wiring the real API later is a swap, not a
+// redesign.
+
+export type ReviewStatus = "unresolved" | "accepted" | "rejected" | "quarantined";
+export type MachineBucket = "auto_kept" | "needs_review" | "auto_rejected";
+
+export type ClipRevision = {
+ id: string;
+ message: string;
+ status: ReviewStatus;
+ transcript: string;
+ createdAt: string;
+ milestone: boolean;
+};
+
+export type ClipEdit = {
+ op: string;
+ startSeconds?: number;
+ endSeconds?: number;
+ durationSeconds?: number;
+};
+
+export type LabClip = {
+ id: string;
+ order: number;
+ transcript: string;
+ originalTranscript: string;
+ durationSeconds: number;
+ status: ReviewStatus;
+ machineBucket: MachineBucket;
+ qcScore: number; // 0..1 aggregate
+ transcriptConfidence: number; // 0..100 (0 when unscored — see transcriptMatchRaw)
+ speakerPurity: number; // 0..100 (0 when unscored — see speakerCheckRaw)
+ // Raw nullable scores, distinct from the 0-defaulted fields above. null
+ // means "unscored" (backend returned null) — used wherever "unscored" must
+ // be told apart from "scored 0", e.g. re-deriving machineBucket when
+ // thresholds change after the clip was already mapped.
+ transcriptMatchRaw: number | null;
+ speakerCheckRaw: number | null;
+ variant: string; // "source" or a model name
+ tags: string[];
+ reasonCodes: string[];
+ peaks: number[]; // mock waveform envelope, 0..1
+ sampleRateHz: number;
+ channels: number;
+ speaker: string;
+ language: string;
+ sourceRecording: string;
+ originalStartSeconds: number;
+ originalEndSeconds: number;
+ revisions: ClipRevision[];
+ edits: ClipEdit[];
+ // Present when backed by the live speechcraft API (null in mock mode).
+ audioUrl?: string | null;
+ waveformPeaksUrl?: string | null;
+ // Backend optimistic-concurrency + audio-op tokens (live-backed only).
+ clipVersion?: number;
+ manifestSha?: string;
+ effectiveAudioRevisionKey?: string | null;
+ renderStatus?: "ready" | "pending" | "failed";
+ canUndoAudio?: boolean;
+ canRedoAudio?: boolean;
+ audioEditOpCount?: number;
+};
+
+export type LabProject = {
+ id: string;
+ name: string;
+};
+
+export const REVIEW_STATUS_ORDER: ReviewStatus[] = [
+ "unresolved",
+ "accepted",
+ "rejected",
+ "quarantined",
+];
+
+export const STATUS_LABELS: Record = {
+ unresolved: "Unresolved",
+ accepted: "Accepted",
+ rejected: "Rejected",
+ quarantined: "Quarantined",
+};
+
+export const MACHINE_BUCKET_LABELS: Record = {
+ auto_kept: "Auto-kept",
+ needs_review: "Needs review",
+ auto_rejected: "Auto-rejected",
+};
+
+export const MACHINE_BUCKET_ORDER: MachineBucket[] = [
+ "auto_kept",
+ "needs_review",
+ "auto_rejected",
+];
+
+export const REASON_CODE_LABELS: Record = {
+ broken_audio: "Broken audio",
+ near_silence_unusable_clip: "Near-silent / unusable",
+ transcript_mismatch: "Transcript mismatch",
+ severe_clipping_corruption: "Severe clipping / corruption",
+ overlap_second_speaker: "Second speaker overlap",
+};
+
+// ── Formatting ──────────────────────────────────────────────────────────
+
+/** "5.37s" style duration. */
+export function formatSeconds(seconds: number): string {
+ return `${seconds.toFixed(2)}s`;
+}
+
+/** "03.49" style clock (seconds.centiseconds), matching the reference UI. */
+export function formatClock(seconds: number): string {
+ const whole = Math.floor(seconds);
+ const centis = Math.round((seconds - whole) * 100);
+ return `${whole.toString().padStart(2, "0")}.${centis.toString().padStart(2, "0")}`;
+}
+
+/** Compact "15m 59s" style for aggregate durations. */
+export function formatDurationCompact(totalSeconds: number): string {
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = Math.round(totalSeconds % 60);
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
+ return `${seconds}s`;
+}
+
+/** QC score cards render 0..100 with two decimals (screenshot shows 40.53). */
+export function formatQcScore(value: number): string {
+ return value.toFixed(2);
+}
+
+// ── Deterministic mock peaks ────────────────────────────────────────────
+
+function seededRandom(seed: number): () => number {
+ let s = seed % 2147483647;
+ if (s <= 0) s += 2147483646;
+ return () => {
+ s = (s * 16807) % 2147483647;
+ return (s - 1) / 2147483646;
+ };
+}
+
+function hashString(input: string): number {
+ let hash = 0;
+ for (let i = 0; i < input.length; i++) {
+ hash = (hash * 31 + input.charCodeAt(i)) & 0x7fffffff;
+ }
+ return hash || 1;
+}
+
+/** Speech-like envelope: bursts of energy with gaps, deterministic per seed. */
+export function generatePeaks(seed: string, count = 240): number[] {
+ const rand = seededRandom(hashString(seed));
+ const peaks: number[] = [];
+ let envelope = 0;
+ let burstPhase = rand() * Math.PI * 2;
+
+ for (let i = 0; i < count; i++) {
+ burstPhase += 0.18 + rand() * 0.06;
+ // Gate creates silence gaps between "words".
+ const gate = Math.sin(burstPhase) > -0.35 ? 1 : 0.06;
+ const target = gate * (0.25 + rand() * 0.75);
+ envelope = envelope * 0.55 + target * 0.45;
+ peaks.push(Math.min(1, Math.max(0.015, envelope)));
+ }
+ return peaks;
+}
+
+// ── Mock projects + clips ───────────────────────────────────────────────
+
+export const MOCK_PROJECTS: LabProject[] = [
+ { id: "mb-mq3wkz25", name: "mb" },
+ { id: "emma-watson-01", name: "Emma Watson" },
+ { id: "billie-rae", name: "Hey Billie Rae" },
+];
+
+const MOCK_TRANSCRIPTS = [
+ "Isn't this must be like stress relieving and Calming what if I just make people",
+ "thought it was gonna do and wanted it to do and more but um, I wanted this single especially to be kind of like the",
+ "Everything's like a stepping stone in regards to the singles I put out. You know,",
+ "have so many lyrical connections Like they all have there's like words that are the same in a lot of them",
+ "and hearts like hell home with you All the H's and whatever. I was people have said",
+ "I think the biggest thing for me was just learning how to trust the process a little bit more",
+ "we spent probably three weeks just on the drum sounds alone which sounds insane now",
+ "but when you hear it back on a proper system it makes all the difference honestly",
+ "there's a moment right before the second chorus where everything drops out and it's just the vocal",
+ "and I remember the engineer looking at me like are you sure you want to do that",
+ "yeah so that was recorded in a tiny room in the back of my parents house believe it or not",
+ "the reverb you hear is actually the natural room we didn't add anything to it",
+ "I wanted it to feel intimate like you're sitting right there in the room with me",
+ "and honestly some of the takes that made the record were the scratch vocals",
+ "we tried to re-record them cleaner but they just didn't have the same feeling",
+ "there's an imperfection to it that I think people connect with more than a perfect take",
+ "the bridge went through maybe fifteen different versions before we landed on this one",
+ "at one point it was twice as long and had a whole extra section that we cut",
+ "sometimes the best thing you can do is take something away rather than add to it",
+ "I learned that from working with producers who are way more experienced than me",
+];
+
+const SPEAKERS = ["SPEAKER_00", "SPEAKER_01", "SPEAKER_00", "SPEAKER_02"];
+const REASON_POOL = [
+ "transcript_mismatch",
+ "overlap_second_speaker",
+ "near_silence_unusable_clip",
+];
+
+export function createMockClips(count = 42): LabClip[] {
+ const rand = seededRandom(20260712);
+ const clips: LabClip[] = [];
+
+ for (let i = 0; i < count; i++) {
+ const id = `candidate_review_clip_${i.toString().padStart(6, "0")}`;
+ const transcript = MOCK_TRANSCRIPTS[i % MOCK_TRANSCRIPTS.length]!;
+ const duration = Number((3 + rand() * 9).toFixed(2));
+ const qcScore = Number(rand().toFixed(3));
+
+ // Machine bucket derived from score, roughly mirroring backend thresholds.
+ const machineBucket: MachineBucket =
+ qcScore >= 0.72 ? "auto_kept" : qcScore < 0.35 ? "auto_rejected" : "needs_review";
+
+ const hasReason = qcScore < 0.4 && rand() > 0.5;
+ const reasonCodes = hasReason
+ ? [REASON_POOL[Math.floor(rand() * REASON_POOL.length)]!]
+ : [];
+
+ const createdAt = new Date(2026, 5, 7, 5, 30, i).toISOString();
+
+ clips.push({
+ id,
+ order: i + 1,
+ transcript,
+ originalTranscript: transcript,
+ durationSeconds: duration,
+ status: "unresolved",
+ machineBucket,
+ qcScore,
+ transcriptConfidence: Number((25 + rand() * 70).toFixed(2)),
+ speakerPurity: Number((60 + rand() * 39).toFixed(2)),
+ transcriptMatchRaw: Number((25 + rand() * 70).toFixed(2)),
+ speakerCheckRaw: Number((60 + rand() * 39).toFixed(2)),
+ variant: "source",
+ tags: [],
+ reasonCodes,
+ peaks: generatePeaks(id),
+ sampleRateHz: 16000,
+ channels: 2,
+ speaker: SPEAKERS[i % SPEAKERS.length]!,
+ language: "en",
+ sourceRecording: `recording_${(i % 3) + 1}`,
+ originalStartSeconds: Number((i * 6.4).toFixed(2)),
+ originalEndSeconds: Number((i * 6.4 + duration).toFixed(2)),
+ revisions: [
+ {
+ id: `${id}-rev-0`,
+ message: "Dataset candidate clip",
+ status: "unresolved",
+ transcript,
+ createdAt,
+ milestone: false,
+ },
+ ],
+ edits: [],
+ });
+ }
+
+ return clips;
+}
+
+// ── Sorting + filtering ─────────────────────────────────────────────────
+
+export type SortMode = "source" | "qc_desc" | "qc_asc" | "status";
+
+export const SORT_OPTIONS: { value: SortMode; label: string }[] = [
+ { value: "source", label: "Source Order" },
+ { value: "qc_desc", label: "QC Score (high → low)" },
+ { value: "qc_asc", label: "QC Score (low → high)" },
+ { value: "status", label: "Review Status" },
+];
+
+const STATUS_PRIORITY: Record = {
+ unresolved: 0,
+ quarantined: 1,
+ rejected: 2,
+ accepted: 3,
+};
+
+export function sortClips(clips: LabClip[], mode: SortMode): LabClip[] {
+ const copy = [...clips];
+ switch (mode) {
+ case "qc_desc":
+ return copy.sort((a, b) => b.qcScore - a.qcScore);
+ case "qc_asc":
+ return copy.sort((a, b) => a.qcScore - b.qcScore);
+ case "status":
+ return copy.sort(
+ (a, b) =>
+ STATUS_PRIORITY[a.status] - STATUS_PRIORITY[b.status] || a.order - b.order,
+ );
+ default:
+ return copy.sort((a, b) => a.order - b.order);
+ }
+}
+
+export function filterClips(
+ clips: LabClip[],
+ search: string,
+ statuses: ReviewStatus[],
+ tags: string[],
+ buckets: MachineBucket[] = [],
+): LabClip[] {
+ const query = search.trim().toLowerCase();
+ return clips.filter((clip) => {
+ if (query && !clip.transcript.toLowerCase().includes(query)) return false;
+ if (statuses.length > 0 && !statuses.includes(clip.status)) return false;
+ if (tags.length > 0 && !tags.some((tag) => clip.tags.includes(tag))) return false;
+ if (buckets.length > 0 && !buckets.includes(clip.machineBucket)) return false;
+ return true;
+ });
+}
diff --git a/frontend/src/components/lab/lab-workstation.tsx b/frontend/src/components/lab/lab-workstation.tsx
new file mode 100644
index 0000000..84f344f
--- /dev/null
+++ b/frontend/src/components/lab/lab-workstation.tsx
@@ -0,0 +1,716 @@
+"use client";
+
+import { cn } from "@midday/ui/cn";
+import { Button } from "@midday/ui/button";
+import { Icons } from "@midday/ui/icons";
+import { useToast } from "@midday/ui/use-toast";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useHotkeys } from "react-hotkeys-hook";
+import { ClipLabPanel } from "./clip-lab-panel";
+import { ClipQueue } from "./clip-queue";
+import { DatasetHealthPage } from "./dataset-health-page";
+import { DiagnosticsSheet } from "./diagnostics-drawer";
+import { ExportDialog } from "./export-dialog";
+import { InspectorRail } from "./inspector-rail";
+import { KeyboardBar } from "./keyboard-bar";
+import { ProjectPicker } from "./project-picker";
+import { LabRerunDialog } from "./rerun-dialog";
+import { TranscriptPanel } from "./transcript-panel";
+import {
+ type ClipEdit,
+ type LabClip,
+ type MachineBucket,
+ type ReviewStatus,
+ type SortMode,
+ createMockClips,
+ filterClips,
+ sortClips,
+} from "./lab-data";
+import { demoEnabled } from "@/lib/demo";
+import {
+ effectiveQcThresholds,
+ fetchClipLabView,
+ fetchDatasetQc,
+ fetchDatasetRuns,
+ fetchProjects,
+ mapApiClip,
+ pickReviewableRun,
+} from "./speechcraft-api";
+import {
+ type DatasetAudioEditOperation,
+ type DatasetClipLabClipView,
+ SpeechcraftApiError,
+ appendAudioOperation,
+ markReferenceClipCandidate,
+ patchClipLab,
+ redoAudioOperation,
+ undoAudioOperation,
+} from "./speechcraft-write-api";
+
+const PRESET_TAGS = [
+ "mispronunciation",
+ "background noise",
+ "clipping",
+ "overlap",
+ "filler word",
+ "breath",
+];
+
+type Mode = "qc" | "lab";
+
+/** Optimistic-concurrency tokens the backend requires on every clip write. */
+function tokensFor(clip: LabClip) {
+ return {
+ expected_manifest_sha256: clip.manifestSha as string,
+ expected_clip_version: clip.clipVersion as number,
+ };
+}
+
+const isLiveBacked = (clip: LabClip) =>
+ clip.manifestSha != null && clip.clipVersion != null;
+
+/** Fold an authoritative server clip view back into the local working copy. */
+function mergeServerClip(prev: LabClip, s: DatasetClipLabClipView): LabClip {
+ return {
+ ...prev,
+ status: s.review_status,
+ transcript: s.transcript_override ?? s.transcript ?? prev.transcript,
+ originalTranscript: s.original_transcript ?? prev.originalTranscript,
+ tags: s.reviewer_tags ?? prev.tags,
+ durationSeconds: s.current_duration_sec ?? prev.durationSeconds,
+ transcriptConfidence: s.transcript_match ?? prev.transcriptConfidence,
+ speakerPurity: s.speaker_check ?? prev.speakerPurity,
+ reasonCodes: (s.pipeline_findings ?? []).map((f) => f.code),
+ variant: s.effective_audio_kind ?? prev.variant,
+ clipVersion: s.clip_version,
+ effectiveAudioRevisionKey: s.effective_audio_revision_key,
+ renderStatus: s.render_status,
+ canUndoAudio: s.can_undo_audio,
+ canRedoAudio: s.can_redo_audio,
+ audioEditOpCount: s.audio_edit_op_count,
+ };
+}
+
+export function LabWorkstation() {
+ const searchParams = useSearchParams();
+ const { toast, update } = useToast();
+ const queryClient = useQueryClient();
+ const demo = demoEnabled(searchParams);
+
+ // ── Real data chain: projects → dataset runs → clip-lab view ──
+ const { data: projects = [] } = useQuery({
+ queryKey: ["sc-projects"],
+ queryFn: fetchProjects,
+ staleTime: 60_000,
+ enabled: !demo,
+ });
+
+ const paramProjectId = searchParams.get("project");
+ const activeProject =
+ projects.find((p) => p.id === paramProjectId) ?? projects[0] ?? null;
+ const projectId = activeProject?.id ?? null;
+
+ const { data: runs = [], isLoading: runsLoading } = useQuery({
+ queryKey: ["sc-runs", projectId],
+ queryFn: () => fetchDatasetRuns(projectId!),
+ enabled: !demo && !!projectId,
+ staleTime: 60_000,
+ });
+
+ const run = useMemo(() => pickReviewableRun(runs), [runs]);
+ const runId = run?.id ?? null;
+
+ const {
+ data: view,
+ isLoading: viewLoading,
+ error: viewError,
+ } = useQuery({
+ queryKey: ["sc-cliplab", runId],
+ queryFn: () => fetchClipLabView(runId!),
+ enabled: !demo && !!runId,
+ staleTime: Number.POSITIVE_INFINITY,
+ });
+
+ // Committed (or default) QC thresholds — drives machineBucket below so
+ // "auto_kept" in Clip Lab means whatever Dataset Health has committed,
+ // not a hardcoded guess. Refetched whenever Dataset Health commits.
+ const { data: qcPayload } = useQuery({
+ queryKey: ["sc-dataset-qc", runId],
+ queryFn: () => fetchDatasetQc(runId!),
+ enabled: !demo && !!runId,
+ staleTime: 30_000,
+ });
+ const qcThresholds = useMemo(() => effectiveQcThresholds(qcPayload), [qcPayload]);
+
+ // ── Local (optimistic) working copy, seeded per run ──
+ const [mode, setMode] = useState("lab");
+ const [exportOpen, setExportOpen] = useState(false);
+ const [clips, setClips] = useState([]);
+ const [activeClipId, setActiveClipId] = useState(null);
+ const [search, setSearch] = useState("");
+ const [filterStatuses, setFilterStatuses] = useState([]);
+ const [filterTags, setFilterTags] = useState([]);
+ const [sortMode, setSortMode] = useState("source");
+ const [filterBuckets, setFilterBuckets] = useState([]);
+ const seededRunRef = useRef(null);
+
+ useEffect(() => {
+ if (!demo && view && runId && seededRunRef.current !== runId) {
+ const mapped = view.clips.map((clip, index) =>
+ mapApiClip(clip, index, runId, view.candidate_manifest_sha256, qcThresholds),
+ );
+ setClips(mapped);
+ setActiveClipId(mapped[0]?.id ?? null);
+ setSearch("");
+ setFilterStatuses([]);
+ setFilterTags([]);
+ setFilterBuckets([]);
+ seededRunRef.current = runId;
+ }
+ // qcThresholds intentionally excluded: this effect only seeds once per
+ // run. Threshold changes after seeding are handled by the re-derive
+ // effect below so in-progress edits aren't clobbered.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [view, runId, demo]);
+
+ // Committed thresholds can change after clips are already seeded (Dataset
+ // Health commits mid-session). Re-derive machineBucket/qcScore in place —
+ // never touch review status, transcript, or audio edit state.
+ useEffect(() => {
+ if (demo) return;
+ setClips((prev) =>
+ prev.map((clip) => {
+ const scoresKnown = clip.transcriptMatchRaw != null && clip.speakerCheckRaw != null;
+ const hardFailed = clip.reasonCodes.length > 0;
+ const passesGate =
+ scoresKnown &&
+ (clip.transcriptMatchRaw as number) >= qcThresholds.transcriptMatchMin &&
+ (clip.speakerCheckRaw as number) >= qcThresholds.speakerCheckMin;
+ const machineBucket: MachineBucket = !scoresKnown
+ ? "needs_review"
+ : hardFailed || !passesGate
+ ? "auto_rejected"
+ : "auto_kept";
+ const qcScore = scoresKnown
+ ? Math.max(
+ 0,
+ Math.min(
+ 1,
+ Math.min(
+ (clip.transcriptMatchRaw as number) / qcThresholds.transcriptMatchMin,
+ (clip.speakerCheckRaw as number) / qcThresholds.speakerCheckMin,
+ ),
+ ),
+ )
+ : 0;
+ if (clip.machineBucket === machineBucket && clip.qcScore === qcScore) return clip;
+ return { ...clip, machineBucket, qcScore: Number(qcScore.toFixed(3)) };
+ }),
+ );
+ }, [qcThresholds, demo]);
+
+ // Demo / UI-review mode: seed from mock clips, no backend. Writes stay local
+ // (mock clips have no manifestSha/clipVersion, so applyClipWrite is a no-op
+ // beyond the optimistic update).
+ useEffect(() => {
+ if (demo && seededRunRef.current !== "demo") {
+ const mock = createMockClips();
+ setClips(mock);
+ setActiveClipId(mock[0]?.id ?? null);
+ seededRunRef.current = "demo";
+ }
+ }, [demo]);
+
+ const visibleClips = useMemo(
+ () => sortClips(filterClips(clips, search, filterStatuses, filterTags, filterBuckets), sortMode),
+ [clips, search, filterStatuses, filterTags, filterBuckets, sortMode],
+ );
+
+ const activeClip = useMemo(
+ () => clips.find((c) => c.id === activeClipId) ?? null,
+ [clips, activeClipId],
+ );
+
+ const availableTags = useMemo(() => {
+ const set = new Set();
+ for (const clip of clips) for (const tag of clip.tags) set.add(tag);
+ return Array.from(set).sort();
+ }, [clips]);
+
+ const allTags = useMemo(
+ () => Array.from(new Set([...PRESET_TAGS, ...availableTags])),
+ [availableTags],
+ );
+
+ const stats = useMemo(() => {
+ const counts: Record = {
+ unresolved: 0,
+ accepted: 0,
+ rejected: 0,
+ quarantined: 0,
+ };
+ for (const clip of clips) counts[clip.status] += 1;
+ return { total: clips.length, reviewed: clips.length - counts.unresolved, counts };
+ }, [clips]);
+
+ const updateClip = useCallback((id: string, fn: (clip: LabClip) => LabClip) => {
+ setClips((prev) => prev.map((clip) => (clip.id === id ? fn(clip) : clip)));
+ }, []);
+
+ // Refetch the authoritative clip-lab view and reseed (used after a 409 stale).
+ const reseedFromServer = useCallback(async () => {
+ if (!runId) return;
+ const fresh = await fetchClipLabView(runId);
+ const mapped = fresh.clips.map((clip, index) =>
+ mapApiClip(clip, index, runId, fresh.candidate_manifest_sha256),
+ );
+ queryClient.setQueryData(["sc-cliplab", runId], fresh);
+ setClips(mapped);
+ }, [runId, queryClient]);
+
+ // Optimistic write: apply locally, call backend, reconcile with the returned
+ // authoritative clip (crucially, its new clip_version). On a stale 409,
+ // reload the view; on any other failure, revert and surface the error.
+ const applyClipWrite = useCallback(
+ async (
+ clipId: string,
+ optimistic: (clip: LabClip) => LabClip,
+ call: (clip: LabClip) => Promise,
+ label: string,
+ ) => {
+ const snapshot = clips.find((c) => c.id === clipId);
+ if (!snapshot) return;
+ updateClip(clipId, optimistic);
+ if (!isLiveBacked(snapshot)) return; // mock / not-yet-live: local only
+ try {
+ const server = await call(snapshot);
+ updateClip(clipId, (c) => mergeServerClip(c, server));
+ } catch (err) {
+ if (err instanceof SpeechcraftApiError && err.isStale) {
+ toast({
+ title: "Clip changed elsewhere",
+ description: "Reloaded the latest version — reapply your change.",
+ variant: "error",
+ duration: 3500,
+ });
+ await reseedFromServer();
+ return;
+ }
+ updateClip(clipId, () => snapshot); // revert
+ toast({
+ title: `${label} failed`,
+ description:
+ err instanceof SpeechcraftApiError ? err.detail : String(err),
+ variant: "error",
+ duration: 4000,
+ });
+ }
+ },
+ [clips, updateClip, toast, reseedFromServer],
+ );
+
+ const nextClipIdRef = useRef(null);
+ nextClipIdRef.current = (() => {
+ if (!activeClipId) return null;
+ const idx = visibleClips.findIndex((c) => c.id === activeClipId);
+ return idx >= 0 ? (visibleClips[idx + 1]?.id ?? null) : null;
+ })();
+
+ const decide = useCallback(
+ (status: ReviewStatus) => {
+ if (!activeClipId) return;
+ const nextId = nextClipIdRef.current;
+ void applyClipWrite(
+ activeClipId,
+ (clip) => ({
+ ...clip,
+ status,
+ revisions: [
+ ...clip.revisions,
+ {
+ id: `${clip.id}-rev-${clip.revisions.length}`,
+ message:
+ status === "accepted"
+ ? "Accepted milestone"
+ : status === "rejected"
+ ? "Rejected milestone"
+ : `${status} milestone`,
+ status,
+ transcript: clip.transcript,
+ createdAt: new Date().toISOString(),
+ milestone: true,
+ },
+ ],
+ }),
+ (clip) =>
+ patchClipLab(runId!, clip.id, {
+ ...tokensFor(clip),
+ review_status: status,
+ }),
+ "Status update",
+ );
+ if (nextId) setActiveClipId(nextId);
+ },
+ [activeClipId, applyClipWrite, runId],
+ );
+
+ const commitStatus = useCallback(
+ (status: ReviewStatus) => {
+ if (!activeClipId) return;
+ void applyClipWrite(
+ activeClipId,
+ (clip) => ({ ...clip, status }),
+ (clip) =>
+ patchClipLab(runId!, clip.id, { ...tokensFor(clip), review_status: status }),
+ "Status update",
+ );
+ },
+ [activeClipId, applyClipWrite, runId],
+ );
+
+ const commitTranscript = useCallback(
+ (text: string) => {
+ if (!activeClipId) return;
+ void applyClipWrite(
+ activeClipId,
+ (clip) => ({ ...clip, transcript: text }),
+ (clip) =>
+ patchClipLab(runId!, clip.id, {
+ ...tokensFor(clip),
+ transcript_override: text,
+ }),
+ "Transcript save",
+ );
+ },
+ [activeClipId, applyClipWrite, runId],
+ );
+
+ const commitTags = useCallback(
+ (tags: string[]) => {
+ if (!activeClipId) return;
+ void applyClipWrite(
+ activeClipId,
+ (clip) => ({ ...clip, tags }),
+ (clip) =>
+ patchClipLab(runId!, clip.id, { ...tokensFor(clip), reviewer_tags: tags }),
+ "Tag update",
+ );
+ },
+ [activeClipId, applyClipWrite, runId],
+ );
+
+ const navigate = useCallback(
+ (direction: "prev" | "next") => {
+ if (!activeClipId) return;
+ const idx = visibleClips.findIndex((c) => c.id === activeClipId);
+ if (idx === -1) return;
+ const target = visibleClips[idx + (direction === "next" ? 1 : -1)];
+ if (target) setActiveClipId(target.id);
+ },
+ [activeClipId, visibleClips],
+ );
+
+ const appendEdit = useCallback(
+ (edit: ClipEdit) => {
+ if (!activeClipId) return;
+ const clip = clips.find((c) => c.id === activeClipId);
+ if (!clip) return;
+ const sr = clip.sampleRateHz || 16000;
+ let operation: DatasetAudioEditOperation | null = null;
+ if (edit.op === "delete_range" && edit.startSeconds != null && edit.endSeconds != null) {
+ operation = {
+ kind: "delete_range",
+ start_sample: Math.round(edit.startSeconds * sr),
+ end_sample: Math.round(edit.endSeconds * sr),
+ };
+ } else if (
+ edit.op === "insert_silence" &&
+ edit.startSeconds != null &&
+ edit.durationSeconds != null
+ ) {
+ operation = {
+ kind: "insert_silence",
+ at_sample: Math.round(edit.startSeconds * sr),
+ duration_samples: Math.round(edit.durationSeconds * sr),
+ };
+ }
+ if (!operation) {
+ // split / merge_next are not backend audio operations.
+ toast({
+ title: "Not supported yet",
+ description: `"${edit.op}" isn't a backend audio operation.`,
+ variant: "error",
+ duration: 3000,
+ });
+ return;
+ }
+ const backendOp = operation;
+ void applyClipWrite(
+ activeClipId,
+ (c) => ({ ...c, edits: [...c.edits, edit] }),
+ (c) => appendAudioOperation(runId!, c.id, { ...tokensFor(c), operation: backendOp }),
+ "Audio edit",
+ );
+ },
+ [activeClipId, clips, applyClipWrite, runId, toast],
+ );
+
+ const undoEdit = useCallback(() => {
+ if (!activeClipId) return;
+ void applyClipWrite(
+ activeClipId,
+ (c) => c,
+ (c) => undoAudioOperation(runId!, c.id, tokensFor(c)),
+ "Undo",
+ );
+ }, [activeClipId, applyClipWrite, runId]);
+
+ const redoEdit = useCallback(() => {
+ if (!activeClipId) return;
+ void applyClipWrite(
+ activeClipId,
+ (c) => c,
+ (c) => redoAudioOperation(runId!, c.id, tokensFor(c)),
+ "Redo",
+ );
+ }, [activeClipId, applyClipWrite, runId]);
+
+ const markReference = useCallback(() => {
+ const clip = clips.find((c) => c.id === activeClipId);
+ if (!clip || !projectId || !runId) return;
+ void (async () => {
+ try {
+ await markReferenceClipCandidate(projectId, runId, {
+ clip_id: clip.id,
+ transcript_text: clip.transcript,
+ });
+ toast({ title: "Marked as reference candidate", variant: "success", duration: 2000 });
+ } catch (err) {
+ toast({
+ title: "Mark reference failed",
+ description: err instanceof SpeechcraftApiError ? err.detail : String(err),
+ variant: "error",
+ duration: 4000,
+ });
+ }
+ })();
+ }, [clips, activeClipId, projectId, runId, toast]);
+
+ const runModel = useCallback(() => {
+ if (!activeClip) return;
+ const clipId = activeClip.id;
+ const { id } = toast({
+ title: "Running DeepFilterNet",
+ description: "Enhancing slice audio…",
+ variant: "progress",
+ progress: 0,
+ duration: Number.POSITIVE_INFINITY,
+ });
+ const started = Date.now();
+ const interval = setInterval(() => {
+ const pct = Math.min(100, Math.round(((Date.now() - started) / 3500) * 100));
+ update(id, { id, progress: pct });
+ if (pct >= 100) {
+ clearInterval(interval);
+ updateClip(clipId, (clip) => ({ ...clip, variant: "deepfilternet" }));
+ update(id, {
+ id,
+ title: "Enhancement complete",
+ description: "Activated the DeepFilterNet variant.",
+ variant: "success",
+ duration: 2500,
+ });
+ }
+ }, 120);
+ }, [activeClip, toast, update, updateClip]);
+
+ const hotkeyEnabled = mode === "lab" && !!activeClipId;
+ useHotkeys("enter", (e) => { e.preventDefault(); decide("accepted"); }, { enabled: hotkeyEnabled }, [decide]);
+ useHotkeys("shift+enter", (e) => { e.preventDefault(); decide("rejected"); }, { enabled: hotkeyEnabled }, [decide]);
+ useHotkeys("up", (e) => { e.preventDefault(); navigate("prev"); }, { enabled: hotkeyEnabled }, [navigate]);
+ useHotkeys("down", (e) => { e.preventDefault(); navigate("next"); }, { enabled: hotkeyEnabled }, [navigate]);
+ useHotkeys("meta+z", (e) => { e.preventDefault(); undoEdit(); }, { enabled: hotkeyEnabled }, [undoEdit]);
+ useHotkeys("meta+shift+z", (e) => { e.preventDefault(); redoEdit(); }, { enabled: hotkeyEnabled }, [redoEdit]);
+
+ const isLoading =
+ !demo &&
+ ((!projectId && projects.length === 0) || runsLoading || (viewLoading && clips.length === 0));
+
+ const undoAvailable = activeClip
+ ? isLiveBacked(activeClip)
+ ? !!activeClip.canUndoAudio
+ : activeClip.edits.length > 0
+ : false;
+ const redoAvailable = activeClip
+ ? isLiveBacked(activeClip)
+ ? !!activeClip.canRedoAudio
+ : false
+ : false;
+
+ return (
+
+
+
+
+
+ {mode === "qc" ? (
+
+ ) : viewError ? (
+
+
+
Backend unavailable
+
+ Couldn't reach the speechcraft backend. Make sure it's running on
+ :8010 (make dev-backend), then reload.
+
+
+
+ ) : isLoading ? (
+
+ Loading clips…
+
+ ) : clips.length === 0 ? (
+
+ No candidate clips in this project's dataset run yet.
+
+ ) : (
+
+
+ setFilterStatuses((cur) =>
+ cur.includes(status) ? cur.filter((s) => s !== status) : [...cur, status],
+ )
+ }
+ onToggleTag={(tag) =>
+ setFilterTags((cur) =>
+ cur.includes(tag) ? cur.filter((t) => t !== tag) : [...cur, tag],
+ )
+ }
+ onToggleBucket={(bucket) =>
+ setFilterBuckets((cur) =>
+ cur.includes(bucket) ? cur.filter((b) => b !== bucket) : [...cur, bucket],
+ )
+ }
+ onClearFilters={() => {
+ setFilterStatuses([]);
+ setFilterTags([]);
+ setFilterBuckets([]);
+ }}
+ sortMode={sortMode}
+ onSortModeChange={setSortMode}
+ onSelect={setActiveClipId}
+ />
+
+
+
+ {activeClip ? (
+ <>
+
decide("accepted")}
+ onReject={() => decide("rejected")}
+ onAppendEdit={appendEdit}
+ onUndo={undoEdit}
+ onRedo={redoEdit}
+ onMarkReference={markReference}
+ onRunModel={runModel}
+ />
+
+ >
+ ) : (
+
+ Select a clip to begin review.
+
+ )}
+
+
+
+
+ {activeClip ? (
+
+ ) : null}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/lab/project-picker.tsx b/frontend/src/components/lab/project-picker.tsx
new file mode 100644
index 0000000..11c1a5d
--- /dev/null
+++ b/frontend/src/components/lab/project-picker.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { Avatar, AvatarFallback } from "@midday/ui/avatar";
+import { cn } from "@midday/ui/cn";
+import { Icons } from "@midday/ui/icons";
+import { Popover, PopoverContent, PopoverTrigger } from "@midday/ui/popover";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@midday/ui/tooltip";
+import { useQuery } from "@tanstack/react-query";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { fetchProjects } from "./speechcraft-api";
+
+export function ProjectPicker() {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const { data: projects = [] } = useQuery({
+ queryKey: ["sc-projects"],
+ queryFn: fetchProjects,
+ staleTime: 60_000,
+ });
+
+ const paramId = searchParams.get("project");
+ const activeProject =
+ projects.find((p) => p.id === paramId) ?? projects[0] ?? null;
+
+ const selectProject = (id: string) => {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set("project", id);
+ router.replace(`${pathname}?${params.toString()}`);
+ };
+
+ const initials = (activeProject?.name ?? "··").slice(0, 2).toUpperCase();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {activeProject?.name ?? "No project"}
+
+
+
+
+
+ Project
+
+ {projects.map((project) => {
+ const isActive = project.id === activeProject?.id;
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/frontend/src/components/lab/qc-logic.test.ts b/frontend/src/components/lab/qc-logic.test.ts
new file mode 100644
index 0000000..9dc5274
--- /dev/null
+++ b/frontend/src/components/lab/qc-logic.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, test } from "bun:test";
+import {
+ bestRejected,
+ combinedSummary,
+ histogram,
+ riskiestKept,
+ thresholdImpactCurve,
+ thresholdStatus,
+ unscoredCount,
+ type QcClip,
+} from "./qc-logic";
+
+function clip(
+ clipId: string,
+ transcriptMatch: number | null,
+ speakerCheck: number | null,
+ durationSec = 2,
+): QcClip {
+ return { clipId, transcriptMatch, speakerCheck, durationSec, reasonCodes: [], trainingText: clipId };
+}
+
+// transcript threshold 85, speaker threshold 70 (defaults)
+const T = 85;
+const S = 70;
+
+const clips: QcClip[] = [
+ clip("a", 95, 90), // clear accept
+ clip("b", 86, 71), // barely accepted (riskiest kept)
+ clip("c", 84, 95), // rejected on transcript only (best rejected, transcript_only)
+ clip("d", 90, 68), // rejected on speaker only
+ clip("e", 40, 30), // clear reject
+ clip("f", null, 80), // unscored transcript => rejected
+];
+
+describe("thresholdStatus (AND-gate, null = rejected)", () => {
+ test("both gates required", () => {
+ expect(thresholdStatus(clip("x", 95, 90), T, S)).toBe("accepted");
+ expect(thresholdStatus(clip("x", 84, 90), T, S)).toBe("rejected");
+ expect(thresholdStatus(clip("x", 95, 69), T, S)).toBe("rejected");
+ });
+ test("null score is rejected", () => {
+ expect(thresholdStatus(clip("x", null, 90), T, S)).toBe("rejected");
+ expect(thresholdStatus(clip("x", 95, null), T, S)).toBe("rejected");
+ });
+});
+
+describe("combinedSummary", () => {
+ test("counts accepted vs rejected", () => {
+ const s = combinedSummary(clips, T, S);
+ expect(s.acceptedCount).toBe(2); // a, b
+ expect(s.rejectedCount).toBe(4); // c, d, e, f
+ expect(s.acceptedDurationSec).toBe(4);
+ });
+});
+
+describe("riskiestKept", () => {
+ test("accepted clips ordered by smallest risk margin first", () => {
+ const kept = riskiestKept(clips, T, S, "risk");
+ expect(kept.map((k) => k.clip.clipId)).toEqual(["b", "a"]); // b is closest to failing
+ });
+});
+
+describe("bestRejected", () => {
+ test("closest to passing first (smallest recovery gap)", () => {
+ const rejected = bestRejected(clips, T, S, "closest");
+ // c (transcript 84, gap 1) and d (speaker 68, gap 2) should lead
+ expect(rejected[0].clip.clipId).toBe("c");
+ });
+ test("transcript_only isolates transcript-only failures", () => {
+ const rejected = bestRejected(clips, T, S, "transcript_only");
+ // c (84) fails transcript with passing speaker; f (null transcript, speaker 80)
+ // is also transcript-only. Ordered by descending transcript score, so c then f.
+ expect(rejected.map((r) => r.clip.clipId)).toEqual(["c", "f"]);
+ });
+ test("speaker_only isolates speaker-only failures", () => {
+ const rejected = bestRejected(clips, T, S, "speaker_only");
+ expect(rejected.map((r) => r.clip.clipId)).toEqual(["d"]);
+ });
+});
+
+describe("thresholdImpactCurve", () => {
+ test("accepted count is monotonic non-increasing as threshold rises", () => {
+ const curve = thresholdImpactCurve(clips, (c) => c.transcriptMatch);
+ expect(curve[0].acceptedClipCount).toBeGreaterThanOrEqual(curve[100].acceptedClipCount);
+ // at threshold 0, every scored clip counts (5 of 6; f is null)
+ expect(curve[0].acceptedClipCount).toBe(5);
+ });
+});
+
+describe("histogram", () => {
+ test("bins scored clips, drops nulls", () => {
+ const bins = histogram(clips, (c) => c.transcriptMatch, 10);
+ const total = bins.reduce((n, b) => n + b.count, 0);
+ expect(total).toBe(5); // f (null) excluded
+ expect(unscoredCount(clips, (c) => c.transcriptMatch)).toBe(1);
+ });
+});
diff --git a/frontend/src/components/lab/qc-logic.ts b/frontend/src/components/lab/qc-logic.ts
new file mode 100644
index 0000000..bf4d751
--- /dev/null
+++ b/frontend/src/components/lab/qc-logic.ts
@@ -0,0 +1,283 @@
+// Pure QC / Dataset-Health logic. Ported from the legacy frontend's
+// src/qc/qcLogic.ts and adapted to the live GET /api/dataset-runs/{id}/qc
+// clip shape. The two scores (transcript_match, speaker_check) are INDEPENDENT
+// necessary gates (AND) — never blended. A null score means "unscored" and is
+// treated as rejected, matching the backend (_threshold_status in dataset_qc.py).
+
+export type QcClip = {
+ clipId: string;
+ durationSec: number;
+ transcriptMatch: number | null;
+ speakerCheck: number | null;
+ reasonCodes: string[];
+ trainingText: string;
+};
+
+export type ManualOverride = "force_keep" | "force_reject";
+export type ThresholdStatus = "accepted" | "rejected";
+
+export type ClipWithMargins = {
+ clip: QcClip;
+ transcriptMargin: number;
+ speakerMargin: number;
+ riskMargin: number;
+ transcriptGap: number;
+ speakerGap: number;
+ recoveryGap: number;
+};
+
+export type CurvePoint = {
+ threshold: number;
+ acceptedDurationSec: number;
+ acceptedClipCount: number;
+};
+
+export type CombinedSummary = {
+ acceptedCount: number;
+ rejectedCount: number;
+ acceptedDurationSec: number;
+ rejectedDurationSec: number;
+};
+
+export type KeptSort = "risk" | "transcript" | "speaker";
+export type RejectedSort = "closest" | "transcript_only" | "speaker_only";
+
+const NEG_INF = Number.NEGATIVE_INFINITY;
+
+function scoreOr(score: number | null | undefined): number {
+ return typeof score === "number" && Number.isFinite(score) ? score : NEG_INF;
+}
+
+function effectiveOverride(
+ clip: QcClip,
+ overrides: Record,
+): ManualOverride | null {
+ return overrides[clip.clipId] ?? null;
+}
+
+export function thresholdStatus(
+ clip: QcClip,
+ transcriptThreshold: number,
+ speakerThreshold: number,
+): ThresholdStatus {
+ if (scoreOr(clip.transcriptMatch) < transcriptThreshold) return "rejected";
+ if (scoreOr(clip.speakerCheck) < speakerThreshold) return "rejected";
+ return "accepted";
+}
+
+export function finalStatus(
+ clip: QcClip,
+ transcriptThreshold: number,
+ speakerThreshold: number,
+ overrides: Record = {},
+): ThresholdStatus {
+ const override = effectiveOverride(clip, overrides);
+ if (override === "force_keep") return "accepted";
+ if (override === "force_reject") return "rejected";
+ return thresholdStatus(clip, transcriptThreshold, speakerThreshold);
+}
+
+export function margins(
+ clip: QcClip,
+ transcriptThreshold: number,
+ speakerThreshold: number,
+): ClipWithMargins {
+ const t = scoreOr(clip.transcriptMatch);
+ const s = scoreOr(clip.speakerCheck);
+ const transcriptMargin = t - transcriptThreshold;
+ const speakerMargin = s - speakerThreshold;
+ const transcriptGap = Math.max(0, transcriptThreshold - t);
+ const speakerGap = Math.max(0, speakerThreshold - s);
+ return {
+ clip,
+ transcriptMargin,
+ speakerMargin,
+ riskMargin: Math.min(transcriptMargin, speakerMargin),
+ transcriptGap,
+ speakerGap,
+ recoveryGap: Math.max(transcriptGap, speakerGap),
+ };
+}
+
+function byClipId(a: QcClip, b: QcClip): number {
+ return a.clipId.localeCompare(b.clipId);
+}
+
+function withMarginsByStatus(
+ clips: QcClip[],
+ transcriptThreshold: number,
+ speakerThreshold: number,
+ status: ThresholdStatus,
+ overrides: Record,
+): ClipWithMargins[] {
+ return clips
+ .filter((c) => finalStatus(c, transcriptThreshold, speakerThreshold, overrides) === status)
+ .map((c) => margins(c, transcriptThreshold, speakerThreshold));
+}
+
+/** Accepted clips ordered so the riskiest kept (closest to failing) come first. */
+export function riskiestKept(
+ clips: QcClip[],
+ transcriptThreshold: number,
+ speakerThreshold: number,
+ sort: KeptSort = "risk",
+ overrides: Record = {},
+): ClipWithMargins[] {
+ const kept = withMarginsByStatus(clips, transcriptThreshold, speakerThreshold, "accepted", overrides);
+ kept.sort((l, r) => {
+ if (sort === "transcript") return l.transcriptMargin - r.transcriptMargin || byClipId(l.clip, r.clip);
+ if (sort === "speaker") return l.speakerMargin - r.speakerMargin || byClipId(l.clip, r.clip);
+ return l.riskMargin - r.riskMargin || byClipId(l.clip, r.clip);
+ });
+ return kept;
+}
+
+/** Rejected clips ordered so the best rejected (closest to passing) come first. */
+export function bestRejected(
+ clips: QcClip[],
+ transcriptThreshold: number,
+ speakerThreshold: number,
+ sort: RejectedSort = "closest",
+ overrides: Record = {},
+): ClipWithMargins[] {
+ const rejected = withMarginsByStatus(clips, transcriptThreshold, speakerThreshold, "rejected", overrides).filter(
+ (entry) => {
+ if (sort === "transcript_only") {
+ return (
+ scoreOr(entry.clip.transcriptMatch) < transcriptThreshold &&
+ scoreOr(entry.clip.speakerCheck) >= speakerThreshold
+ );
+ }
+ if (sort === "speaker_only") {
+ return (
+ scoreOr(entry.clip.speakerCheck) < speakerThreshold &&
+ scoreOr(entry.clip.transcriptMatch) >= transcriptThreshold
+ );
+ }
+ return true;
+ },
+ );
+ rejected.sort((l, r) => {
+ if (sort === "transcript_only") {
+ return scoreOr(r.clip.transcriptMatch) - scoreOr(l.clip.transcriptMatch) || byClipId(l.clip, r.clip);
+ }
+ if (sort === "speaker_only") {
+ return scoreOr(r.clip.speakerCheck) - scoreOr(l.clip.speakerCheck) || byClipId(l.clip, r.clip);
+ }
+ return l.recoveryGap - r.recoveryGap || byClipId(l.clip, r.clip);
+ });
+ return rejected;
+}
+
+export function clampScoreToBucket(score: number | null | undefined): number | null {
+ if (typeof score !== "number" || !Number.isFinite(score)) return null;
+ const bucket = Math.floor(score);
+ if (bucket < 0) return 0;
+ if (bucket > 100) return 100;
+ return bucket;
+}
+
+/**
+ * Yield curve: for every integer threshold 0..100, the accepted duration and
+ * clip count if that single gate were applied alone. This is the "area under
+ * the graph" the threshold handle sweeps.
+ */
+export function thresholdImpactCurve(
+ clips: QcClip[],
+ getScore: (clip: QcClip) => number | null | undefined,
+): CurvePoint[] {
+ const durationAt = new Float64Array(101);
+ const countAt = new Uint32Array(101);
+ for (const clip of clips) {
+ const bucket = clampScoreToBucket(getScore(clip));
+ if (bucket === null) continue;
+ durationAt[bucket] += clip.durationSec;
+ countAt[bucket] += 1;
+ }
+ const curve: CurvePoint[] = new Array(101);
+ let acceptedDurationSec = 0;
+ let acceptedClipCount = 0;
+ for (let threshold = 100; threshold >= 0; threshold -= 1) {
+ acceptedDurationSec += durationAt[threshold];
+ acceptedClipCount += countAt[threshold];
+ curve[threshold] = {
+ threshold,
+ acceptedDurationSec: Number(acceptedDurationSec.toFixed(6)),
+ acceptedClipCount,
+ };
+ }
+ return curve;
+}
+
+export type HistogramBin = {
+ /** Inclusive lower edge of the bin, 0..100. */
+ start: number;
+ /** Exclusive upper edge (except the final bin, which is inclusive of 100). */
+ end: number;
+ /** Bin center, used as the chart X value. */
+ center: number;
+ count: number;
+};
+
+/** Bin scores into `binCount` equal-width buckets across 0..100. Nulls dropped. */
+export function histogram(
+ clips: QcClip[],
+ getScore: (clip: QcClip) => number | null | undefined,
+ binCount = 20,
+): HistogramBin[] {
+ const width = 100 / binCount;
+ const bins: HistogramBin[] = Array.from({ length: binCount }, (_, i) => ({
+ start: i * width,
+ end: (i + 1) * width,
+ center: i * width + width / 2,
+ count: 0,
+ }));
+ for (const clip of clips) {
+ const score = getScore(clip);
+ if (typeof score !== "number" || !Number.isFinite(score)) continue;
+ const clamped = Math.max(0, Math.min(100, score));
+ let index = Math.floor(clamped / width);
+ if (index >= binCount) index = binCount - 1;
+ bins[index].count += 1;
+ }
+ return bins;
+}
+
+/** Count of clips whose score is null/unscored for a given field. */
+export function unscoredCount(
+ clips: QcClip[],
+ getScore: (clip: QcClip) => number | null | undefined,
+): number {
+ let n = 0;
+ for (const clip of clips) {
+ const score = getScore(clip);
+ if (typeof score !== "number" || !Number.isFinite(score)) n += 1;
+ }
+ return n;
+}
+
+export function combinedSummary(
+ clips: QcClip[],
+ transcriptThreshold: number,
+ speakerThreshold: number,
+ overrides: Record = {},
+): CombinedSummary {
+ const summary: CombinedSummary = {
+ acceptedCount: 0,
+ rejectedCount: 0,
+ acceptedDurationSec: 0,
+ rejectedDurationSec: 0,
+ };
+ for (const clip of clips) {
+ if (finalStatus(clip, transcriptThreshold, speakerThreshold, overrides) === "accepted") {
+ summary.acceptedCount += 1;
+ summary.acceptedDurationSec += clip.durationSec;
+ } else {
+ summary.rejectedCount += 1;
+ summary.rejectedDurationSec += clip.durationSec;
+ }
+ }
+ summary.acceptedDurationSec = Number(summary.acceptedDurationSec.toFixed(6));
+ summary.rejectedDurationSec = Number(summary.rejectedDurationSec.toFixed(6));
+ return summary;
+}
diff --git a/frontend/src/components/lab/rerun-dialog.tsx b/frontend/src/components/lab/rerun-dialog.tsx
new file mode 100644
index 0000000..40f212d
--- /dev/null
+++ b/frontend/src/components/lab/rerun-dialog.tsx
@@ -0,0 +1,228 @@
+"use client";
+
+import { Button } from "@midday/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@midday/ui/dialog";
+import { Input } from "@midday/ui/input";
+import { Label } from "@midday/ui/label";
+import { Separator } from "@midday/ui/separator";
+import { Textarea } from "@midday/ui/textarea";
+import { useToast } from "@midday/ui/use-toast";
+import { useQueryClient } from "@tanstack/react-query";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { SpeechcraftApiError } from "./speechcraft-write-api";
+import {
+ createDatasetRun,
+ fetchRun,
+ resolveWhisperModel,
+ slicerRerun,
+} from "@/components/wizard/wizard-api";
+
+// The two downstream re-run boundaries from the design:
+// - cheap re-slice (adjust clip geometry) via slicer-rerun on the SAME run
+// - expensive re-run with externally-known overrides (initial_prompt /
+// text normalization) as a NEW run cloned from the current one.
+// These live here in the loop — never as wizard knobs — because the insight
+// to change them only arises after seeing results.
+
+const SLICER_FIELDS: { key: string; label: string; unit: string; step: number }[] = [
+ { key: "candidate_target_clip_sec", label: "Target clip", unit: "sec", step: 0.5 },
+ { key: "candidate_min_clip_sec", label: "Minimum clip", unit: "sec", step: 0.5 },
+ { key: "candidate_max_clip_sec", label: "Maximum clip", unit: "sec", step: 0.5 },
+ { key: "cutpoint_min_gap_ms", label: "Smallest cuttable gap", unit: "ms", step: 5 },
+ { key: "cutpoint_left_word_edge_guard_ms", label: "Left word-edge guard", unit: "ms", step: 5 },
+ { key: "cutpoint_right_word_edge_guard_ms", label: "Right word-edge guard", unit: "ms", step: 5 },
+];
+
+const SLICER_DEFAULTS: Record = {
+ candidate_target_clip_sec: 8,
+ candidate_min_clip_sec: 3,
+ candidate_max_clip_sec: 15,
+ cutpoint_min_gap_ms: 80,
+ cutpoint_left_word_edge_guard_ms: 30,
+ cutpoint_right_word_edge_guard_ms: 30,
+};
+
+const NORMALIZATION = ["strict", "loose", "spoken_form"] as const;
+
+export function LabRerunDialog({
+ projectId,
+ runId,
+}: {
+ projectId: string | null;
+ runId: string | null;
+}) {
+ const { toast } = useToast();
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const [open, setOpen] = useState(false);
+ const [slicer, setSlicer] = useState>(SLICER_DEFAULTS);
+ const [initialPrompt, setInitialPrompt] = useState("");
+ const [textNorm, setTextNorm] = useState<(typeof NORMALIZATION)[number]>("loose");
+ const [busy, setBusy] = useState(null);
+
+ const reslice = async () => {
+ if (!runId) return;
+ setBusy("reslice");
+ try {
+ await slicerRerun(runId, slicer);
+ toast({
+ title: "Re-slicing started",
+ description: "New candidate clips are being generated — reload when it completes.",
+ variant: "success",
+ duration: 3500,
+ });
+ queryClient.invalidateQueries({ queryKey: ["sc-cliplab", runId] });
+ setOpen(false);
+ } catch (err) {
+ toast({
+ title: "Re-slice failed",
+ description: err instanceof SpeechcraftApiError ? err.detail : String(err),
+ variant: "error",
+ duration: 4000,
+ });
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const rerunAsr = async () => {
+ if (!projectId || !runId) return;
+ setBusy("rerun");
+ try {
+ const current = await fetchRun(runId);
+ const sourceIds = (current.input_summary.source_recording_ids as string[]) ?? [];
+ const singleSpeaker = Boolean(current.input_summary.single_speaker);
+ const resolved = await resolveWhisperModel();
+ if (resolved.model === null) throw new SpeechcraftApiError(0, resolved.error);
+ const fresh = await createDatasetRun(projectId, {
+ source_recording_ids: sourceIds,
+ whisper_model_size: resolved.model,
+ single_speaker: singleSpeaker,
+ config: {
+ initial_prompt: initialPrompt.trim() || undefined,
+ text_normalization_strategy: textNorm,
+ },
+ });
+ toast({ title: "New run created", description: "Re-running with your overrides.", variant: "success", duration: 2500 });
+ setOpen(false);
+ router.push(
+ `/login4?project=${encodeURIComponent(projectId)}&run=${encodeURIComponent(fresh.id)}`,
+ );
+ } catch (err) {
+ toast({
+ title: "Re-run failed",
+ description: err instanceof SpeechcraftApiError ? err.detail : String(err),
+ variant: "error",
+ duration: 4000,
+ });
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/lab/speechcraft-api.ts b/frontend/src/components/lab/speechcraft-api.ts
new file mode 100644
index 0000000..db916cb
--- /dev/null
+++ b/frontend/src/components/lab/speechcraft-api.ts
@@ -0,0 +1,289 @@
+// Client for speechcraft's live FastAPI backend, reached same-origin through
+// the /sc-api Next rewrite (see next.config.ts) to avoid CORS and stream
+// audio range requests. Read-only for now: projects, dataset runs, and the
+// clip-lab view. Writes (status/transcript/edits) still run on local
+// optimistic state — wiring those mutations is the next step.
+
+import type { LabClip, MachineBucket, ReviewStatus } from "./lab-data";
+
+const BASE = process.env.NEXT_PUBLIC_SPEECHCRAFT_API_URL ?? "/sc-api";
+
+// Default QC gate thresholds — mirror backend dataset_qc.py
+// (DEFAULT_TRANSCRIPT_THRESHOLD / DEFAULT_SPEAKER_THRESHOLD). transcript_match
+// and speaker_check are two INDEPENDENT necessary gates (AND); they are never
+// blended into one score. These are only the fallback: once Dataset Health
+// commits thresholds (POST /qc/finalize), mapApiClip's machineBucket should
+// use the committed finalized_thresholds instead — see effectiveQcThresholds.
+export const QC_TRANSCRIPT_THRESHOLD = 85;
+export const QC_SPEAKER_THRESHOLD = 70;
+
+export type QcThresholds = {
+ transcriptMatchMin: number;
+ speakerCheckMin: number;
+};
+
+export const DEFAULT_QC_THRESHOLDS: QcThresholds = {
+ transcriptMatchMin: QC_TRANSCRIPT_THRESHOLD,
+ speakerCheckMin: QC_SPEAKER_THRESHOLD,
+};
+
+/** Committed thresholds win; otherwise fall back to the backend defaults. */
+export function effectiveQcThresholds(payload: DatasetQcPayload | undefined): QcThresholds {
+ if (payload?.finalized_thresholds) {
+ return {
+ transcriptMatchMin: payload.finalized_thresholds.transcript_match_min,
+ speakerCheckMin: payload.finalized_thresholds.speaker_check_min,
+ };
+ }
+ if (payload?.defaults) {
+ return {
+ transcriptMatchMin: payload.defaults.transcript_match_threshold,
+ speakerCheckMin: payload.defaults.speaker_check_threshold,
+ };
+ }
+ return DEFAULT_QC_THRESHOLDS;
+}
+
+export type SpeechcraftProject = {
+ id: string;
+ name: string;
+};
+
+export type SpeechcraftDatasetRun = {
+ id: string;
+ project_id: string;
+ stage: string;
+ status: string;
+ output_summary?: { clip_count?: number } | null;
+};
+
+export type ClipLabApiClip = {
+ clip_id: string;
+ clip_version: number;
+ content_hash: string;
+ transcript_override: string | null;
+ effective_audio_revision_key: string | null;
+ acceptance_stale: boolean;
+ review_status: string;
+ transcript: string | null;
+ original_transcript: string | null;
+ reviewer_tags: string[];
+ pipeline_findings: unknown[];
+ transcript_match: number | null;
+ speaker_check: number | null;
+ sample_rate_hz: number | null;
+ effective_audio_kind: string | null;
+ audio_url: string;
+ waveform_peaks_url: string | null;
+ current_duration_sec: number | null;
+ audio_edit_op_count: number;
+ audio_edit_ops: unknown[];
+ can_undo_audio: boolean;
+ can_redo_audio: boolean;
+ render_status: string | null;
+};
+
+export type ClipLabApiView = {
+ run_id: string;
+ candidate_manifest_sha256: string;
+ stale_state: boolean;
+ qc_available: boolean;
+ clips: ClipLabApiClip[];
+};
+
+async function getJson(path: string): Promise {
+ const res = await fetch(`${BASE}${path}`, { headers: { Accept: "application/json" } });
+ if (!res.ok) {
+ throw new Error(`Speechcraft API ${res.status} for ${path}`);
+ }
+ return (await res.json()) as T;
+}
+
+export async function fetchProjects(): Promise {
+ return getJson("/api/projects");
+}
+
+export async function fetchDatasetRuns(projectId: string): Promise {
+ return getJson(`/api/projects/${projectId}/dataset-runs`);
+}
+
+export async function fetchClipLabView(runId: string): Promise {
+ return getJson(`/api/dataset-runs/${runId}/clip-lab`);
+}
+
+// ── Dataset Health (QC) ─────────────────────────────────────────────────
+// Mirrors backend DatasetQcPayloadView / DatasetQcClipView (models.py).
+export type DatasetQcClipApi = {
+ clip_id: string;
+ audio_path: string;
+ audio_url: string;
+ duration_sec: number;
+ training_text: string;
+ alignment_text: string | null;
+ transcript_match: number | null; // 0..100, null = unscored (rejected)
+ speaker_check: number | null; // 0..100, null = unscored (rejected)
+ transcript_reason_codes: string[];
+ speaker_reason_codes: string[];
+ candidate_reason_codes: string[];
+ qc_reason_codes: string[];
+ manual_override: "force_keep" | "force_reject" | null;
+};
+
+export type DatasetQcPayload = {
+ run_id: string;
+ ready: boolean;
+ missing_artifacts: string[];
+ invalid_artifacts: string[];
+ defaults: { transcript_match_threshold: number; speaker_check_threshold: number };
+ finalized: boolean;
+ finalized_thresholds: { transcript_match_min: number; speaker_check_min: number } | null;
+ clips: DatasetQcClipApi[];
+};
+
+/** Authoritative QC payload for the Dataset Health page (thresholds + per-clip scores). */
+export async function fetchDatasetQc(runId: string): Promise {
+ return getJson(`/api/dataset-runs/${runId}/qc`);
+}
+
+export type DatasetRunLog = {
+ run_id: string;
+ path: string;
+ text: string;
+ truncated: boolean;
+};
+
+/** Raw backend worker log for a run (tail-truncated). Diagnostics drawer. */
+export async function fetchRunLog(runId: string): Promise {
+ return getJson(`/api/dataset-runs/${runId}/log`);
+}
+
+/** Pick the most relevant run to review: newest run that actually has clips. */
+export function pickReviewableRun(
+ runs: SpeechcraftDatasetRun[],
+): SpeechcraftDatasetRun | null {
+ const withClips = runs.find((r) => (r.output_summary?.clip_count ?? 0) > 0);
+ return withClips ?? runs[0] ?? null;
+}
+
+/** Absolute (same-origin) URL for a clip's audio / peaks endpoint. */
+export function resolveMediaUrl(pathOrUrl: string | null | undefined): string | null {
+ if (!pathOrUrl) return null;
+ if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) return pathOrUrl;
+ return `${BASE}${pathOrUrl}`;
+}
+
+const KNOWN_STATUSES: ReviewStatus[] = [
+ "unresolved",
+ "accepted",
+ "rejected",
+ "quarantined",
+];
+
+function normalizeStatus(raw: string): ReviewStatus {
+ return (KNOWN_STATUSES as string[]).includes(raw) ? (raw as ReviewStatus) : "unresolved";
+}
+
+function findingToCode(finding: unknown): string {
+ if (typeof finding === "string") return finding;
+ if (finding && typeof finding === "object") {
+ const f = finding as Record;
+ return String(f.code ?? f.reason ?? f.type ?? "flag");
+ }
+ return "flag";
+}
+
+/** Map a live clip-lab clip into the view model the panels already consume. */
+export function mapApiClip(
+ clip: ClipLabApiClip,
+ index: number,
+ runId: string,
+ manifestSha: string,
+ thresholds: QcThresholds = DEFAULT_QC_THRESHOLDS,
+): LabClip {
+ const transcript = clip.transcript ?? clip.original_transcript ?? "";
+ const transcriptConfidence = clip.transcript_match ?? 0;
+ const speakerPurity = clip.speaker_check ?? 0;
+
+ // Backend-faithful machine bucket: the two scores are independent gates
+ // (AND). Hard pipeline findings reject outright; unknown scores can't be
+ // machine-decided (needs_review); otherwise pass = both gates cleared.
+ // Thresholds are whatever Dataset Health has committed (or the backend
+ // defaults if nothing has been committed yet) — never a client-side guess.
+ const scoresKnown = clip.transcript_match != null && clip.speaker_check != null;
+ const hardFailed = clip.pipeline_findings.length > 0;
+ const passesGate =
+ scoresKnown &&
+ (clip.transcript_match as number) >= thresholds.transcriptMatchMin &&
+ (clip.speaker_check as number) >= thresholds.speakerCheckMin;
+ const machineBucket: MachineBucket = !scoresKnown
+ ? "needs_review"
+ : hardFailed || !passesGate
+ ? "auto_rejected"
+ : "auto_kept";
+
+ // Limiting-gate satisfaction (min across the two gates, each normalized to
+ // its own threshold) — NOT a blended score. >=1 means both gates pass; used
+ // only to order "closest to failing" in the queue.
+ const qcScore = scoresKnown
+ ? Math.max(
+ 0,
+ Math.min(
+ 1,
+ Math.min(
+ transcriptConfidence / thresholds.transcriptMatchMin,
+ speakerPurity / thresholds.speakerCheckMin,
+ ),
+ ),
+ )
+ : 0;
+
+ const edits = Array.isArray(clip.audio_edit_ops)
+ ? clip.audio_edit_ops.map((op) => {
+ const o = (op ?? {}) as Record;
+ return {
+ op: String(o.op ?? o.kind ?? "edit"),
+ startSeconds: typeof o.start_seconds === "number" ? o.start_seconds : undefined,
+ endSeconds: typeof o.end_seconds === "number" ? o.end_seconds : undefined,
+ durationSeconds:
+ typeof o.duration_seconds === "number" ? o.duration_seconds : undefined,
+ };
+ })
+ : [];
+
+ return {
+ id: clip.clip_id,
+ order: index + 1,
+ transcript,
+ originalTranscript: clip.original_transcript ?? transcript,
+ durationSeconds: clip.current_duration_sec ?? 0,
+ status: normalizeStatus(clip.review_status),
+ machineBucket,
+ qcScore: Number(qcScore.toFixed(3)),
+ transcriptConfidence,
+ speakerPurity,
+ transcriptMatchRaw: clip.transcript_match,
+ speakerCheckRaw: clip.speaker_check,
+ variant: clip.effective_audio_kind ?? "source",
+ tags: clip.reviewer_tags ?? [],
+ reasonCodes: (clip.pipeline_findings ?? []).map(findingToCode),
+ peaks: [],
+ sampleRateHz: clip.sample_rate_hz ?? 16000,
+ channels: 1,
+ speaker: "—",
+ language: "en",
+ sourceRecording: runId,
+ originalStartSeconds: 0,
+ originalEndSeconds: clip.current_duration_sec ?? 0,
+ revisions: [],
+ edits,
+ audioUrl: resolveMediaUrl(clip.audio_url),
+ waveformPeaksUrl: resolveMediaUrl(clip.waveform_peaks_url),
+ clipVersion: clip.clip_version,
+ manifestSha,
+ effectiveAudioRevisionKey: clip.effective_audio_revision_key,
+ renderStatus: clip.render_status as LabClip["renderStatus"],
+ canUndoAudio: clip.can_undo_audio,
+ canRedoAudio: clip.can_redo_audio,
+ audioEditOpCount: clip.audio_edit_op_count,
+ };
+}
diff --git a/frontend/src/components/lab/speechcraft-write-api.ts b/frontend/src/components/lab/speechcraft-write-api.ts
new file mode 100644
index 0000000..6235cb2
--- /dev/null
+++ b/frontend/src/components/lab/speechcraft-write-api.ts
@@ -0,0 +1,258 @@
+// Write client for speechcraft's live FastAPI backend (same-origin via the
+// /sc-api rewrite). Every clip-lab mutation carries optimistic-concurrency
+// tokens (expected_manifest_sha256 + expected_clip_version); the backend
+// returns 409 if either is stale. Error responses are mapped into a typed
+// SpeechcraftApiError so the workstation can react per failure kind.
+
+const BASE = process.env.NEXT_PUBLIC_SPEECHCRAFT_API_URL ?? "/sc-api";
+
+// ── Error taxonomy ──────────────────────────────────────────────────────
+// Mirrors the HTTPException status codes raised in backend/app/main.py:
+// 404 not found · 409 stale manifest/clip · 422 unrendered audio ·
+// 400 validation · 500 render failure · 503 clip-lab state/lock error.
+export type ApiErrorKind =
+ | "not_found"
+ | "conflict"
+ | "unrendered"
+ | "validation"
+ | "render"
+ | "unavailable"
+ | "unknown";
+
+export class SpeechcraftApiError extends Error {
+ readonly status: number;
+ readonly kind: ApiErrorKind;
+ readonly detail: string;
+
+ constructor(status: number, detail: string) {
+ super(detail || `Speechcraft API error ${status}`);
+ this.name = "SpeechcraftApiError";
+ this.status = status;
+ this.detail = detail;
+ this.kind = SpeechcraftApiError.kindFor(status);
+ }
+
+ static kindFor(status: number): ApiErrorKind {
+ switch (status) {
+ case 404:
+ return "not_found";
+ case 409:
+ return "conflict";
+ case 422:
+ return "unrendered";
+ case 400:
+ return "validation";
+ case 500:
+ return "render";
+ case 503:
+ return "unavailable";
+ default:
+ return "unknown";
+ }
+ }
+
+ /** True when the client's optimistic tokens are stale and it must refetch. */
+ get isStale(): boolean {
+ return this.kind === "conflict";
+ }
+}
+
+async function parseError(res: Response): Promise {
+ let detail = `${res.status} ${res.statusText}`;
+ try {
+ const body = await res.json();
+ if (body && typeof body === "object" && "detail" in body) {
+ const d = (body as { detail: unknown }).detail;
+ detail = typeof d === "string" ? d : JSON.stringify(d);
+ }
+ } catch {
+ // non-JSON error body; keep status line
+ }
+ return new SpeechcraftApiError(res.status, detail);
+}
+
+async function sendJson(
+ path: string,
+ method: "POST" | "PATCH",
+ body: unknown,
+): Promise {
+ const res = await fetch(`${BASE}${path}`, {
+ method,
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) throw await parseError(res);
+ return (await res.json()) as T;
+}
+
+// ── Payload + response types (mirror backend/app/models.py) ─────────────
+export type DatasetAudioEditOperation =
+ | { kind: "delete_range"; start_sample: number; end_sample: number }
+ | { kind: "insert_silence"; at_sample: number; duration_samples: number };
+
+export type DatasetClipLabPatchRequest = {
+ expected_manifest_sha256: string;
+ expected_clip_version: number;
+ review_status?: "unresolved" | "accepted" | "rejected" | "quarantined";
+ transcript_override?: string | null;
+ reviewer_tags?: string[];
+};
+
+export type DatasetClipLabAudioOperationRequest = {
+ expected_manifest_sha256: string;
+ expected_clip_version: number;
+ operation: DatasetAudioEditOperation;
+};
+
+export type DatasetClipLabAudioStackRequest = {
+ expected_manifest_sha256: string;
+ expected_clip_version: number;
+};
+
+// Full clip row returned by every write (backend DatasetClipLabClipView).
+export type DatasetClipLabClipView = {
+ clip_id: string;
+ clip_version: number;
+ review_status: "unresolved" | "accepted" | "rejected" | "quarantined";
+ transcript: string;
+ original_transcript: string;
+ transcript_override: string | null;
+ reviewer_tags: string[];
+ pipeline_findings: { code: string; label: string }[];
+ content_hash: string;
+ accepted_content_hash: string | null;
+ acceptance_stale: boolean;
+ transcript_match: number | null;
+ speaker_check: number | null;
+ sample_rate_hz: number | null;
+ effective_audio_kind: "candidate_original" | "rendered_revision" | null;
+ effective_audio_revision_key: string | null;
+ audio_url: string | null;
+ waveform_peaks_url: string | null;
+ current_duration_sec: number | null;
+ audio_edit_op_count: number;
+ audio_edit_ops: DatasetAudioEditOperation[];
+ can_undo_audio: boolean;
+ can_redo_audio: boolean;
+ render_status: "ready" | "pending" | "failed";
+};
+
+export type MarkReferenceClipCandidateRequest = {
+ clip_id: string;
+ transcript_text: string;
+};
+
+export type ReferenceClipCandidateView = {
+ project_id: string;
+ dataset_run_id: string;
+ clip_id: string;
+ transcript_text: string;
+ filename: string;
+ relative_path: string;
+ source_audio_path: string;
+ created_at: string;
+};
+
+export type DatasetWaveformPeaksPayload = {
+ revision_key: string;
+ bins: number;
+ peaks: number[];
+ duration_sec: number;
+ sample_rate_hz: number;
+};
+
+// ── Write operations ────────────────────────────────────────────────────
+const clipPath = (runId: string, clipId: string) =>
+ `/api/dataset-runs/${runId}/clips/${encodeURIComponent(clipId)}`;
+
+/** Review status / transcript override / reviewer tags. */
+export function patchClipLab(
+ runId: string,
+ clipId: string,
+ payload: DatasetClipLabPatchRequest,
+): Promise {
+ return sendJson(`${clipPath(runId, clipId)}/clip-lab`, "PATCH", payload);
+}
+
+/** Append an EDL audio operation (delete range / insert silence). */
+export function appendAudioOperation(
+ runId: string,
+ clipId: string,
+ payload: DatasetClipLabAudioOperationRequest,
+): Promise {
+ return sendJson(`${clipPath(runId, clipId)}/audio/operations`, "POST", payload);
+}
+
+export function undoAudioOperation(
+ runId: string,
+ clipId: string,
+ payload: DatasetClipLabAudioStackRequest,
+): Promise {
+ return sendJson(`${clipPath(runId, clipId)}/audio/undo`, "POST", payload);
+}
+
+export function redoAudioOperation(
+ runId: string,
+ clipId: string,
+ payload: DatasetClipLabAudioStackRequest,
+): Promise {
+ return sendJson(`${clipPath(runId, clipId)}/audio/redo`, "POST", payload);
+}
+
+/** Save the active clip as a reference-asset candidate for the project. */
+export function markReferenceClipCandidate(
+ projectId: string,
+ runId: string,
+ payload: MarkReferenceClipCandidateRequest,
+): Promise {
+ return sendJson(
+ `/api/projects/${projectId}/dataset-runs/${runId}/reference-clip-candidates`,
+ "POST",
+ payload,
+ );
+}
+
+/** Fetch real waveform peaks for a clip revision (path from the clip view). */
+export async function fetchWaveformPeaks(
+ pathOrUrl: string,
+): Promise {
+ const url = pathOrUrl.startsWith("http") ? pathOrUrl : `${BASE}${pathOrUrl}`;
+ const res = await fetch(url, { headers: { Accept: "application/json" } });
+ if (!res.ok) throw await parseError(res);
+ return (await res.json()) as DatasetWaveformPeaksPayload;
+}
+
+// ── Dataset Health (QC) finalize ────────────────────────────────────────
+// POST /api/dataset-runs/{run_id}/qc/finalize — commits the chosen thresholds
+// (+ optional manual overrides) as the durable QC verdict. This writes
+// dataset_qc.json AND clears prior export artifacts, so it is a deliberate,
+// confirmed action, not something to fire on every slider drag. 400 on
+// validation (e.g. QC not ready), 404 unknown run.
+export type DatasetQcManualOverride = {
+ clip_id: string;
+ override: "force_keep" | "force_reject";
+ reason?: string;
+};
+
+export type DatasetQcFinalizeResponse = {
+ run_id: string;
+ dataset_qc_path: string;
+ summary: {
+ accepted_count: number;
+ rejected_count: number;
+ accepted_duration_sec: number;
+ rejected_duration_sec: number;
+ };
+};
+
+export async function finalizeDatasetQc(
+ runId: string,
+ thresholds: { transcript_match_min: number; speaker_check_min: number },
+ manualOverrides: DatasetQcManualOverride[] = [],
+): Promise {
+ return sendJson(
+ `/api/dataset-runs/${runId}/qc/finalize`,
+ "POST",
+ { thresholds, manual_overrides: manualOverrides },
+ );
+}
diff --git a/frontend/src/components/lab/status-badge.tsx b/frontend/src/components/lab/status-badge.tsx
new file mode 100644
index 0000000..fe17933
--- /dev/null
+++ b/frontend/src/components/lab/status-badge.tsx
@@ -0,0 +1,31 @@
+import { Badge } from "@midday/ui/badge";
+import { cn } from "@midday/ui/cn";
+import { type ReviewStatus, STATUS_LABELS } from "./lab-data";
+
+// Live human review status as a solid, semantically-colored Badge — this is
+// the authoritative truth (distinct from the muted/outline machine-bucket
+// badge used in the inspector).
+const STATUS_CLASSES: Record = {
+ unresolved:
+ "border-transparent bg-[#e8dcae] text-[#5c5326] dark:bg-[#33301c] dark:text-[#d9c441] hover:bg-[#e8dcae]",
+ accepted:
+ "border-transparent bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300 hover:bg-emerald-100",
+ rejected:
+ "border-transparent bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300 hover:bg-red-100",
+ quarantined:
+ "border-transparent bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300 hover:bg-amber-100",
+};
+
+export function StatusBadge({
+ status,
+ className,
+}: {
+ status: ReviewStatus;
+ className?: string;
+}) {
+ return (
+
+ {STATUS_LABELS[status]}
+
+ );
+}
diff --git a/frontend/src/components/lab/threshold-histogram-chart.tsx b/frontend/src/components/lab/threshold-histogram-chart.tsx
new file mode 100644
index 0000000..48a7d44
--- /dev/null
+++ b/frontend/src/components/lab/threshold-histogram-chart.tsx
@@ -0,0 +1,213 @@
+"use client";
+
+import { useCallback, useMemo, useRef, useState } from "react";
+import {
+ Bar,
+ BarChart,
+ CartesianGrid,
+ Cell,
+ ReferenceLine,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+import type { HistogramBin } from "./qc-logic";
+
+type ThresholdHistogramChartProps = {
+ title: string;
+ subtitle: string;
+ bins: HistogramBin[];
+ threshold: number;
+ onThresholdChange: (value: number) => void;
+ unscoredCount: number;
+ acceptedCount: number;
+ acceptedDurationSec: number;
+ height?: number;
+};
+
+function formatDuration(seconds: number): string {
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.round(seconds % 60);
+ if (mins <= 0) return `${secs}s`;
+ return `${mins}m ${secs}s`;
+}
+
+type TooltipPayloadItem = {
+ payload?: HistogramBin;
+};
+
+function HistogramTooltip({
+ active,
+ payload,
+}: {
+ active?: boolean;
+ payload?: TooltipPayloadItem[];
+}) {
+ if (!active || !payload?.length) return null;
+ const bin = payload[0]?.payload;
+ if (!bin) return null;
+ return (
+
+
+ {Math.round(bin.start)}–{Math.round(bin.end)}
+
+
+ {bin.count} clip{bin.count === 1 ? "" : "s"}
+
+
+ );
+}
+
+export function ThresholdHistogramChart({
+ title,
+ subtitle,
+ bins,
+ threshold,
+ onThresholdChange,
+ unscoredCount,
+ acceptedCount,
+ acceptedDurationSec,
+ height = 240,
+}: ThresholdHistogramChartProps) {
+ const containerRef = useRef(null);
+ const [dragging, setDragging] = useState(false);
+
+ // Shared with the below: the plot area is the container
+ // minus these margins on every side. Keeping this as one constant means the
+ // drag math and recharts' own layout can never drift apart.
+ const PLOT_MARGIN = { top: 6, right: 8, bottom: 0, left: 8 };
+
+ const data = useMemo(
+ () => bins.map((bin) => ({ ...bin, kept: bin.center >= threshold })),
+ [bins, threshold],
+ );
+
+ const maxCount = useMemo(() => Math.max(1, ...bins.map((b) => b.count)), [bins]);
+
+ const scoreFromClientX = useCallback((clientX: number): number => {
+ const el = containerRef.current;
+ if (!el) return threshold;
+ const rect = el.getBoundingClientRect();
+ const plotLeft = rect.left + PLOT_MARGIN.left;
+ const plotWidth = rect.width - PLOT_MARGIN.left - PLOT_MARGIN.right;
+ if (plotWidth <= 0) return threshold;
+ const ratio = (clientX - plotLeft) / plotWidth;
+ const clamped = Math.max(0, Math.min(1, ratio));
+ return Math.round(clamped * 100);
+ }, [threshold, PLOT_MARGIN.left, PLOT_MARGIN.right]);
+
+ const handlePointerDown = useCallback(
+ (event: React.PointerEvent) => {
+ event.preventDefault();
+ setDragging(true);
+ onThresholdChange(scoreFromClientX(event.clientX));
+ const onMove = (moveEvent: PointerEvent) => {
+ onThresholdChange(scoreFromClientX(moveEvent.clientX));
+ };
+ const onUp = () => {
+ setDragging(false);
+ window.removeEventListener("pointermove", onMove);
+ window.removeEventListener("pointerup", onUp);
+ };
+ window.addEventListener("pointermove", onMove);
+ window.addEventListener("pointerup", onUp);
+ },
+ [onThresholdChange, scoreFromClientX],
+ );
+
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ onThresholdChange(Math.max(0, threshold - 1));
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ onThresholdChange(Math.min(100, threshold + 1));
+ }
+ },
+ [onThresholdChange, threshold],
+ );
+
+ return (
+
+
+
{title}
+ min {threshold}
+
+
{subtitle}
+
+
+
+
+
+
+
+ } cursor={false} />
+
+ {data.map((entry) => (
+ |
+ ))}
+
+
+
+
+
+ {/* Draggable handle grip, positioned over the reference line. Uses the
+ same PLOT_MARGIN as the chart above so it tracks the real bar
+ positions instead of the raw container width. */}
+
+
+
+
+
+ {acceptedCount} clip{acceptedCount === 1 ? "" : "s"} kept ·{" "}
+ {formatDuration(acceptedDurationSec)}
+
+ {unscoredCount > 0 && (
+ {unscoredCount} unscored
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/lab/transcript-panel.tsx b/frontend/src/components/lab/transcript-panel.tsx
new file mode 100644
index 0000000..ba4c44c
--- /dev/null
+++ b/frontend/src/components/lab/transcript-panel.tsx
@@ -0,0 +1,185 @@
+"use client";
+
+import { Card } from "@midday/ui/card";
+import { cn } from "@midday/ui/cn";
+import { Icons } from "@midday/ui/icons";
+import { Input } from "@midday/ui/input";
+import { Textarea } from "@midday/ui/textarea";
+import { useEffect, useRef, useState } from "react";
+import { SavingBar } from "@/components/saving-bar";
+import { type LabClip, formatQcScore } from "./lab-data";
+
+type TranscriptPanelProps = {
+ clip: LabClip;
+ allTags: string[];
+ onTranscriptChange: (text: string) => void;
+ onTagsChange: (tags: string[]) => void;
+};
+
+export function TranscriptPanel({
+ clip,
+ allTags,
+ onTranscriptChange,
+ onTagsChange,
+}: TranscriptPanelProps) {
+ const [draft, setDraft] = useState(clip.transcript);
+ const [isPending, setIsPending] = useState(false);
+ const [tagInput, setTagInput] = useState("");
+ const textareaRef = useRef(null);
+ const debounceRef = useRef | null>(null);
+
+ // Reset the draft when the active clip changes.
+ useEffect(() => {
+ setDraft(clip.transcript);
+ setIsPending(false);
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ }, [clip.id, clip.transcript]);
+
+ // Auto-grow the textarea to fit content.
+ useEffect(() => {
+ const el = textareaRef.current;
+ if (!el) return;
+ el.style.height = "auto";
+ el.style.height = `${el.scrollHeight}px`;
+ }, [draft]);
+
+ const handleTranscript = (value: string) => {
+ setDraft(value);
+ setIsPending(true);
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => {
+ onTranscriptChange(value);
+ setIsPending(false);
+ }, 600);
+ };
+
+ const addTag = (raw: string) => {
+ const tag = raw.trim();
+ if (!tag) return;
+ if (clip.tags.some((t) => t.toLowerCase() === tag.toLowerCase())) return;
+ onTagsChange([...clip.tags, tag]);
+ setTagInput("");
+ };
+
+ const removeTag = (tag: string) => {
+ onTagsChange(clip.tags.filter((t) => t !== tag));
+ };
+
+ const suggestions = allTags
+ .filter((tag) => !clip.tags.some((t) => t.toLowerCase() === tag.toLowerCase()))
+ .slice(0, 8);
+
+ return (
+
+
+
+ Transcript
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/lab/waveform-pane.tsx b/frontend/src/components/lab/waveform-pane.tsx
new file mode 100644
index 0000000..6fdc986
--- /dev/null
+++ b/frontend/src/components/lab/waveform-pane.tsx
@@ -0,0 +1,260 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import WaveSurfer from "wavesurfer.js";
+import RegionsPlugin from "wavesurfer.js/dist/plugins/regions.esm.js";
+
+type WaveformPaneProps = {
+ audioUrl: string;
+ selectionStart: number;
+ selectionEnd: number;
+ onSelectionChange: (start: number, end: number) => void;
+ onCursorChange: (time: number) => void;
+ onHoverChange?: (time: number | null) => void;
+ onReady?: (instance: WaveSurfer | null) => void;
+ onPlayingChange?: (isPlaying: boolean) => void;
+};
+
+// Adapted from speechcraft's WaveformPane — real wavesurfer + regions, but
+// restyled to Midday's monochrome ink palette (was teal) and simplified to
+// decode audio directly (no cached-peaks machinery). Keeps the core
+// mechanics: click-to-seek, drag-to-select region, hover readout, ctrl+wheel
+// zoom, and exposes the instance via onReady so the transport can drive it.
+export function WaveformPane({
+ audioUrl,
+ selectionStart,
+ selectionEnd,
+ onSelectionChange,
+ onCursorChange,
+ onHoverChange,
+ onReady,
+ onPlayingChange,
+}: WaveformPaneProps) {
+ const containerRef = useRef(null);
+ const waveSurferRef = useRef(null);
+ const regionsRef = useRef | null>(null);
+ const zoomRef = useRef(0);
+ const pointerDownRef = useRef(false);
+ const pointerStartXRef = useRef(null);
+ const pointerStartTimeRef = useRef(null);
+ const draggedRef = useRef(false);
+
+ const selectionChangeRef = useRef(onSelectionChange);
+ const cursorChangeRef = useRef(onCursorChange);
+ const hoverChangeRef = useRef(onHoverChange);
+ const readyRef = useRef(onReady);
+ const playingChangeRef = useRef(onPlayingChange);
+ selectionChangeRef.current = onSelectionChange;
+ cursorChangeRef.current = onCursorChange;
+ hoverChangeRef.current = onHoverChange;
+ readyRef.current = onReady;
+ playingChangeRef.current = onPlayingChange;
+
+ const [state, setState] = useState<"loading" | "ready" | "error">("loading");
+ const [error, setError] = useState(null);
+
+ const isAbortLike = (err: unknown) => {
+ const msg = (err instanceof Error ? err.message : String(err ?? "")).toLowerCase();
+ return msg.includes("abort") || msg.includes("cancel");
+ };
+
+ // Create the instance once.
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ const isDark = document.documentElement.classList.contains("dark");
+ const regions = RegionsPlugin.create();
+ const ws = WaveSurfer.create({
+ container: containerRef.current,
+ height: 200,
+ normalize: true,
+ waveColor: isDark ? "#454545" : "#cfcfcf",
+ progressColor: isDark ? "#9a9a9a" : "#6b6b6b",
+ cursorColor: isDark ? "#f5f5f5" : "#171717",
+ cursorWidth: 1,
+ barWidth: 2,
+ barGap: 1,
+ dragToSeek: false,
+ interact: true,
+ autoScroll: false,
+ autoCenter: false,
+ plugins: [regions],
+ });
+
+ waveSurferRef.current = ws;
+ regionsRef.current = regions;
+ readyRef.current?.(ws);
+
+ const timeAtClientX = (clientX: number): number | null => {
+ const wrapper = ws.getWrapper();
+ const scroll = wrapper.parentElement;
+ const duration = ws.getDuration();
+ if (!scroll || duration <= 0 || wrapper.scrollWidth <= 0) return null;
+ const rect = scroll.getBoundingClientRect();
+ const localX = Math.max(0, Math.min(clientX - rect.left, rect.width));
+ const absX = Math.max(0, Math.min(scroll.scrollLeft + localX, wrapper.scrollWidth));
+ return Number(((absX / wrapper.scrollWidth) * duration).toFixed(3));
+ };
+
+ ws.on("timeupdate", (t) => {
+ if (ws.isPlaying()) cursorChangeRef.current(Number(t.toFixed(2)));
+ });
+ ws.on("interaction", () => {
+ if (!draggedRef.current) cursorChangeRef.current(Number(ws.getCurrentTime().toFixed(2)));
+ });
+ ws.on("click", () => {
+ if (draggedRef.current) return;
+ const t = Number(ws.getCurrentTime().toFixed(2));
+ cursorChangeRef.current(t);
+ selectionChangeRef.current(t, t);
+ });
+ ws.on("play", () => playingChangeRef.current?.(true));
+ ws.on("pause", () => playingChangeRef.current?.(false));
+ ws.on("finish", () => playingChangeRef.current?.(false));
+ ws.on("ready", () => {
+ setState("ready");
+ setError(null);
+ });
+ ws.on("error", (err) => {
+ if (isAbortLike(err)) return;
+ setState("error");
+ setError(err instanceof Error ? err.message : "Audio failed to load.");
+ playingChangeRef.current?.(false);
+ });
+
+ regions.on("region-created", (region) => {
+ for (const other of regions.getRegions()) {
+ if (other.id !== region.id) other.remove();
+ }
+ selectionChangeRef.current(Number(region.start.toFixed(3)), Number(region.end.toFixed(3)));
+ });
+ regions.on("region-updated", (region) => {
+ selectionChangeRef.current(Number(region.start.toFixed(3)), Number(region.end.toFixed(3)));
+ });
+
+ const el = containerRef.current;
+ const onPointerDown = (e: PointerEvent) => {
+ pointerDownRef.current = true;
+ pointerStartXRef.current = e.clientX;
+ pointerStartTimeRef.current = timeAtClientX(e.clientX);
+ draggedRef.current = false;
+ };
+ const onPointerMove = (e: PointerEvent) => {
+ const t = timeAtClientX(e.clientX);
+ if (t !== null) hoverChangeRef.current?.(t);
+ if (pointerDownRef.current && pointerStartXRef.current !== null) {
+ if (Math.abs(e.clientX - pointerStartXRef.current) > 4) draggedRef.current = true;
+ }
+ };
+ const onPointerUp = (e: PointerEvent) => {
+ const didDrag = draggedRef.current;
+ const start = pointerStartTimeRef.current;
+ const end = timeAtClientX(e.clientX);
+ pointerDownRef.current = false;
+ pointerStartXRef.current = null;
+ pointerStartTimeRef.current = null;
+ if (didDrag && start !== null && end !== null) {
+ selectionChangeRef.current(Math.min(start, end), Math.max(start, end));
+ setTimeout(() => {
+ draggedRef.current = false;
+ }, 120);
+ } else {
+ draggedRef.current = false;
+ }
+ };
+ const onWheel = (e: WheelEvent) => {
+ if (!e.ctrlKey) return;
+ e.preventDefault();
+ const base = zoomRef.current || 80;
+ zoomRef.current = Math.max(20, Math.min(600, base + (e.deltaY < 0 ? 30 : -30)));
+ ws.zoom(zoomRef.current);
+ };
+ const onLeave = () => hoverChangeRef.current?.(null);
+
+ el.addEventListener("wheel", onWheel, { passive: false });
+ el.addEventListener("pointerdown", onPointerDown);
+ el.addEventListener("pointermove", onPointerMove);
+ el.addEventListener("pointerup", onPointerUp);
+ el.addEventListener("pointerleave", onLeave);
+
+ return () => {
+ el.removeEventListener("wheel", onWheel);
+ el.removeEventListener("pointerdown", onPointerDown);
+ el.removeEventListener("pointermove", onPointerMove);
+ el.removeEventListener("pointerup", onPointerUp);
+ el.removeEventListener("pointerleave", onLeave);
+ readyRef.current?.(null);
+ ws.destroy();
+ waveSurferRef.current = null;
+ regionsRef.current = null;
+ };
+ }, []);
+
+ // Load audio when the URL changes.
+ useEffect(() => {
+ const ws = waveSurferRef.current;
+ if (!ws || !audioUrl) return;
+ setState("loading");
+ setError(null);
+ zoomRef.current = 0;
+ void ws.load(audioUrl).catch((err) => {
+ if (!isAbortLike(err)) {
+ setState("error");
+ setError(err instanceof Error ? err.message : "Audio failed to load.");
+ }
+ });
+ }, [audioUrl]);
+
+ // Reflect external selection into a region.
+ useEffect(() => {
+ const regions = regionsRef.current;
+ if (!regions) return;
+ const start = Math.min(selectionStart, selectionEnd);
+ const end = Math.max(selectionStart, selectionEnd);
+ const current = regions.getRegions()[0];
+ if (end <= start + 0.01) {
+ current?.remove();
+ return;
+ }
+ if (!current) {
+ regions.addRegion({
+ start,
+ end,
+ color: "hsl(var(--primary) / 0.12)",
+ drag: true,
+ resize: true,
+ });
+ } else if (Math.abs(current.start - start) > 0.02 || Math.abs(current.end - end) > 0.02) {
+ current.setOptions({ start, end });
+ }
+ }, [selectionStart, selectionEnd]);
+
+ return (
+
+ {/* lineHeight/fontSize 0: line-height is inherited into WaveSurfer's
+ shadow DOM, where whitespace text nodes in its template would
+ otherwise create phantom line boxes that inflate the scroll/wrapper
+ height and push the main canvas below the (absolutely positioned)
+ progress canvas — producing a vertical step at the playback cursor. */}
+
+ {state !== "ready" ? (
+
+
+ {state === "loading" ? "Loading audio…" : "Audio unavailable"}
+
+ {state === "error" ? (
+ {error}
+ ) : null}
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/components/lab/workstation-sidebar.tsx b/frontend/src/components/lab/workstation-sidebar.tsx
new file mode 100644
index 0000000..892345a
--- /dev/null
+++ b/frontend/src/components/lab/workstation-sidebar.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { Button } from "@midday/ui/button";
+import { Icons } from "@midday/ui/icons";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@midday/ui/tooltip";
+import Link from "next/link";
+import { useState } from "react";
+import { ExportDialog } from "./export-dialog";
+import { ProjectPicker } from "./project-picker";
+
+export function WorkstationSidebar() {
+ const [exportOpen, setExportOpen] = useState(false);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/login-continue-button.tsx b/frontend/src/components/login-continue-button.tsx
new file mode 100644
index 0000000..dadad39
--- /dev/null
+++ b/frontend/src/components/login-continue-button.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { SubmitButton } from "@midday/ui/submit-button";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+
+// End of the setup wizard: hand off into the Lab workstation. No auth
+// return_to concept anymore (local single-user tool).
+export function LoginContinueButton() {
+ const router = useRouter();
+ const [isLoading, setLoading] = useState(false);
+
+ const handleContinue = () => {
+ setLoading(true);
+ router.push("/lab");
+ };
+
+ return (
+
+ Continue
+
+ );
+}
diff --git a/frontend/src/components/login-testimonials.tsx b/frontend/src/components/login-testimonials.tsx
new file mode 100644
index 0000000..0c37ec1
--- /dev/null
+++ b/frontend/src/components/login-testimonials.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import { AnimatePresence, motion } from "framer-motion";
+import { useEffect, useState } from "react";
+
+type Testimonial = {
+ name?: string;
+ title?: string;
+ content?: string;
+ highlighted: string;
+ firstPart: string;
+ secondPart: string;
+ // Which part actually contains the `highlighted` text. Some entries'
+ // highlighted quote straddles the firstPart/secondPart boundary, so this
+ // is set explicitly per-entry rather than inferred.
+ highlightPart: "first" | "second";
+};
+
+const testimonials: Testimonial[] = [
+ {
+ highlighted: "We went from two months of data prep down to ten days.",
+ firstPart: "We went from two months of data prep down to ten days",
+ secondPart:
+ ". We had studio recordings, raw interviews, and archival audio we'd never been able to use. Speechcraft ingested all of it and handed us a clean dataset with a full trail back to every source file.",
+ highlightPart: "first",
+ },
+ {
+ highlighted:
+ "I didn't realise how much of my training data was quietly wrong until I could actually see it.",
+ firstPart:
+ "I didn't realise how much of my training data was quietly wrong until I could actually see it",
+ secondPart:
+ ". Speechcraft surfaced clips cut mid-word, mismatched-transcripts and background bleed. The voice I shipped after fixing that was night and day compared to anything I'd trained before.",
+ highlightPart: "first",
+ },
+ {
+ highlighted:
+ "Our model quality improved more from fixing the data than from anything we had changed about the model itself.",
+ firstPart:
+ "I was manually pulling clips from a 40-hour podcast. Speechcraft processed the entire archive and every clip came back clean enough to train on",
+ secondPart:
+ ". Our model quality improved more from fixing the data than from anything we had changed about the model itself.",
+ highlightPart: "second",
+ },
+ {
+ highlighted: "We used that budget for compute instead.",
+ firstPart:
+ "We were about to hire a freelancer for three months to clean and label our audio archive. Speechcraft got us to the same place in four days, and the output was more consistent than what a human working manually would have produced",
+ secondPart: ". We used that budget for compute instead.",
+ highlightPart: "second",
+ },
+ {
+ highlighted:
+ "I had a clean labelled dataset before my advisor expected me to have finished data collection.",
+ firstPart:
+ "I came into this expecting to spend most of the fellowship building data infrastructure before I could start my actual research",
+ secondPart:
+ ". Speechcraft collapsed that into a week. I had a clean labelled dataset before my advisor expected me to have finished data collection.",
+ highlightPart: "second",
+ },
+ {
+ content:
+ "Unhappy with results, we switched base models mid-project. Normally, differing format requirements meant redoing all data preparation. With Speechcraft, we simply changed the export target and recompiled. The dataset was ready in minutes. We didn't lose a single annotation or re-review any clips.",
+ highlighted:
+ "The dataset was ready in minutes. We didn't lose a single annotation or re-review any clips.",
+ firstPart:
+ "Unhappy with results, we switched base models mid-project. Normally, differing format requirements meant redoing all data preparation. With Speechcraft, we simply changed the export target and recompiled.",
+ secondPart:
+ " The dataset was ready in minutes. We didn't lose a single annotation or re-review any clips.",
+ highlightPart: "second",
+ },
+ {
+ content:
+ "Before this, when my model output sounded wrong, I'd have to trace it back manually. With Speechcraft I know exactly which recording fed which clip, who reviewed it, and what state it's in before anything reaches training. This has changed how fast I can iterate.",
+ highlighted:
+ "I know exactly which recording fed which clip, who reviewed it, and what state it's in before anything reaches training.",
+ firstPart:
+ "Before this, when my model output sounded wrong, I'd have to trace it back manually. With Speechcraft I know exactly which recording fed which clip, who reviewed it, and what state it's in before anything reaches training.",
+ secondPart:
+ " That sounds like a small thing. This has changed how fast I can iterate.",
+ highlightPart: "first",
+ },
+ {
+ content:
+ "Our previous workflow for preparing a new voice model was about six weeks if everything went right, which it usually didn't. Someone would run a script wrong, a step would time out and we'd lose the work. We did the same scope of work in Speechcraft in eight days. The time we saved on the first project alone covered the time it took to learn the tool.",
+ highlighted: "We did the same scope of work in eight days.",
+ firstPart:
+ "Our previous workflow for preparing a new voice model was about six weeks if everything went right, which it usually didn't. Someone would run a script wrong, a step would time out and we'd lose the work. We did the same scope of work in Speechcraft in eight days.",
+ secondPart:
+ " We did the same scope of work in eight days. The time we saved on the first project alone covered the time it took to learn the tool.",
+ highlightPart: "second",
+ },
+];
+
+export default function LoginTestimonials() {
+ const [currentTestimonial, setCurrentTestimonial] = useState(0);
+
+ useEffect(() => {
+ // Set random starting index only on client to avoid hydration mismatch
+ setCurrentTestimonial(Math.floor(Math.random() * testimonials.length));
+
+ const interval = setInterval(() => {
+ setCurrentTestimonial((prev) => (prev + 1) % testimonials.length);
+ }, 6000);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ return (
+
+
+
+ {/* Quote - First */}
+
+ {/* Large opening quote */}
+
+
+ {(() => {
+ const testimonial = testimonials[currentTestimonial];
+ const firstPart = testimonial?.firstPart || "";
+ const secondPart = testimonial?.secondPart || "";
+ const startsWithPunctuation =
+ secondPart.startsWith(".") || secondPart.startsWith(",");
+ const punctuation = startsWithPunctuation ? secondPart[0] : ".";
+ const secondPartWithoutPunctuation = startsWithPunctuation
+ ? secondPart.slice(1)
+ : secondPart;
+
+ return testimonial?.highlightPart === "first" ? (
+ <>
+
+ "{firstPart}
+ {punctuation}
+
+ {secondPartWithoutPunctuation}"
+ >
+ ) : (
+ <>
+ "{firstPart}
+ {punctuation}
+
+ {secondPartWithoutPunctuation}"
+
+ >
+ );
+ })()}
+
+
+
+ {/* Name and Title - Second (only when provided) */}
+ {testimonials[currentTestimonial]?.name && (
+
+ {testimonials[currentTestimonial]?.name}
+ {testimonials[currentTestimonial]?.title
+ ? `, ${testimonials[currentTestimonial]?.title}`
+ : null}
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/login-video-background.tsx b/frontend/src/components/login-video-background.tsx
new file mode 100644
index 0000000..928cddb
--- /dev/null
+++ b/frontend/src/components/login-video-background.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import LoginTestimonials from "./login-testimonials";
+
+export function LoginVideoBackground() {
+ const [isVideoLoaded, setIsVideoLoaded] = useState(false);
+ const videoRef = useRef(null);
+
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ const handleCanPlay = () => {
+ setIsVideoLoaded(true);
+ };
+
+ const handleLoadedData = () => {
+ setIsVideoLoaded(true);
+ };
+
+ const handleCanPlayThrough = () => {
+ setIsVideoLoaded(true);
+ };
+
+ // Check if video is already loaded
+ if (video.readyState >= 3) {
+ setIsVideoLoaded(true);
+ }
+
+ video.addEventListener("canplay", handleCanPlay);
+ video.addEventListener("loadeddata", handleLoadedData);
+ video.addEventListener("canplaythrough", handleCanPlayThrough);
+
+ return () => {
+ video.removeEventListener("canplay", handleCanPlay);
+ video.removeEventListener("loadeddata", handleLoadedData);
+ video.removeEventListener("canplaythrough", handleCanPlayThrough);
+ };
+ }, []);
+
+ return (
+
+ {/* Poster image with blur effect */}
+
+

+
+
+ {/* Video */}
+
+
+ {/* Overlay for better text readability */}
+
+
+ {/* Content overlay */}
+
+
+ );
+}
diff --git a/frontend/src/components/model-download.tsx b/frontend/src/components/model-download.tsx
new file mode 100644
index 0000000..782e57a
--- /dev/null
+++ b/frontend/src/components/model-download.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { Icons } from "@midday/ui/icons";
+import { SubmitButton } from "@midday/ui/submit-button";
+import { Spinner } from "@midday/ui/spinner";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { SpeechcraftApiError } from "@/components/lab/speechcraft-write-api";
+import { demoEnabled, withDemo } from "@/lib/demo";
+import {
+ createDatasetRun,
+ resolveWhisperModel,
+ startRun,
+} from "./wizard/wizard-api";
+
+// No knobs: the most capable Whisper model the hardware can load is resolved
+// automatically (large-v3, else base). If neither loads, that's a hard error
+// with the backend reason surfaced — not a silent spinner.
+type Phase = "resolving" | "starting" | "error";
+
+export function ModelDownload() {
+ const router = useRouter();
+ const params = useSearchParams();
+ const projectId = params.get("project");
+ const demo = demoEnabled(params);
+ const [phase, setPhase] = useState("resolving");
+ const [model, setModel] = useState(null);
+ const [error, setError] = useState(null);
+ const startedRef = useRef(false);
+
+ const skipAhead = useCallback(() => {
+ router.push(withDemo(`/login4?project=${encodeURIComponent(projectId ?? "demo-review")}&run=demo`, true));
+ }, [router, projectId]);
+
+ const run = useCallback(async () => {
+ if (demo) {
+ skipAhead();
+ return;
+ }
+ if (!projectId) {
+ setPhase("error");
+ setError("No project in the URL. Restart from ingest.");
+ return;
+ }
+ setPhase("resolving");
+ setError(null);
+ try {
+ const resolved = await resolveWhisperModel();
+ if (resolved.model === null) {
+ setPhase("error");
+ setError(resolved.error);
+ return;
+ }
+ setModel(resolved.model);
+ setPhase("starting");
+ const datasetRun = await createDatasetRun(projectId, {
+ whisper_model_size: resolved.model,
+ });
+ await startRun(datasetRun.id);
+ router.push(
+ withDemo(
+ `/login4?project=${encodeURIComponent(projectId)}&run=${encodeURIComponent(datasetRun.id)}`,
+ demo,
+ ),
+ );
+ } catch (err) {
+ setPhase("error");
+ setError(err instanceof SpeechcraftApiError ? err.detail : "Could not prepare the model.");
+ }
+ }, [projectId, router, demo, skipAhead]);
+
+ useEffect(() => {
+ if (startedRef.current) return;
+ startedRef.current = true;
+ void run();
+ }, [run]);
+
+ if (phase === "error") {
+ return (
+ <>
+
+
Couldn't prepare a model
+
{error}
+
+
+
{
+ startedRef.current = false;
+ void run();
+ }}
+ className="bg-primary px-6 py-4 text-secondary font-medium flex space-x-2 h-[40px] w-full"
+ isSubmitting={false}
+ >
+
+
+ Retry
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
Preparing models
+
+ {phase === "resolving"
+ ? "Detecting your hardware and loading the best Whisper model…"
+ : `Using ${model} · starting speaker detection…`}
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/qc/QcThresholdImpactCard.tsx b/frontend/src/components/qc/QcThresholdImpactCard.tsx
deleted file mode 100644
index 16fff64..0000000
--- a/frontend/src/components/qc/QcThresholdImpactCard.tsx
+++ /dev/null
@@ -1,362 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from "react";
-import type { CurvePoint } from "../../qc/qcLogic";
-
-type QcThresholdImpactCardProps = {
- label: string;
- threshold: number;
- points: CurvePoint[];
- accentClassName: string;
- emptyLabel: string;
- explanation: string;
- onDraftChange: (value: number) => void;
- onCommit: () => void;
-};
-
-const VIEWBOX_WIDTH = 320;
-const VIEWBOX_HEIGHT = 180;
-const RANGE_THUMB_WIDTH_PX = 14;
-const X_TICKS = [0, 25, 50, 75, 100];
-const Y_MAX_PADDING_MULTIPLIER = 1.03;
-const HOVER_LINE_TOLERANCE = 16;
-
-type HoverState = {
- threshold: number;
- x: number;
- y: number;
- leftPercent: number;
- topPercent: number;
-};
-
-function useElementWidth() {
- const ref = useRef(null);
- const [width, setWidth] = useState(0);
-
- useEffect(() => {
- const element = ref.current;
- if (!element) {
- return undefined;
- }
-
- const update = () => {
- setWidth(element.getBoundingClientRect().width);
- };
- update();
-
- if (typeof ResizeObserver === "undefined") {
- window.addEventListener("resize", update);
- return () => window.removeEventListener("resize", update);
- }
-
- const observer = new ResizeObserver(update);
- observer.observe(element);
- return () => observer.disconnect();
- }, []);
-
- return { ref, width };
-}
-
-function thresholdToSvgX(threshold: number, renderedWidthPx: number): number {
- const clampedWidth = renderedWidthPx > 0 ? renderedWidthPx : VIEWBOX_WIDTH;
- if (clampedWidth <= RANGE_THUMB_WIDTH_PX) {
- return (threshold / 100) * VIEWBOX_WIDTH;
- }
-
- const pct = threshold / 100;
- const thumbRadius = RANGE_THUMB_WIDTH_PX / 2;
- const xPx = thumbRadius + pct * (clampedWidth - RANGE_THUMB_WIDTH_PX);
- return (xPx / clampedWidth) * VIEWBOX_WIDTH;
-}
-
-function pathFromPoints(
- points: CurvePoint[],
- renderedWidthPx: number,
- yMinSec: number,
- yMaxSec: number,
-): string {
- if (points.length === 0) {
- return "";
- }
-
- if (yMaxSec <= yMinSec) {
- return "";
- }
-
- return points
- .map((point, index) => {
- const x = thresholdToSvgX(point.threshold, renderedWidthPx || VIEWBOX_WIDTH);
- const y = durationToSvgY(point.acceptedDurationSec, yMinSec, yMaxSec);
- return `${index === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}`;
- })
- .join(" ");
-}
-
-function durationToSvgY(durationSec: number, minDurationSec: number, maxDurationSec: number): number {
- if (!Number.isFinite(maxDurationSec) || maxDurationSec <= minDurationSec) {
- return VIEWBOX_HEIGHT;
- }
-
- const clampedDuration = Math.max(minDurationSec, Math.min(maxDurationSec, durationSec));
- return VIEWBOX_HEIGHT - ((clampedDuration - minDurationSec) / (maxDurationSec - minDurationSec)) * VIEWBOX_HEIGHT;
-}
-
-function durationDomainForPoints(points: CurvePoint[]): { min: number; max: number } {
- const values = points
- .map((point) => point.acceptedDurationSec)
- .filter((value) => Number.isFinite(value));
-
- if (values.length === 0) {
- return { min: 0, max: 60 };
- }
-
- const min = Math.min(...values);
- const max = Math.max(...values);
-
- if (max <= min) {
- const pad = Math.max(1, max * 0.05);
- return {
- min: Math.max(0, min - pad),
- max: max + pad,
- };
- }
-
- const pad = Math.max(1, (max - min) * 0.08);
- return {
- min: Math.max(0, min - pad),
- max: max + pad,
- };
-}
-
-function formatDurationCompact(totalSeconds: number): string {
- if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
- return "0s";
- }
-
- if (totalSeconds >= 3600) {
- return `${(totalSeconds / 3600).toFixed(1)}h`;
- }
- if (totalSeconds >= 60) {
- return `${(totalSeconds / 60).toFixed(1)}m`;
- }
- return `${Math.round(totalSeconds)}s`;
-}
-
-function formatDurationHms(totalSeconds: number): string {
- const roundedSeconds = Math.max(0, Math.round(totalSeconds));
- const hours = Math.floor(roundedSeconds / 3600);
- const minutes = Math.floor((roundedSeconds % 3600) / 60);
- const seconds = roundedSeconds % 60;
-
- if (hours > 0) {
- return `${hours}h ${minutes}m ${seconds}s`;
- }
- if (minutes > 0) {
- return `${minutes}m ${seconds}s`;
- }
- return `${seconds}s`;
-}
-
-function thresholdFromPointer(clientX: number, element: HTMLDivElement | null, fallbackThreshold: number): number {
- if (!element) {
- return fallbackThreshold;
- }
-
- const rect = element.getBoundingClientRect();
- const thumbRadius = RANGE_THUMB_WIDTH_PX / 2;
- const rawX = clientX - rect.left;
- const pct = (rawX - thumbRadius) / Math.max(1, rect.width - RANGE_THUMB_WIDTH_PX);
- return Math.max(0, Math.min(100, Math.round(pct * 100)));
-}
-
-function pointerToSvgY(clientY: number, frame: HTMLDivElement | null): number | null {
- if (!frame) {
- return null;
- }
-
- const rect = frame.getBoundingClientRect();
- if (rect.height <= 0) {
- return null;
- }
-
- const rawY = clientY - rect.top;
- const clampedY = Math.max(0, Math.min(rect.height, rawY));
- return (clampedY / rect.height) * VIEWBOX_HEIGHT;
-}
-
-export default function QcThresholdImpactCard({
- label,
- threshold,
- points,
- accentClassName,
- emptyLabel,
- explanation,
- onDraftChange,
- onCommit,
-}: QcThresholdImpactCardProps) {
- const maxDurationSec = useMemo(
- () => Math.max(...points.map((point) => point.acceptedDurationSec), 0),
- [points],
- );
- const yDomain = useMemo(() => durationDomainForPoints(points), [points]);
- const { ref: plotAreaRef, width: plotWidth } = useElementWidth();
- const path = useMemo(
- () => pathFromPoints(points, plotWidth, yDomain.min, yDomain.max),
- [points, plotWidth, yDomain.min, yDomain.max],
- );
- const hasData = path.length > 0;
- const thresholdX = thresholdToSvgX(threshold, plotWidth || VIEWBOX_WIDTH);
- const curveFrameRef = useRef(null);
- const rafRef = useRef(null);
- const [hover, setHover] = useState(null);
-
- useEffect(() => {
- return () => {
- if (rafRef.current !== null) {
- window.cancelAnimationFrame(rafRef.current);
- }
- };
- }, []);
-
- const handlePointerMove = (event: React.PointerEvent) => {
- const clientX = event.clientX;
- const clientY = event.clientY;
- if (rafRef.current !== null) {
- window.cancelAnimationFrame(rafRef.current);
- }
-
- rafRef.current = window.requestAnimationFrame(() => {
- const nextThreshold = thresholdFromPointer(clientX, plotAreaRef.current, threshold);
- const point = points[nextThreshold];
- const frameRect = curveFrameRef.current?.getBoundingClientRect();
-
- if (!point || !frameRect || frameRect.width <= 0) {
- setHover(null);
- return;
- }
-
- const rawX = clientX - frameRect.left;
- const clampedX = Math.max(0, Math.min(frameRect.width, rawX));
- const visualX = (clampedX / frameRect.width) * VIEWBOX_WIDTH;
- const y = durationToSvgY(point.acceptedDurationSec, yDomain.min, yDomain.max);
- const pointerY = pointerToSvgY(clientY, curveFrameRef.current);
-
- if (pointerY === null || Math.abs(pointerY - y) > HOVER_LINE_TOLERANCE) {
- setHover(null);
- return;
- }
-
- setHover({
- threshold: nextThreshold,
- x: visualX,
- y,
- leftPercent: (visualX / VIEWBOX_WIDTH) * 100,
- topPercent: (y / VIEWBOX_HEIGHT) * 100,
- });
- });
- };
-
- const hoverPoint = hover ? points[hover.threshold] ?? null : null;
-
- return (
-
-