",
+ "css": ".rail { display: flex; align-items: center; gap: var(--space-6); padding: var(--space-3) var(--space-6); border-bottom: 1px solid var(--line); background: var(--panel); box-shadow: inset 0 1px var(--highlight); }"
+ }
+ ],
+ "narrative": {
+ "northStar": "Master Control",
+ "overview": "arr's UI is a broadcast master-control panel: the daemon is a signal chain (TMDB and Prowlarr feed in, Transmission is driven out) and every surface reads as lamps, readouts and traces on that chain. Near-black charcoal ground, machined panels with hairline borders and a 1px inset highlight, Rajdhani display names, monospace readouts, three semantic lamp hues plus one phosphor-cyan interactive hue. Committed dark. Built for the operator's one-glance question: can arr do its job, and if not, which upstream is down.",
+ "keyCharacteristics": [
+ "Signal chain, not dashboard: topology mirrors DESIGN.md §3; no sidebar, no stat cards, no hero metric.",
+ "Lamps carry state, words carry proof — every hue is paired with a mono status word.",
+ "Rajdhani 700 uppercase for names, system sans for micro-labels, mono for every value.",
+ "Panels are machined: hairline border, inset highlight, 4px radius; never soft shadows or glass.",
+ "One authored motion: the power-up strike, upstream to downstream, once."
+ ],
+ "rules": [
+ {
+ "name": "The Lamp-Hue Rule",
+ "body": "Green/amber/red belong to state and to nothing else. Interaction is phosphor-cyan. A saturated color that is neither a state nor an interactive affordance is a bug.",
+ "section": "colors"
+ },
+ {
+ "name": "The Word-Beside-The-Lamp Rule",
+ "body": "Every lamp and lit trace is paired with a monospace status word. Remove all color and the board must still read.",
+ "section": "colors"
+ },
+ {
+ "name": "The Readout Rule",
+ "body": "Monospace is for values the operator reads — statuses, sizes, latencies, versions. Prose and labels are never mono; names are never sans.",
+ "section": "typography"
+ },
+ {
+ "name": "The Machined-Panel Rule",
+ "body": "Depth is one inset 1px highlight on a hairline-bordered panel. No drop shadows, no glass, no gradients. The only glow on the board is a lamp's own light.",
+ "section": "elevation"
+ },
+ {
+ "name": "The Dead-Air Rule",
+ "body": "When nothing is flowing, nothing animates. Pulse means probing; data-state=\"off\" is dark and still. Motion that misreports activity is a truth bug.",
+ "section": "motion"
+ }
+ ],
+ "dos": [
+ "Do lay future surfaces (manual search buckets, queues) out as panels and readouts consuming these tokens; chips in §9.3 are small panels with mono values.",
+ "Do keep every control ≥44px, focus rings cyan and visible, and reduced-motion honored.",
+ "Do keep muted text at or above 4.5:1 on panel ground (--ink-muted floor).",
+ "Do self-host every asset; the SPA is embedded in the binary and must work with no network."
+ ],
+ "donts": [
+ "Don't introduce a second interactive hue or use cyan for a status.",
+ "Don't box content in soft-shadowed rounded cards or add glass/blur.",
+ "Don't put a kicker/eyebrow label above a heading.",
+ "Don't animate idle or dead elements; the strike runs once, on real data."
+ ]
+ }
+}
diff --git a/Cargo.lock b/Cargo.lock
index a38c007..c1b9880 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -78,6 +78,8 @@ dependencies = [
"arr-db",
"arr-meta",
"axum",
+ "include_dir",
+ "mime_guess",
"reqwest",
"serde",
"sqlx",
@@ -1022,6 +1024,25 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "include_dir"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
+dependencies = [
+ "include_dir_macros",
+]
+
+[[package]]
+name = "include_dir_macros"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
+dependencies = [
+ "proc-macro2",
+ "quote",
+]
+
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -1171,6 +1192,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
[[package]]
name = "mio"
version = "1.2.2"
@@ -2420,6 +2451,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
[[package]]
name = "unicode-bidi"
version = "0.3.18"
diff --git a/Justfile b/Justfile
index f8b2d6b..cc041a1 100644
--- a/Justfile
+++ b/Justfile
@@ -38,8 +38,8 @@ web-check:
#!/usr/bin/env bash
set -euo pipefail
if [ ! -f web/package.json ]; then echo "web/ not present yet, skipping"; exit 0; fi
- biome ci web/
pnpm -C web install --frozen-lockfile
+ pnpm -C web exec biome ci .
pnpm -C web exec tsc -b --noEmit
# Build the SPA and the binary that embeds it.
diff --git a/PRODUCT.md b/PRODUCT.md
new file mode 100644
index 0000000..27ebeef
--- /dev/null
+++ b/PRODUCT.md
@@ -0,0 +1,87 @@
+# Product
+
+
+
+All facts below are drawn from DESIGN.md and the issue tracker; none were
+confirmed in an interview (the driving session mandated autonomous operation).
+Items marked *(inferred)* are best-effort readings, not user-approved.
+
+## Platform
+
+web
+
+## Stack
+
+Vite + TypeScript SPA, no framework *(fixed by DESIGN.md §11: `web/` is a
+Vite + TypeScript SPA embedded in the Rust binary via `include_dir`; the
+reference project `~/tea/maestro` is framework-free vanilla TS)*.
+
+## Users
+
+A single household's operator (the person who runs the media stack) and, via
+filtered views, a small set of family members. Used on a self-hosted LAN/VPN,
+mostly desktop, sometimes phone. The operator's job: decide what media is
+wanted, watch it get sourced, and resolve the queue items that need a human
+call (no-PT-source queue, waivers, manual grabs).
+
+## Product Purpose
+
+One service replacing Radarr, Sonarr and later Bazarr: decide what you want,
+find it, fetch it, put it somewhere Jellyfin can read. Success is a 20-40 MB
+single binary doing what five .NET services do at 600-900 MB — with a UI whose
+manual-search view is actually usable.
+
+## Positioning
+
+Policy engine classifies every release candidate before the human sees it.
+The UI shows decisions (eligible / waived / rejected, with the killing rule
+named), not raw release-name dumps. Radarr cannot truthfully claim this; its
+manual search buries the decision under the release name column.
+
+## Operating Context
+
+- Self-hosted behind VPN + Authelia; the app itself has no auth layer.
+- API-first: the SPA is one client of the OpenAPI-described HTTP API, no
+ privileged path.
+- Coexists with Prowlarr (indexers), Transmission (downloads), Jellyfin
+ (playback), Jellyseerr (requests), ntfy (notifications).
+
+## Capabilities and Constraints
+
+- Phase 1 (now): skeleton only — health endpoint, embedded empty SPA shell
+ with one page rendering `GET /api/health`.
+- Phase 5 brings the real UI: unified search (§9.2), manual search buckets
+ (§9.3), library views, queues.
+- The manual-search view is the reason the UI exists; §9.3 fixes chip-based
+ columns, three buckets, secondary release names. Future tokens/components
+ must serve dense, scannable, chip-heavy tabular data.
+- CI gate: `biome ci web/` and `tsc -b --noEmit`; target under 5 min total.
+- Bundle is embedded at compile time; no CDN, no external assets at runtime
+ *(inferred from the include_dir + single-binary requirement)*.
+
+## Brand Commitments
+
+Name: `arr`, lowercase. No logo, no committed palette or typography yet
+*(inferred: nothing visual exists in the repo)*. Voice of DESIGN.md is terse,
+technical, anti-noise — the UI should read the same way.
+
+## Evidence on Hand
+
+- DESIGN.md — the contract, including §9.2/§9.3 UI specs.
+- `~/tea/maestro` — reference for repo/web mechanics, not visual identity.
+- No screenshots, no user testimonials, no existing UI. Nothing to fabricate.
+
+## Product Principles
+
+- Decisions over data: surface the classification, keep the raw evidence one
+ expand away.
+- Quiet by default: default views show only what needs attention; everything
+ satisfied collapses (mirrors §4.2 and §9.5's notification restraint).
+- Density with fixed structure: chips and fixed-width columns, never
+ horizontal scroll.
+- One surface of truth: the API; the UI never has a privileged path.
+
+## Accessibility & Inclusion
+
+No product-specific requirement established. Household includes a pre-reader
+child, but the child consumes Jellyfin, not this UI *(inferred)*.
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..145abb6
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
+ "files": {
+ "includes": ["web/**", "!web/src/api/**", "!web/dist/**", "!web/node_modules/**"]
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "space",
+ "indentWidth": 2,
+ "lineWidth": 100
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true
+ }
+ },
+ "assist": {
+ "actions": {
+ "source": {
+ "organizeImports": "on"
+ }
+ }
+ }
+}
diff --git a/crates/arr-daemon/Cargo.toml b/crates/arr-daemon/Cargo.toml
index 7b0d9f5..edda77f 100644
--- a/crates/arr-daemon/Cargo.toml
+++ b/crates/arr-daemon/Cargo.toml
@@ -16,6 +16,8 @@ arr-compat = { workspace = true }
arr-db = { workspace = true }
arr-meta = { workspace = true }
axum = { workspace = true }
+include_dir = { workspace = true }
+mime_guess = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
diff --git a/crates/arr-daemon/build.rs b/crates/arr-daemon/build.rs
new file mode 100644
index 0000000..21bdd32
--- /dev/null
+++ b/crates/arr-daemon/build.rs
@@ -0,0 +1,127 @@
+//! Resolve the SPA bundle embedded by `web.rs` via `include_dir!`.
+//!
+//! Three modes, in order:
+//!
+//! * `ARR_WEB_DIST` set — a prebuilt bundle (Nix, CI, release pipeline) is
+//! embedded as-is. Missing or empty means a broken pipeline: fail.
+//! * pnpm available — the default developer path: build `web/` and embed
+//! `web/dist`. Failures in the build itself fail loudly.
+//! * pnpm missing, debug profile — embed a generated placeholder page and
+//! warn. This keeps `cargo clippy`/`cargo test` working on runners with no
+//! node toolchain (the CI rust job, DESIGN.md §12).
+//!
+//! A release build never gets the placeholder: shipping a binary whose UI is
+//! an apology defeats the single-binary acceptance test, so that combination
+//! panics instead.
+
+use std::env;
+use std::path::PathBuf;
+use std::process::Command;
+
+fn main() {
+ println!("cargo:rerun-if-env-changed=ARR_WEB_DIST");
+
+ let dist = match env::var_os("ARR_WEB_DIST") {
+ Some(prebuilt) => PathBuf::from(prebuilt),
+ None => build_or_placeholder(),
+ };
+
+ let index = dist.join("index.html");
+ let bundled = std::fs::metadata(&index).is_ok_and(|m| m.is_file() && m.len() > 0);
+ assert!(
+ bundled,
+ "no SPA bundle at {} (missing or empty index.html) — check the vite \
+ outDir or the ARR_WEB_DIST override",
+ index.display()
+ );
+
+ // `include_dir!` expands `$VAR` against the env rustc is invoked with,
+ // which is what `cargo:rustc-env` sets.
+ println!("cargo:rustc-env=ARR_WEB_BUNDLE_DIR={}", dist.display());
+}
+
+fn build_or_placeholder() -> PathBuf {
+ let manifest_dir =
+ PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR"));
+ let web_dir = manifest_dir.join("../../web");
+
+ // Rebuild only when a frontend input changes, not on unrelated Rust
+ // recompiles. The dist watch matters too: the inputs decide whether this
+ // script reruns, but `include_dir!` reads dist, and without this watch an
+ // incremental build can regenerate dist yet keep the old bytes embedded.
+ for rel in [
+ "src",
+ "index.html",
+ "package.json",
+ "pnpm-lock.yaml",
+ "pnpm-workspace.yaml",
+ "vite.config.ts",
+ "tsconfig.json",
+ "dist",
+ ] {
+ println!("cargo:rerun-if-changed={}", web_dir.join(rel).display());
+ }
+
+ if !tool_works("pnpm") {
+ let profile = env::var("PROFILE").unwrap_or_default();
+ assert!(
+ profile != "release",
+ "arr embeds the web SPA at build time and needs pnpm + node for a \
+ release build. Install them, or point ARR_WEB_DIST at a prebuilt \
+ bundle."
+ );
+ println!(
+ "cargo:warning=pnpm not found; embedding a placeholder page instead of \
+ the web SPA (debug builds only)"
+ );
+ return write_placeholder();
+ }
+
+ run(
+ Command::new("pnpm")
+ .args(["install", "--frozen-lockfile"])
+ .current_dir(&web_dir),
+ "pnpm install --frozen-lockfile",
+ );
+ run(
+ Command::new("pnpm").arg("build").current_dir(&web_dir),
+ "pnpm build",
+ );
+
+ web_dir.join("dist")
+}
+
+fn write_placeholder() -> PathBuf {
+ let out = PathBuf::from(env::var("OUT_DIR").expect("cargo sets OUT_DIR")).join("placeholder");
+ std::fs::create_dir_all(&out).expect("create placeholder dir");
+ std::fs::write(out.join("index.html"), PLACEHOLDER_HTML).expect("write placeholder");
+ out
+}
+
+fn tool_works(tool: &str) -> bool {
+ Command::new(tool)
+ .arg("--version")
+ .output()
+ .is_ok_and(|o| o.status.success())
+}
+
+fn run(cmd: &mut Command, label: &str) {
+ let status = cmd
+ .status()
+ .unwrap_or_else(|e| panic!("failed to spawn `{label}`: {e}"));
+ assert!(status.success(), "`{label}` failed with {status}");
+}
+
+const PLACEHOLDER_HTML: &str = r#"
+
+
arr — web bundle not built
+
+
arr is running
+
but this binary was built without the web bundle (no pnpm on the build
+machine). Rebuild with pnpm + node installed, or set ARR_WEB_DIST
+to a prebuilt bundle.
+"#;
diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs
index 1438fd7..6d9b587 100644
--- a/crates/arr-daemon/src/main.rs
+++ b/crates/arr-daemon/src/main.rs
@@ -1,6 +1,7 @@
//! arr — reconcile loop and process entry point. See DESIGN.md §8.
mod config;
+mod web;
use std::process::ExitCode;
use std::sync::Arc;
@@ -94,6 +95,7 @@ async fn run() -> Result<(), Error> {
let app = arr_api::router(state)
.merge(arr_compat::router(compat))
+ .fallback(web::serve)
.layer(TraceLayer::new_for_http());
let listener = tokio::net::TcpListener::bind(config.bind_addr)
diff --git a/crates/arr-daemon/src/web.rs b/crates/arr-daemon/src/web.rs
new file mode 100644
index 0000000..2ca285c
--- /dev/null
+++ b/crates/arr-daemon/src/web.rs
@@ -0,0 +1,137 @@
+//! The embedded SPA. See DESIGN.md §11: `web/` is built by Vite and compiled
+//! into this binary via `include_dir`, so `cargo run` serves the whole UI
+//! with no external files. `build.rs` resolves which directory gets embedded.
+
+use axum::http::{header, StatusCode, Uri};
+use axum::response::{IntoResponse, Response};
+use include_dir::{include_dir, Dir};
+
+static BUNDLE: Dir<'_> = include_dir!("$ARR_WEB_BUNDLE_DIR");
+
+/// Fallback handler for everything the API router did not claim.
+pub async fn serve(uri: Uri) -> Response {
+ serve_path(uri.path())
+}
+
+/// Serve a path from the bundle.
+///
+/// Content-hashed files under `assets/` are immutable, so they get a year of
+/// `immutable` cache. The shell and everything else is `no-cache`: the shell
+/// references the current hashed asset names and a stale cached copy would
+/// point at files that no longer exist after a rebuild.
+///
+/// A miss falls back to the shell only for extensionless paths (client-side
+/// routes). A miss that names a file — a stale `assets/index-OLD.js` — is a
+/// real 404: serving HTML where the browser expects JS blanks the page. And
+/// `/api/*` never falls through to HTML; an unknown API path is a 404.
+fn serve_path(path: &str) -> Response {
+ let trimmed = path.trim_start_matches('/');
+ if trimmed == "api" || trimmed.starts_with("api/") {
+ return not_found();
+ }
+ if trimmed.is_empty() {
+ return serve_index();
+ }
+ match BUNDLE.get_file(trimmed) {
+ Some(file) => {
+ let mime = mime_guess::from_path(trimmed).first_or_octet_stream();
+ let cache = if trimmed.starts_with("assets/") {
+ "public, max-age=31536000, immutable"
+ } else {
+ "no-cache"
+ };
+ (
+ [
+ (header::CONTENT_TYPE, mime.as_ref()),
+ (header::CACHE_CONTROL, cache),
+ ],
+ file.contents(),
+ )
+ .into_response()
+ }
+ None if names_a_file(trimmed) => not_found(),
+ None => serve_index(),
+ }
+}
+
+fn serve_index() -> Response {
+ match BUNDLE.get_file("index.html") {
+ Some(file) => (
+ [
+ (header::CONTENT_TYPE, "text/html; charset=utf-8"),
+ (header::CACHE_CONTROL, "no-cache"),
+ ],
+ file.contents(),
+ )
+ .into_response(),
+ // build.rs guarantees index.html exists in whatever it embedded.
+ None => not_found(),
+ }
+}
+
+/// Whether the last segment carries an extension, i.e. names a concrete file
+/// rather than a client-side route.
+fn names_a_file(path: &str) -> bool {
+ path.rsplit('/').next().is_some_and(|seg| seg.contains('.'))
+}
+
+fn not_found() -> Response {
+ (
+ StatusCode::NOT_FOUND,
+ [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
+ "not found",
+ )
+ .into_response()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn content_type(response: &Response) -> String {
+ response
+ .headers()
+ .get(header::CONTENT_TYPE)
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or_default()
+ .to_owned()
+ }
+
+ #[test]
+ fn the_shell_is_embedded_html() {
+ let response = serve_path("/");
+ assert_eq!(response.status(), StatusCode::OK);
+ assert!(content_type(&response).starts_with("text/html"));
+ }
+
+ #[test]
+ fn an_extensionless_route_falls_back_to_the_shell() {
+ let response = serve_path("/movies/42");
+ assert_eq!(response.status(), StatusCode::OK);
+ assert!(content_type(&response).starts_with("text/html"));
+ }
+
+ #[test]
+ fn a_missing_hashed_asset_is_a_404_never_html() {
+ let response = serve_path("/assets/index-deadbeef.js");
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+ assert!(!content_type(&response).starts_with("text/html"));
+ }
+
+ #[test]
+ fn unknown_api_paths_never_fall_through_to_html() {
+ let response = serve_path("/api/nope");
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+ assert!(!content_type(&response).starts_with("text/html"));
+ }
+
+ #[test]
+ fn the_shell_is_never_cached() {
+ let cache = serve_path("/")
+ .headers()
+ .get(header::CACHE_CONTROL)
+ .and_then(|v| v.to_str().ok())
+ .map(str::to_owned);
+ assert_eq!(cache.as_deref(), Some("no-cache"));
+ }
+}
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..ac7c7f0
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+ arr
+
+
+
+
+
+