diff --git a/.dockerignore b/.dockerignore
index c3344205..06c7ce53 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -25,6 +25,13 @@ __pycache__/
dist/
build/
+# Frontend build artifacts and local dependency caches
+webui/.tanstack/
+webui/.vite/
+webui/node_modules/
+webui/test-results/
+webui/static/dist/
+
# Virtual environments
venv/
env/
diff --git a/Dockerfile b/Dockerfile
index 1e2d77b9..a225b8b0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,16 @@
# SoulSync WebUI Dockerfile
# Multi-architecture support for AMD64 and ARM64
+FROM node:24-slim AS webui-builder
+
+WORKDIR /app/webui
+
+COPY webui/package.json webui/package-lock.json ./
+RUN npm ci
+
+COPY webui/ ./
+RUN npm run build
+
# Stage 1: Builder — install Python dependencies with compilation tools
FROM python:3.11-slim AS builder
@@ -54,6 +64,7 @@ RUN useradd --create-home --shell /bin/bash --uid 1000 soulsync
# in tools/), it gets counted twice in the image. Cin caught this on
# 2026-05-08 — see the .dockerignore comment for the same incident.
COPY --chown=soulsync:soulsync . .
+COPY --chown=soulsync:soulsync --from=webui-builder /app/webui/static/dist /app/webui/static/dist
# Create runtime mount-point directories the app expects to exist.
# NOTE: /app/data is for database FILES, /app/database is the Python package
diff --git a/README.md b/README.md
index 771a0bac..f29f5c5f 100644
--- a/README.md
+++ b/README.md
@@ -278,19 +278,38 @@ The template points at `boulderbadgedad/soulsync:latest` (stable) by default. To
```bash
git clone https://github.com/Nezreka/SoulSync
cd SoulSync
-pip install -r requirements.txt
+python -m pip install -r requirements.txt
gunicorn -c gunicorn.conf.py wsgi:application
# Open http://localhost:8008
```
-For local development and tests:
+### Local Development
+
+Use two terminals so the backend and Vite stay independent:
+
+1. Backend
+ ```bash
+ python -m pip install -r requirements-dev.txt
+ gunicorn -c gunicorn.dev.conf.py wsgi:application
+ ```
+ The dev Gunicorn config watches backend files and restarts the Python server when they change.
+2. Frontend
+ ```bash
+ cd webui
+ npm ci
+ npm run dev
+ ```
+ Vite hot reloads the React side when you change webui files.
+
+Run tests separately when needed:
```bash
-pip install -r requirements-dev.txt
-pytest
-gunicorn -c gunicorn.dev.conf.py wsgi:application
+python -m pytest
```
+If you want a convenience launcher, `python dev.py` starts both halves together
+on any OS. `./dev.sh` remains available as a Unix shell wrapper.
+
---
## Setup Guide
@@ -413,17 +432,14 @@ SoulSync uses a `dev` → `main` flow:
2. Branch off `dev`: `git checkout -b fix/your-change dev`
3. Make your changes and commit
4. Push and open a PR against **`dev`** (not `main`)
-5. CI (`build-and-test.yml`) runs ruff lint + compile + pytest on your branch — wait for green
+5. CI (`build-and-test.yml`) runs ruff lint + compile + `python -m pytest` on your branch — wait for green
6. A maintainer reviews and merges
### Running locally
-```bash
-pip install -r requirements-dev.txt
-python -m ruff check . # must be 0 errors
-python -m pytest # all tests must pass
-gunicorn -c gunicorn.dev.conf.py wsgi:application
-```
+Use the [Local Development](#local-development) section above for the full repo-wide setup and the portable dev launcher.
+
+For web UI work, see [webui/README.md](webui/README.md). It keeps the React-side notes close to the app while this file stays the single place for repo-wide dev instructions.
Ruff config lives in `pyproject.toml`. The ruleset is intentionally lenient — it catches real bugs (undefined names, import shadowing, closure-in-loop) without style nits.
diff --git a/core/webui/__init__.py b/core/webui/__init__.py
new file mode 100644
index 00000000..79bc922c
--- /dev/null
+++ b/core/webui/__init__.py
@@ -0,0 +1,19 @@
+"""WebUI delivery helpers."""
+
+from core.webui.assets import (
+ build_webui_vite_assets,
+ clear_webui_vite_manifest_cache,
+ default_static_url_builder,
+ get_webui_vite_manifest_path,
+ load_webui_vite_manifest,
+)
+from core.webui.spa import should_serve_webui_spa
+
+__all__ = [
+ "build_webui_vite_assets",
+ "clear_webui_vite_manifest_cache",
+ "default_static_url_builder",
+ "get_webui_vite_manifest_path",
+ "load_webui_vite_manifest",
+ "should_serve_webui_spa",
+]
diff --git a/core/webui/assets.py b/core/webui/assets.py
new file mode 100644
index 00000000..8c055921
--- /dev/null
+++ b/core/webui/assets.py
@@ -0,0 +1,126 @@
+"""WebUI Vite asset helpers."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+from typing import Any, Callable
+
+from utils.logging_config import get_logger as _create_logger
+
+logger = _create_logger("webui.assets")
+
+DEFAULT_WEBUI_VITE_ENTRY = "src/app/main.tsx"
+DEFAULT_WEBUI_VITE_BASE = "/static/dist/"
+DEFAULT_WEBUI_VITE_DEV_URL = "http://127.0.0.1:5173"
+DEFAULT_WEBUI_VITE_DEV_ENV = "SOULSYNC_WEBUI_VITE_DEV"
+DEFAULT_WEBUI_VITE_URL_ENV = "SOULSYNC_WEBUI_VITE_URL"
+
+_MANIFEST_CACHE: dict[str, tuple[float | None, dict[str, Any]]] = {}
+
+
+def get_webui_vite_manifest_path() -> Path:
+ """Return the generated Vite manifest path inside the repo."""
+ return Path(__file__).resolve().parents[2] / "webui" / "static" / "dist" / ".vite" / "manifest.json"
+
+
+def clear_webui_vite_manifest_cache() -> None:
+ """Clear the in-process manifest cache. Primarily useful for tests."""
+ _MANIFEST_CACHE.clear()
+
+
+def default_static_url_builder(filename: str) -> str:
+ """Build a Flask-style static URL without requiring Flask imports."""
+ return f"/static/{filename.lstrip('/')}"
+
+
+def _env_truthy(value: str | None) -> bool:
+ return (value or "").lower() in {"1", "true", "yes", "on"}
+
+
+def _resolve_dev_mode(dev: bool | None) -> bool:
+ if dev is not None:
+ return bool(dev)
+ return _env_truthy(os.environ.get(DEFAULT_WEBUI_VITE_DEV_ENV))
+
+
+def _resolve_dev_url(dev_url: str | None) -> str:
+ if dev_url:
+ return dev_url.rstrip("/")
+ return os.environ.get(DEFAULT_WEBUI_VITE_URL_ENV, DEFAULT_WEBUI_VITE_DEV_URL).rstrip("/")
+
+
+def load_webui_vite_manifest(manifest_path: str | Path | None = None) -> dict[str, Any]:
+ """Load and cache the generated Vite manifest."""
+ path = Path(manifest_path) if manifest_path else get_webui_vite_manifest_path()
+ cache_key = str(path)
+ manifest_mtime = None
+ if path.exists():
+ try:
+ manifest_mtime = path.stat().st_mtime
+ except OSError:
+ manifest_mtime = None
+
+ cached = _MANIFEST_CACHE.get(cache_key)
+ if cached and cached[0] == manifest_mtime:
+ return cached[1]
+
+ if path.exists():
+ try:
+ with path.open("r", encoding="utf-8") as handle:
+ manifest = json.load(handle)
+ except Exception as exc:
+ logger.warning("Failed to load webui manifest: %s", exc)
+ manifest = {}
+ else:
+ manifest = {}
+
+ _MANIFEST_CACHE[cache_key] = (manifest_mtime, manifest)
+ return manifest
+
+
+def build_webui_vite_assets(
+ placement: str = "body",
+ *,
+ dev: bool | None = None,
+ dev_url: str | None = None,
+ entry: str = DEFAULT_WEBUI_VITE_ENTRY,
+ manifest_path: str | Path | None = None,
+ manifest_loader: Callable[[], dict[str, Any]] | None = None,
+ static_url_builder: Callable[[str], str] | None = None,
+) -> str:
+ """Return HTML tags for the WebUI bundle or dev client."""
+ if placement not in ("head", "body"):
+ return ""
+
+ dev_mode = _resolve_dev_mode(dev)
+ vite_url = _resolve_dev_url(dev_url)
+ static_url = static_url_builder or default_static_url_builder
+
+ if dev_mode:
+ if placement == "head":
+ return ""
+ base = DEFAULT_WEBUI_VITE_BASE.rstrip("/")
+ return "\n".join([
+ f'',
+ f'',
+ ])
+
+ loader = manifest_loader or (lambda: load_webui_vite_manifest(manifest_path))
+ manifest = loader()
+ entry_meta = manifest.get(entry)
+ if not entry_meta:
+ return ""
+
+ if placement == "head":
+ return "\n".join(
+ f''
+ for css_file in entry_meta.get("css", [])
+ )
+
+ entry_file = entry_meta.get("file")
+ if not entry_file:
+ return ""
+
+ return f''
diff --git a/core/webui/spa.py b/core/webui/spa.py
new file mode 100644
index 00000000..d572f262
--- /dev/null
+++ b/core/webui/spa.py
@@ -0,0 +1,23 @@
+"""WebUI SPA routing helpers."""
+
+from __future__ import annotations
+
+EXACT_EXCLUDED_PATHS = {"/callback", "/status"}
+PREFIX_EXCLUDED_PATHS = (
+ "/api",
+ "/auth",
+ "/callback/",
+ "/deezer/",
+ "/socket.io",
+ "/static",
+ "/stream",
+ "/tidal/",
+)
+
+
+def should_serve_webui_spa(pathname: str) -> bool:
+ """Return True when a request path should fall through to the SPA."""
+ normalized = pathname.rstrip("/") or "/"
+ if normalized in EXACT_EXCLUDED_PATHS:
+ return False
+ return not normalized.startswith(PREFIX_EXCLUDED_PATHS)
diff --git a/dev.py b/dev.py
new file mode 100755
index 00000000..95ca24bd
--- /dev/null
+++ b/dev.py
@@ -0,0 +1,348 @@
+#!/usr/bin/env python3
+"""SoulSync development launcher.
+
+Starts the backend and Vite dev server together, restarts the backend when
+backend source files change, and handles shutdown cleanly across platforms.
+"""
+
+from __future__ import annotations
+
+import atexit
+import os
+import shutil
+import signal
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+ROOT_DIR = Path(__file__).resolve().parent
+LOG_DIR = ROOT_DIR / 'logs'
+GUNICORN_CONFIG = ROOT_DIR / 'gunicorn.dev.conf.py'
+VITE_URL = os.environ.get('SOULSYNC_WEBUI_VITE_URL', 'http://127.0.0.1:5173').rstrip('/')
+VITE_LOG_FILE = Path(os.environ.get('SOULSYNC_WEBUI_VITE_LOG', str(LOG_DIR / 'webui-vite.log')))
+
+INCLUDED_SUFFIXES = {'.py', '.html', '.jinja', '.jinja2'}
+SHUTDOWN_GRACE_SECONDS = int(os.environ.get('SOULSYNC_SHUTDOWN_GRACE_SECONDS', '10'))
+FORCE_KILL_ON_SHUTDOWN = os.environ.get('SOULSYNC_FORCE_KILL_ON_SHUTDOWN', '1').lower() in {
+ '1',
+ 'true',
+ 'yes',
+ 'on',
+}
+
+shutdown_requested = False
+managed_processes: list[tuple[str, subprocess.Popen, object | None]] = []
+
+
+def resolve_command(*candidates: str) -> str | None:
+ for candidate in candidates:
+ resolved = shutil.which(candidate)
+ if resolved:
+ return resolved
+ return None
+
+
+def is_excluded(path: Path) -> bool:
+ try:
+ relative = path.relative_to(ROOT_DIR)
+ except ValueError:
+ return False
+
+ parts = relative.parts
+ if not parts:
+ return False
+
+ if any(part == '__pycache__' for part in parts):
+ return True
+ if parts[0] in {'.git', 'logs'}:
+ return True
+ if len(parts) >= 2 and parts[0] == 'webui' and parts[1] == 'node_modules':
+ return True
+ if len(parts) >= 3 and parts[0] == 'webui' and parts[1] == 'static' and parts[2] == 'dist':
+ return True
+ return False
+
+
+def build_backend_env(direct_mode: bool) -> dict[str, str]:
+ env = os.environ.copy()
+ env.setdefault('SOULSYNC_WEB_DEV_NO_CACHE', '1')
+ env.setdefault('SOULSYNC_WEBUI_VITE_DEV', '1')
+ env.setdefault('SOULSYNC_WEBUI_VITE_URL', VITE_URL)
+ env.setdefault('SOULSYNC_WEBUI_VITE_LOG', str(VITE_LOG_FILE))
+ env.setdefault('SOULSYNC_CONFIG_PATH', str(ROOT_DIR / 'config' / 'config.json'))
+
+ if direct_mode:
+ env.setdefault('SOULSYNC_WEB_BIND_HOST', '127.0.0.1')
+ env.setdefault('SOULSYNC_WEB_BIND_PORT', '8008')
+
+ return env
+
+
+def start_process(label: str, cmd: list[str], *, log_file: Path | None = None, env: dict[str, str] | None = None) -> tuple[subprocess.Popen, object | None]:
+ log_handle = None
+ stdout = None
+ stderr = None
+ if log_file is not None:
+ log_file.parent.mkdir(parents=True, exist_ok=True)
+ log_handle = log_file.open('ab')
+ stdout = log_handle
+ stderr = log_handle
+
+ creationflags = 0
+ start_new_session = False
+ if os.name == 'nt':
+ creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
+ else:
+ start_new_session = True
+
+ try:
+ proc = subprocess.Popen(
+ cmd,
+ cwd=str(ROOT_DIR),
+ env=env,
+ stdin=subprocess.DEVNULL,
+ stdout=stdout,
+ stderr=stderr,
+ creationflags=creationflags,
+ start_new_session=start_new_session,
+ )
+ except Exception:
+ if log_handle is not None:
+ log_handle.close()
+ raise
+
+ managed_processes.append((label, proc, log_handle))
+ return proc, log_handle
+
+
+def wait_for_exit(proc: subprocess.Popen, seconds: int) -> bool:
+ checks = max(1, int(seconds * 10))
+ for _ in range(checks):
+ if proc.poll() is not None:
+ return True
+ time.sleep(0.1)
+ return proc.poll() is not None
+
+
+def stop_process(label: str, proc: subprocess.Popen, log_handle: object | None) -> None:
+ if proc.poll() is not None:
+ if log_handle is not None:
+ log_handle.close()
+ return
+
+ print(f'Stopping {label}...')
+
+ try:
+ if os.name == 'nt':
+ proc.terminate()
+ else:
+ os.killpg(proc.pid, signal.SIGTERM)
+ except ProcessLookupError:
+ pass
+
+ if not wait_for_exit(proc, SHUTDOWN_GRACE_SECONDS):
+ if not FORCE_KILL_ON_SHUTDOWN:
+ print(f'{label} did not exit in time; skipping forced kill for this test run.')
+ else:
+ print(f'{label} did not exit in time; forcing shutdown...')
+ if os.name == 'nt':
+ subprocess.run(
+ ['taskkill', '/T', '/F', '/PID', str(proc.pid)],
+ check=False,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ else:
+ try:
+ os.killpg(proc.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ wait_for_exit(proc, 5)
+
+ if log_handle is not None:
+ log_handle.close()
+
+
+def cleanup() -> None:
+ global shutdown_requested
+
+ if shutdown_requested:
+ return
+ shutdown_requested = True
+
+ for label, proc, log_handle in reversed(managed_processes):
+ stop_process(label, proc, log_handle)
+
+
+def compute_backend_watch_state() -> str:
+ rows: list[str] = []
+
+ for dirpath, dirnames, filenames in os.walk(ROOT_DIR):
+ current_dir = Path(dirpath)
+ if is_excluded(current_dir):
+ dirnames[:] = []
+ continue
+
+ dirnames[:] = [
+ name
+ for name in dirnames
+ if not is_excluded(current_dir / name) and name != '__pycache__'
+ ]
+
+ for filename in filenames:
+ path = current_dir / filename
+ if path.suffix not in INCLUDED_SUFFIXES:
+ continue
+ try:
+ stat = path.stat()
+ except FileNotFoundError:
+ continue
+ rows.append(f'{stat.st_mtime_ns} {path}')
+
+ return '\n'.join(sorted(rows))
+
+
+def start_vite() -> subprocess.Popen:
+ npm = resolve_command('npm', 'npm.cmd')
+ if npm is None:
+ raise SystemExit('npm is required to run the Vite dev server.')
+
+ print(f'Starting Vite dev server at {VITE_URL}...')
+ vite_cmd = [
+ npm,
+ '--prefix',
+ str(ROOT_DIR / 'webui'),
+ 'run',
+ 'dev',
+ '--',
+ '--host',
+ '127.0.0.1',
+ '--port',
+ '5173',
+ ]
+ proc, _ = start_process('Vite dev server', vite_cmd, log_file=VITE_LOG_FILE, env=os.environ.copy())
+ return proc
+
+
+def wait_for_vite_ready(vite_proc: subprocess.Popen) -> None:
+ ready_url = f'{VITE_URL}/static/dist/@vite/client'
+ vite_ready = False
+
+ for _ in range(50):
+ if vite_proc.poll() is not None:
+ print('Warning: Vite dev server exited before it became ready.')
+ break
+ try:
+ with urllib.request.urlopen(ready_url, timeout=1) as response:
+ if response.status < 400:
+ vite_ready = True
+ break
+ except (urllib.error.URLError, TimeoutError, OSError):
+ pass
+ time.sleep(0.2)
+
+ if vite_ready:
+ print('Vite dev server is ready.')
+ else:
+ print('Warning: timed out waiting for the Vite dev server.')
+ print('The backend will still start, but the frontend may not hot-reload yet.')
+
+
+def start_backend() -> tuple[subprocess.Popen, object | None]:
+ backend_mode = os.environ.get('SOULSYNC_DEV_BACKEND', '').strip().lower()
+ direct_mode = backend_mode == 'direct'
+ gunicorn_mode = backend_mode == 'gunicorn'
+
+ if not backend_mode:
+ if os.name == 'nt':
+ direct_mode = True
+ elif resolve_command('gunicorn') is None:
+ print('gunicorn not found; falling back to direct Python server.')
+ direct_mode = True
+ else:
+ gunicorn_mode = True
+
+ print('Starting SoulSync web server...')
+
+ if gunicorn_mode:
+ gunicorn = resolve_command('gunicorn')
+ if gunicorn is None:
+ raise SystemExit('gunicorn is not available but SOULSYNC_DEV_BACKEND=gunicorn was requested.')
+ print(f'Using Gunicorn config: {GUNICORN_CONFIG}')
+ cmd = [gunicorn, '-c', str(GUNICORN_CONFIG), 'wsgi:application']
+ else:
+ print('Using direct Python server for backend.')
+ cmd = [sys.executable, str(ROOT_DIR / 'web_server.py')]
+
+ proc, log_handle = start_process(
+ 'SoulSync web server',
+ cmd,
+ env=build_backend_env(direct_mode),
+ )
+ return proc, log_handle
+
+
+def watch_and_run_backend() -> None:
+ last_state = compute_backend_watch_state()
+ backend_proc, backend_log = start_backend()
+
+ try:
+ while not shutdown_requested:
+ time.sleep(1)
+
+ if backend_proc.poll() is not None:
+ print('SoulSync web server exited. Restarting...')
+ stop_process('SoulSync web server', backend_proc, backend_log)
+ managed_processes.pop()
+ backend_proc, backend_log = start_backend()
+ last_state = compute_backend_watch_state()
+ continue
+
+ current_state = compute_backend_watch_state()
+ if current_state != last_state:
+ print('Detected backend file changes. Restarting SoulSync web server...')
+ last_state = current_state
+ stop_process('SoulSync web server', backend_proc, backend_log)
+ managed_processes.pop()
+ backend_proc, backend_log = start_backend()
+ finally:
+ if backend_proc.poll() is None:
+ stop_process('SoulSync web server', backend_proc, backend_log)
+ if managed_processes:
+ managed_processes.pop()
+
+
+def main() -> int:
+ if not (ROOT_DIR / 'webui' / 'node_modules').is_dir():
+ print('webui/node_modules is missing.')
+ print('Run: cd webui && npm ci')
+ return 1
+
+ vite_proc = start_vite()
+ try:
+ wait_for_vite_ready(vite_proc)
+ print(f'Vite log: {VITE_LOG_FILE}')
+ print('Backend file watching is enabled.')
+ watch_and_run_backend()
+ finally:
+ cleanup()
+ return 0
+
+
+def _handle_signal(signum: int, _frame) -> None:
+ raise SystemExit(130 if signum == signal.SIGINT else 143)
+
+
+signal.signal(signal.SIGINT, _handle_signal)
+if hasattr(signal, 'SIGTERM'):
+ signal.signal(signal.SIGTERM, _handle_signal)
+if hasattr(signal, 'SIGBREAK'):
+ signal.signal(signal.SIGBREAK, _handle_signal)
+
+atexit.register(cleanup)
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/dev.sh b/dev.sh
new file mode 100755
index 00000000..fffb87fd
--- /dev/null
+++ b/dev.sh
@@ -0,0 +1,16 @@
+#!/bin/sh
+# Compatibility wrapper for the Python launcher.
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+if command -v python3 >/dev/null 2>&1; then
+ exec python3 "$SCRIPT_DIR/dev.py" "$@"
+fi
+
+if command -v python >/dev/null 2>&1; then
+ exec python "$SCRIPT_DIR/dev.py" "$@"
+fi
+
+echo "Python is required to run the SoulSync dev launcher."
+exit 1
diff --git a/docs/webui-page-migration-analysis.md b/docs/webui-page-migration-analysis.md
new file mode 100644
index 00000000..0d123551
--- /dev/null
+++ b/docs/webui-page-migration-analysis.md
@@ -0,0 +1,267 @@
+# WebUI Page Migration Analysis
+
+Snapshot date: 2026-05-02
+
+## Summary
+- The shell route manifest now has 18 page ids.
+- `issues` is still the only React-owned route.
+- Since the last snapshot, the biggest changes are:
+ - `downloads` was renamed into `search`.
+ - The live queue became `active-downloads`.
+ - `watchlist` and `wishlist` became full sidebar pages.
+ - `tools` was split off from `dashboard`.
+ - `artists` is no longer a route id.
+- The shell is also more modular now. The old monolithic `script.js` has been split across `core.js`, `init.js`, `shared-helpers.js`, and feature modules such as `search.js`, `api-monitor.js`, `pages-extra.js`, `stats-automations.js`, and `wishlist-tools.js`.
+- Current profile compatibility still normalizes old `downloads` and `artists` references to `search`, so the docs and the route ids are not always using the same historical language.
+
+## What Changed Since The Last Snapshot
+- `search` is now the canonical route for the old download/search experience.
+- `active-downloads` owns the dedicated live queue that used to sit inside the search flow.
+- `watchlist` and `wishlist` moved out of dashboard-era chrome and into their own routes.
+- `tools` moved off the dashboard into a dedicated sidebar page.
+- `dashboard` is a bit narrower now, because several operational surfaces were split out.
+- `artist-detail` is still a first-class route, but its permission relationship is now tied to `library` and `search`, not to an `artists` page.
+- The contextual help system still contains some historical `downloads` and `artists` wording, so those labels should be treated as legacy text rather than route ids.
+
+## Current Architecture
+- `webui/index.html` still hosts the Flask-rendered shell, the sidebar, the media player, the legacy `.page` containers, and the React mount point.
+- `webui/static/core.js` now holds a lot of the shared global state that used to live in the old monolith.
+- `webui/static/init.js` still owns page activation, permission gating, nav highlighting, legacy routing, and the `window.SoulSyncWebRouter` bridge.
+- `webui/static/shell-bridge.js` and the TanStack Router adapter still decide whether a route is handled by the React host or handed back to the legacy shell.
+- `issues` remains the reference pattern for React-owned pages: route manifest ownership, shell bridge integration, route-local data loading, and detail-modal behavior all live in the React subtree.
+- The legacy shell is now spread across feature modules rather than one giant coordinator file, which makes the migration seams a little clearer than they were a month ago.
+
+### Route and Compatibility Notes
+- Manifest page ids: `dashboard`, `sync`, `search`, `discover`, `playlist-explorer`, `watchlist`, `wishlist`, `automations`, `active-downloads`, `library`, `tools`, `artist-detail`, `stats`, `import`, `settings`, `issues`, `help`, `hydrabase`.
+- `downloads` and `artists` are no longer manifest ids.
+- HTML `.page` containers exist for every legacy page plus `webui-react-root` for React.
+- `watchlist`, `wishlist`, and `active-downloads` are now standalone route targets instead of dashboard overlays.
+- `tools` is now a dedicated page, so dashboard can be treated as a monitoring hub instead of the one-stop maintenance surface.
+- `help` and `issues` remain always-allowed for non-admins.
+- `settings` remains admin-only.
+- `artist-detail` is allowed when the profile can access `library` or `search`.
+
+## Cross-Cutting Features
+- Profile and permission routing still live in the shell bootstrap.
+- Shell chrome and nav highlighting are still shared shell responsibilities.
+- Media player behavior, queue handling, and global overlays still cut across multiple pages.
+- Socket/WebSocket and polling behavior remain the biggest migration risks for live pages.
+- The help system, tours, and helper annotations still reference some historical route names, so route-migration work should use the manifest as the source of truth.
+- Visual effects such as `particles.js` and `worker-orbs.js` remain shell-global.
+
+## Scoring Rubric
+Each page is scored from 1 to 5 on five axes:
+
+- Rendering surface size: HTML/UI area and number of distinct render states
+- State/coupling complexity: amount of local state plus coupling to other pages or shell-global state
+- Async/realtime complexity: fetch fan-out, polling, WebSocket/live progress, streaming, or long-running workflows
+- Cross-cutting shell dependency: reliance on shared shell behaviors, globals, overlays, or non-route contracts
+- Testability/parity difficulty: how hard it is to prove route parity without regressions
+
+Rollups:
+
+- Migration effort
+ - `Low`: total score 9-11
+ - `Medium`: total score 12-17
+ - `High`: total score 18-21
+ - `Very High`: total score 22-25
+- Regression risk
+ - `Low`: mostly isolated UI with limited async and minimal shell coupling
+ - `Medium`: moderate data flow or workflow complexity with bounded blast radius
+ - `High`: broad coupling, many async states, or sensitive user workflows
+
+## Summary Matrix
+
+| Page | Owner | Scores (R/S/A/C/T) | Effort | Risk | Recommended Wave |
+| --- | --- | --- | --- | --- | --- |
+| `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 0 |
+| `help` | Legacy | 3 / 2 / 1 / 1 / 2 | Low | Low | Wave 1 |
+| `hydrabase` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 |
+| `stats` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 |
+| `import` | Legacy | 3 / 3 / 3 / 2 / 3 | Medium | Medium | Wave 1 |
+| `search` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 2 |
+| `watchlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 |
+| `wishlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 |
+| `active-downloads` | Legacy | 3 / 4 / 4 / 3 / 4 | High | High | Wave 4 |
+| `tools` | Legacy | 4 / 4 / 4 / 4 / 4 | High | High | Wave 4 |
+| `dashboard` | Legacy | 4 / 4 / 4 / 4 / 4 | High | High | Wave 5 |
+| `discover` | Legacy | 5 / 5 / 4 / 4 / 5 | Very High | High | Wave 6 |
+| `playlist-explorer` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 7 |
+| `library` | Legacy | 4 / 5 / 4 / 4 / 5 | Very High | High | Wave 8 |
+| `artist-detail` | Legacy | 5 / 5 / 4 / 5 / 5 | Very High | High | Wave 8 |
+| `sync` | Legacy | 5 / 5 / 5 / 4 / 5 | Very High | High | Wave 9 |
+| `settings` | Legacy | 5 / 5 / 4 / 5 / 5 | Very High | High | Wave 10 |
+| `automations` | Legacy | 4 / 5 / 4 / 3 / 4 | High | High | Wave 10 |
+
+## Page Catalog
+
+### Wave 0: Baseline
+
+#### `issues`
+- Current owner: React.
+- Primary files: `webui/src/routes/issues/*`, `webui/src/platform/shell/*`, `webui/src/app/router.tsx`.
+- Main surface: counts cards, filtered issue list, issue-detail modal, mutation flows.
+- Key coupling: shell page gating, shell nav badge refresh, bridge-controlled chrome, React Query cache.
+- Why it stays first: it is already the canonical React route pattern and the migration baseline.
+
+### Wave 1: Safest wins
+
+#### `help`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/docs.js`, `webui/static/helper.js`.
+- Main surface: docs navigation, long-form sections, screenshots, lightbox behavior.
+- Key coupling: mostly shell chrome and docs deep linking.
+- Recommendation: still one of the safest early migrations, but keep the helper system shell-owned for now.
+
+#### `hydrabase`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/init.js`.
+- Main surface: connection state, saved credentials, peer count, comparison list.
+- Key coupling: profile gating and a small amount of shell state.
+- Recommendation: low-risk route with a narrow surface.
+
+#### `stats`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/stats-automations.js`.
+- Main surface: listening stats, charts, ranked lists, database storage visualization.
+- Key coupling: chart rendering, some deep links back into library routes.
+- Recommendation: early migration candidate with good parity-test potential.
+
+#### `import`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/stats-automations.js`, `webui/static/helper.js`.
+- Main surface: staging files, album and singles matching, suggestion cards, processing queue.
+- Key coupling: settings-derived staging path assumptions and downstream library state.
+- Recommendation: still bounded enough for an early wave, though more workflow-heavy than `help` or `stats`.
+
+### Wave 2: Search split
+
+#### `search`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/search.js`, `webui/static/downloads.js`, `webui/static/shared-helpers.js`.
+- Main surface: basic search, enhanced search, source picker, fallback banner, download launch flow.
+- Key coupling: global search widget parity, shared search controller, download modal handoff, legacy DOM ids that still say `downloads`.
+- Recommendation: this is the renamed download/search surface, so it should be treated as a distinct migration from the queue view, not as the old monolith.
+
+### Wave 3: Watchlist pair
+
+#### `watchlist`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/api-monitor.js`, `webui/static/helper.js`.
+- Main surface: watched-artist grid, per-artist config, scan status, global override banner, bulk actions.
+- Key coupling: discovery and wishlist generation, scan polling, per-profile access rules.
+- Recommendation: good mid-complexity route once the shell bridge and route-local data patterns are stable.
+
+#### `wishlist`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/api-monitor.js`, `webui/static/wishlist-tools.js`, `webui/static/helper.js`.
+- Main surface: track queue, cycle state, live processing, nebula visualization, countdown timers.
+- Key coupling: watchlist scans, playlist sync handoff, download processing, live polling.
+- Recommendation: visually distinctive but still bounded enough to follow `watchlist` in the same program wave.
+
+### Wave 4: Operational split
+
+#### `active-downloads`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/pages-extra.js`.
+- Main surface: centralized live download list, status filters, batch grouping, batch history, cancellation controls.
+- Key coupling: polling every few seconds, download batch hydration, nav badge counts, server-side download state.
+- Recommendation: the old embedded queue moved here, so this page should be treated as the queue sibling of `search`.
+
+#### `tools`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/wishlist-tools.js`, `webui/static/stats-automations.js`, `webui/static/helper.js`.
+- Main surface: database updater, metadata updater, quality scan, duplicate clean, retag, backups, maintenance sections.
+- Key coupling: lots of operational actions and several background jobs, but less dashboard chrome than before.
+- Recommendation: split-off from the dashboard, but still operational enough to stay in a later wave.
+
+### Wave 5: Monitoring hub
+
+#### `dashboard`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/init.js`, `webui/static/wishlist-tools.js`, `webui/static/api-monitor.js`, `webui/static/worker-orbs.js`.
+- Main surface: service cards, enrichment workers, library status, recent syncs, system stats, activity feed, quick nav.
+- Key coupling: almost every global subsystem eventually shows up here.
+- Recommendation: narrower than the old snapshot because tools moved out, but still one of the central shell surfaces.
+
+### Wave 6: Broad discovery surface
+
+#### `discover`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/discover.js`, `webui/static/helper.js`.
+- Main surface: hero carousel, recent releases, genre browser, decade browser, similar artists, seasonal picks.
+- Key coupling: watchlist-derived recommendations, discovery pool, download handoffs, many semi-independent sections.
+- Recommendation: broad rendering surface and heavy fetch fan-out make this a high-risk migration.
+
+### Wave 7: Visual interaction route
+
+#### `playlist-explorer`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/wishlist-tools.js`, `webui/static/helper.js`.
+- Main surface: playlist cards, discovery tree, selection model, zoom/pan interactions, wishlist submission.
+- Key coupling: document-level pointer handling, discovery workflow, artist navigation.
+- Recommendation: interactive enough to wait until the team is comfortable migrating richer stateful views.
+
+### Wave 8: Library stack
+
+#### `library`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/library.js`, `webui/static/helper.js`.
+- Main surface: searchable artist grid, watchlist filters, pagination, download bubbles, deep links to detail.
+- Key coupling: tightly bound to `artist-detail`, watchlist systems, and library-wide expectations.
+- Recommendation: should be migrated alongside the detail route, not as an isolated quick win.
+
+#### `artist-detail`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/library.js`, `webui/static/downloads.js`, `webui/static/helper.js`.
+- Main surface: hero section, discography views, bulk operations, inline editing, tag writing, reorganize, quality actions.
+- Key coupling: explicitly coupled to `library`, plus downloads, playback, metadata services, and file-organization settings.
+- Recommendation: treat this as part of the library stack and keep it out of early waves.
+
+### Wave 9: Multi-source orchestration
+
+#### `sync`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/sync-spotify.js`, `webui/static/sync-services.js`, `webui/static/wishlist-tools.js`.
+- Main surface: mirrored playlists, source tabs, discovery, sync history, playlist import flows, M3U export.
+- Key coupling: the heaviest async orchestration in the app, with long-running workflows and state rehydration.
+- Recommendation: one of the last major migrations.
+
+### Wave 10: Final complex authoring/admin routes
+
+#### `settings`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/settings.js`, `webui/static/helper.js`.
+- Main surface: service credentials, download config, quality settings, file organization, appearance, advanced settings.
+- Key coupling: almost every other page depends on settings-derived behavior or stored configuration.
+- Recommendation: late migration because the blast radius is very large.
+
+#### `automations`
+- Current owner: Legacy.
+- Primary files: `webui/index.html`, `webui/static/stats-automations.js`, `webui/static/helper.js`.
+- Main surface: automation list, visual builder, run history, one-click hub groups, config editing.
+- Key coupling: nested editable state, polling, run/deploy/toggle flows, and other system-level actions.
+- Recommendation: save this for the final wave with the other complex authoring surfaces.
+
+## Platform Unlocks
+- `search` likely unlocks reusable search-controller and download-launch primitives for the global search widget and other search entry points.
+- `watchlist` likely unlocks artist-card, per-artist config, and scan-status primitives for `discover` and `wishlist`.
+- `wishlist` likely unlocks queue/cycle visualization, live polling, and retry-state handling for `active-downloads` and sync-driven download flows.
+- `active-downloads` likely unlocks batch grouping, queue filtering, and cancellation patterns for other download-related surfaces.
+- `tools` likely unlocks maintenance-card and operational-action patterns that can be reused from `dashboard`.
+- `library` + `artist-detail` still unlock entity-detail patterns, bulk actions, and file-management workflows.
+
+## Why Earlier Waves Are Safer
+- Wave 1 routes are either mostly static or bounded data UIs with limited cross-route side effects.
+- Wave 2 adds the renamed search surface without dragging in the full queue history.
+- Wave 3 introduces the new watchlist/wishlist split, which is important but still narrower than discovery or library management.
+- Wave 4 adds the live queue and tools split once the route-local patterns are already in place.
+- Wave 5 keeps the dashboard after its maintenance responsibilities have been peeled away.
+- Waves 6-10 defer the broadest, most coupled, or most orchestration-heavy surfaces until the team has the most leverage.
+
+## Final Recommendation
+- Keep `issues` as the reference implementation and preserve the existing bridge contract.
+- Treat `search`, `watchlist`, `wishlist`, `active-downloads`, and `tools` as the current route ids, and keep `downloads` and `artists` only as compatibility history.
+- Migrate the safe routes first: `help`, `hydrabase`, `stats`, and `import`.
+- Use `search` as the next meaningful proving ground now that the download queue has been split out.
+- Avoid pulling `settings`, `sync`, `library`, `artist-detail`, or `automations` forward unless there is a separate product priority strong enough to justify the added regression risk.
diff --git a/gunicorn.dev.conf.py b/gunicorn.dev.conf.py
index 5bf2132e..43068ff3 100644
--- a/gunicorn.dev.conf.py
+++ b/gunicorn.dev.conf.py
@@ -1,11 +1,27 @@
"""Gunicorn configuration for local development."""
+from pathlib import Path
+import os
+
bind = "127.0.0.1:8008"
worker_class = "gthread"
workers = 1
threads = 4
reload = True
-raw_env = ["SOULSYNC_WEB_DEV_NO_CACHE=1"]
+
+_ROOT_DIR = Path(__file__).resolve().parent
+_VITE_URL = os.environ.get('SOULSYNC_WEBUI_VITE_URL', 'http://127.0.0.1:5173').rstrip('/')
+_VITE_LOG = os.environ.get('SOULSYNC_WEBUI_VITE_LOG', str(_ROOT_DIR / 'logs' / 'webui-vite.log'))
+
+# Dev Gunicorn config and Vite dev server are paired on purpose.
+raw_env = [
+ "SOULSYNC_WEB_DEV_NO_CACHE=1",
+ "SOULSYNC_WEBUI_VITE_DEV=1",
+ f"SOULSYNC_CONFIG_PATH={os.environ.get('SOULSYNC_CONFIG_PATH', str(_ROOT_DIR / 'config' / 'config.json'))}",
+ f"SOULSYNC_LOG_LEVEL={os.environ.get('SOULSYNC_LOG_LEVEL', '')}",
+ f"SOULSYNC_WEBUI_VITE_URL={_VITE_URL}",
+ f"SOULSYNC_WEBUI_VITE_LOG={_VITE_LOG}",
+]
# Keep requests from hanging forever on slow external services.
timeout = 120
diff --git a/tests/webui/test_assets.py b/tests/webui/test_assets.py
new file mode 100644
index 00000000..21660899
--- /dev/null
+++ b/tests/webui/test_assets.py
@@ -0,0 +1,96 @@
+"""Tests for the WebUI asset helpers."""
+
+from __future__ import annotations
+
+import json
+import os
+import time
+
+import pytest
+
+from core.webui import (
+ build_webui_vite_assets,
+ clear_webui_vite_manifest_cache,
+ load_webui_vite_manifest,
+ should_serve_webui_spa,
+)
+
+
+def test_build_webui_vite_assets_renders_dev_scripts():
+ html = build_webui_vite_assets("body", dev=True, dev_url="http://127.0.0.1:5173")
+ assert html == (
+ '\n'
+ ''
+ )
+
+
+def test_build_webui_vite_assets_renders_manifest_assets():
+ manifest = {
+ "src/app/main.tsx": {
+ "css": ["assets/main.css"],
+ "file": "assets/main.js",
+ }
+ }
+
+ html_head = build_webui_vite_assets(
+ "head",
+ manifest_loader=lambda: manifest,
+ static_url_builder=lambda filename: f"/assets/{filename}",
+ )
+ html_body = build_webui_vite_assets(
+ "body",
+ manifest_loader=lambda: manifest,
+ static_url_builder=lambda filename: f"/assets/{filename}",
+ )
+
+ assert html_head == ''
+ assert html_body == ''
+
+
+@pytest.mark.parametrize(
+ "pathname",
+ [
+ "/api/issues",
+ "/auth/spotify",
+ "/callback",
+ "/callback/extra",
+ "/deezer/callback",
+ "/socket.io",
+ "/static/app.js",
+ "/stream/file",
+ "/tidal/callback",
+ "/status",
+ ],
+)
+def test_should_serve_webui_spa_blocks_reserved_paths(pathname):
+ assert should_serve_webui_spa(pathname) is False
+
+
+@pytest.mark.parametrize(
+ "pathname",
+ [
+ "/",
+ "/issues",
+ "/issues?issueId=7",
+ "/artists/Opeth",
+ "/discover",
+ ],
+)
+def test_should_serve_webui_spa_allows_client_routes(pathname):
+ assert should_serve_webui_spa(pathname) is True
+
+
+def test_load_webui_vite_manifest_reloads_when_file_changes(tmp_path):
+ clear_webui_vite_manifest_cache()
+ manifest_path = tmp_path / "manifest.json"
+ manifest_path.write_text(json.dumps({"src/app/main.tsx": {"file": "assets/one.js"}}))
+
+ first = load_webui_vite_manifest(manifest_path)
+ assert first["src/app/main.tsx"]["file"] == "assets/one.js"
+
+ manifest_path.write_text(json.dumps({"src/app/main.tsx": {"file": "assets/two.js"}}))
+ future = time.time() + 10
+ os.utime(manifest_path, (future, future))
+
+ second = load_webui_vite_manifest(manifest_path)
+ assert second["src/app/main.tsx"]["file"] == "assets/two.js"
diff --git a/web_server.py b/web_server.py
index 354c7ca9..db0d01f9 100644
--- a/web_server.py
+++ b/web_server.py
@@ -20,7 +20,7 @@ from pathlib import Path
from urllib.parse import urljoin, urlparse
from concurrent.futures import ThreadPoolExecutor, as_completed
-from flask import Flask, render_template, request, jsonify, redirect, send_file, send_from_directory, Response, session, g, abort
+from flask import Flask, abort, render_template, request, jsonify, redirect, send_file, send_from_directory, Response, session, g
from flask_socketio import SocketIO, emit, join_room, leave_room
from utils.logging_config import get_logger, setup_logging
from utils.async_helpers import run_async
@@ -97,6 +97,7 @@ from core.metadata.cache import get_metadata_cache
from core.metadata import registry as metadata_registry
from core.metadata import is_internal_image_host
from core.metadata import normalize_image_url as fix_artist_image_url
+from core.webui import build_webui_vite_assets, should_serve_webui_spa
from core.metadata.registry import (
clear_cached_metadata_client,
get_metadata_source_label,
@@ -292,13 +293,12 @@ app.jinja_env.auto_reload = DEV_STATIC_NO_CACHE
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 if DEV_STATIC_NO_CACHE else 31536000
-# Cache-bust query string for static assets — appended to every
-# url_for('static', ...) URL via the context processor below. Computed
-# once per process start so each server restart invalidates the
-# browser's cached copy of every JS/CSS file. This is the surefire
-# fix for "user has stale JS even after Ctrl+Shift+R" — the URL
-# itself changes, so the browser cannot reuse a previously-cached
-# response no matter what its Cache-Control header said.
+# Cache-bust query string for static assets. Computed once per process
+# start so each server restart invalidates the browser's cached copy of
+# every JS/CSS file. This is the surefire fix for "user has stale JS
+# even after Ctrl+Shift+R" — the URL itself changes, so the browser
+# cannot reuse a previously-cached response no matter what its
+# Cache-Control header said.
import time as _cache_bust_time
_STATIC_CACHE_BUST = str(int(_cache_bust_time.time()))
@@ -371,6 +371,11 @@ def _log_rejected_socketio_origin():
request.headers.get('X-Forwarded-Proto', ''),
)
+@app.context_processor
+def inject_webui_assets():
+ return {
+ 'vite_assets': build_webui_vite_assets,
+ }
# --- Profile Context (before_request hook) ---
@app.before_request
@@ -514,7 +519,26 @@ def get_spotify_client_for_profile(profile_id=None):
return metadata_registry.get_spotify_client_for_profile(profile_id)
# Valid page IDs for profile permission validation
-VALID_PAGE_IDS = {'dashboard', 'sync', 'search', 'downloads', 'discover', 'artists', 'automations', 'library', 'import', 'settings', 'help'}
+VALID_PAGE_IDS = {
+ 'dashboard',
+ 'sync',
+ 'search',
+ 'discover',
+ 'playlist-explorer',
+ 'watchlist',
+ 'wishlist',
+ 'automations',
+ 'active-downloads',
+ 'library',
+ 'tools',
+ 'artist-detail',
+ 'stats',
+ 'import',
+ 'settings',
+ 'help',
+ 'hydrabase',
+ 'issues',
+}
def check_download_permission():
"""Check if current profile has download permission. Returns error response or None if allowed."""
@@ -3325,9 +3349,9 @@ def service_worker():
@app.route('/')
def spa_catch_all(page):
# Serve index.html for client-side routes; let Flask handle real routes first.
- if page.startswith(('api/', 'static/', 'auth/', 'callback', 'deezer/', 'tidal/', 'status')):
+ if not should_serve_webui_spa(f'/{page}'):
abort(404)
- return render_template('index.html')
+ return index()
# --- API Endpoints ---
@@ -35798,8 +35822,11 @@ def start_runtime_services():
# Direct execution: python web_server.py (dev/Windows fallback)
# Production should use: gunicorn -c gunicorn.conf.py wsgi:application
if _DIRECT_RUN:
+ web_run_host = os.environ.get('SOULSYNC_WEB_BIND_HOST', '0.0.0.0')
+ web_run_port = int(os.environ.get('SOULSYNC_WEB_BIND_PORT', '8008'))
+ display_host = '127.0.0.1' if web_run_host in {'0.0.0.0', '::'} else web_run_host
logger.info("Starting SoulSync Web UI Server...")
- logger.info("Open your browser and navigate to http://127.0.0.1:8008")
+ logger.info(f"Open your browser and navigate to http://{display_host}:{web_run_port}")
logger.info("Tip: For production, use gunicorn -c gunicorn.conf.py wsgi:application")
start_runtime_services()
- socketio.run(app, host='0.0.0.0', port=8008, debug=False, allow_unsafe_werkzeug=True)
+ socketio.run(app, host=web_run_host, port=web_run_port, debug=False, allow_unsafe_werkzeug=True)
diff --git a/webui/.gitignore b/webui/.gitignore
new file mode 100644
index 00000000..1f36028b
--- /dev/null
+++ b/webui/.gitignore
@@ -0,0 +1,4 @@
+.vite
+node_modules
+static/dist
+test-results
diff --git a/webui/.oxfmtrc.json b/webui/.oxfmtrc.json
new file mode 100644
index 00000000..80abf596
--- /dev/null
+++ b/webui/.oxfmtrc.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "./node_modules/oxfmt/configuration_schema.json",
+ "ignorePatterns": ["src/routeTree.gen.ts"],
+ "singleQuote": true,
+ "sortImports": {
+ "groups": [
+ "type-import",
+ ["value-builtin", "value-external"],
+ "type-internal",
+ "value-internal",
+ ["type-parent", "type-sibling", "type-index"],
+ ["value-parent", "value-sibling", "value-index"],
+ "unknown"
+ ]
+ },
+ "sortPackageJson": true,
+ "trailingComma": "all"
+}
diff --git a/webui/.oxlintrc.json b/webui/.oxlintrc.json
new file mode 100644
index 00000000..daa31144
--- /dev/null
+++ b/webui/.oxlintrc.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "ignorePatterns": ["src/routeTree.gen.ts"],
+ "options": {
+ "typeAware": true,
+ "typeCheck": true
+ }
+}
diff --git a/webui/README.md b/webui/README.md
new file mode 100644
index 00000000..d32587c2
--- /dev/null
+++ b/webui/README.md
@@ -0,0 +1,138 @@
+# WebUI Hybrid Rendering
+
+SoulSync's web UI is in a transition phase:
+
+- most pages still render through the legacy vanilla JS shell
+- `/issues` is rendered by the new React app
+- a small shell bridge keeps both runtimes aware of the active page, profile context, and navigation state
+
+## How It Fits Together
+
+```mermaid
+flowchart LR
+ Browser["Browser parses /webui/index.html"]
+ Legacy["Legacy shell scripts\n(core.js -> ... -> init.js)"]
+ Bridge["shell-bridge.js\nwindow.SoulSyncWebShellBridge"]
+ React["Vite React app\nsrc/app/main.tsx"]
+ Router["TanStack Router\nwindow.SoulSyncWebRouter"]
+
+ Browser --> Legacy
+ Browser --> React
+ Legacy --> Bridge
+ React --> Router
+ Router --> Bridge
+ Bridge --> Legacy
+```
+
+## Runtime Roles
+
+- `webui/static/init.js`
+ - boots the legacy shell
+ - selects the active profile
+ - handles the legacy page loading flow
+
+- `webui/static/shell-bridge.js`
+ - owns the browser-side bridge object
+ - exposes `window.SoulSyncWebShellBridge`
+ - owns the shared page chrome and route handoff helpers
+
+- `webui/src/app/main.tsx`
+ - mounts the React app
+ - binds `window.SoulSyncWebRouter`
+
+- `webui/src/platform/shell/route-controllers.tsx`
+ - listens for bridge readiness
+ - keeps React pages aligned with the shell
+
+## Load Order
+
+The current order in `index.html` matters:
+
+1. legacy shell scripts load first
+2. `init.js` sets up the shell runtime
+3. `shell-bridge.js` publishes the bridge and shared chrome helpers after the shell state exists
+4. the Vite React app is injected through `{{ vite_assets('body') }}` and boots as a module after parsing
+
+That order avoids load-time references to missing globals and keeps the React side able to react to bridge readiness events. The React entry can start fetching early, but the shell bridge and legacy globals are already available by the time the React runtime starts acting on them.
+
+## Notes
+
+- The bridge is intentionally small and browser-only.
+- This is the start of the migration, not a full replacement of the legacy shell.
+- When adding another React page, check whether it needs:
+ - a route entry in `webui/src/platform/shell/route-manifest.ts`
+ - bridge typings in `webui/src/platform/shell/globals.d.ts`
+ - a legacy fallback path in `webui/static/init.js`
+ - bridge glue or handoff logic in `webui/static/shell-bridge.js`
+
+## Folder Layout
+
+The React webui uses a small set of predictable folders so route slices stay easy to extend,
+test, and understand.
+
+```text
+webui/src/
+ app/ React bootstrap, router, query client, shared API client
+ components/ Shared UI primitives
+ platform/ Shell bridge and browser/platform integration
+ routes/ Route-local code and TanStack Router pages
+ test/ Shared test utilities and setup helpers
+```
+
+### Route Slices
+
+- Keep route-specific code inside `webui/src/routes//`.
+- Put the routing entry in `route.tsx`.
+- Put route-local UI in a `-ui/` folder.
+- Prefix non-routing files with `-` so TanStack Router ignores them.
+- Keep the route slice small and cohesive.
+- Prefer a few files with clear responsibilities over many tiny files with overlapping names.
+
+Example:
+
+```text
+webui/src/routes/issues/
+ route.tsx
+ -issues.types.ts
+ -issues.api.ts
+ -issues.helpers.ts
+ -issues.api.test.ts
+ -issues.helpers.test.ts
+ -ui/
+ issues-page.tsx
+ issue-detail-modal.tsx
+ issue-domain-host.tsx
+```
+
+The initial `issues` slice is the model to follow:
+
+- `-issues.api.ts` holds request code and query options
+- `-issues.helpers.ts` holds pure normalization and formatting
+- `-issues.types.ts` holds shared types
+- `-ui/` holds the page, modal, and legacy handoff UI
+
+### Shared Code
+
+- Put reusable UI in `webui/src/components/`.
+- Put shell integration in `webui/src/platform/`.
+- Put bootstrap and app-wide wiring in `webui/src/app/`.
+- Move code up a level only when it is genuinely shared.
+- Avoid creating new conventions that overlap with existing ones.
+
+### Testing Choices
+
+We have a lot of testing tools available, but we do not need all of them for every feature.
+
+- Use plain unit tests for pure functions and small transforms.
+- Use React component or route tests when the behavior lives in the UI or router.
+- Use MSW-backed tests when request shape, response handling, or error handling matters.
+- Use Playwright when the behavior is best proven end-to-end with the server and browser together.
+- Prefer the smallest test setup that still proves the thing that can regress.
+
+## Development
+
+The repo root now owns the full local-dev instructions. Start there for the
+portable launcher and backend/frontend setup:
+
+1. [README.md](../README.md) for the end-to-end dev flow
+2. `npm run check` and `npm run fix` for React-side linting and formatting
diff --git a/webui/index.html b/webui/index.html
index 01e98b77..5c1a431d 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -12,6 +12,7 @@
+ {{ vite_assets('head')|safe }}
@@ -103,6 +104,8 @@
+
+
@@ -117,6 +120,8 @@
+
+
@@ -189,7 +194,7 @@