diff --git a/.impeccable/surfaces/web-src-main-ts.md b/.impeccable/surfaces/web-src-main-ts.md
new file mode 100644
index 0000000..5e82744
--- /dev/null
+++ b/.impeccable/surfaces/web-src-main-ts.md
@@ -0,0 +1,26 @@
+---
+version: 1
+slug: "web-src-main-ts"
+primary_target: "web/src/main.ts"
+related_targets: ["web/index.html","web/src/search.ts","web/src/style.css"]
+---
+
+# Surface: unified search (web/src/main.ts, §9.2)
+
+Mode: Operate. The operator finds or adds a title without knowing which of
+the two they are doing.
+
+- Audience: the household operator; desktop mostly, phone sometimes.
+- Task: one rail-mounted command input. Typing swaps the chain board for the
+ search deck; clearing or Esc returns to the board. "/" focuses from anywhere.
+- Structure (pinned by DESIGN.md §9.2): IN LIBRARY group first, ON TMDB below,
+ engraved micro-label headings, machined rows, chips on the right.
+- Add flow: inline panel under a TMDB row — root radio cards (audience name,
+ policy name, path), main pre-selected, one confirm. Never a separate page.
+- Manual intake: magnet / .torrent input routes to a MANUAL GRAB panel showing
+ parsed display name and btih; grab wiring lands with the release buckets
+ (issue #31/#22), and the panel says so honestly.
+- Constraints: 250 ms debounce, AbortController cancellation, token-gated CSS,
+ cyan for interaction only, dead-air rule (no idle animation).
+- Unresolved: library rows are informational until the library view (#32)
+ gives them a destination; TMDB search is movie-only until series land (#39).
diff --git a/web/index.html b/web/index.html
index ac7c7f0..dcd8d88 100644
--- a/web/index.html
+++ b/web/index.html
@@ -28,12 +28,31 @@
output in this environment).
FINISH: unreviewed and undocumented is unfinished; this build ends with the
finish review, the verdict, and DESIGN.md.
+ SEARCH (§9.2, issue #30): one command input on the rail — the board's
+ command line. Typing swaps the chain for the search deck: IN LIBRARY
+ first, ON TMDB below, engraved group labels, machined rows. Enter on a
+ TMDB row expands an inline add panel with root and policy pre-filled;
+ a pasted magnet or .torrent routes to a manual-intake panel. There is
+ no separate add page, ever. Esc returns to the board; "/" focuses the
+ box from anywhere.
-->
arr
+
+
+ /
+
probing chain…
v —
@@ -41,6 +60,33 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/main.ts b/web/src/main.ts
index 7a664f5..458283d 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -1,4 +1,14 @@
import { type CheckStatus, type Probe, probeHealth } from "./health";
+import {
+ addMovie,
+ type LibraryMovie,
+ movieRoots,
+ parseManualInput,
+ type Root,
+ type SearchResponse,
+ searchTitles,
+ type TmdbMovie,
+} from "./search";
import "./style.css";
const POLL_MS = 15_000;
@@ -154,6 +164,458 @@ function main() {
void probe();
}, POLL_MS);
void probe();
+
+ searchMain(board);
+}
+
+const DEBOUNCE_MS = 250;
+
+interface DeckRefs {
+ deck: HTMLElement;
+ status: HTMLElement;
+ groups: {
+ library: { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement };
+ tmdb: { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement };
+ };
+ manualSection: HTMLElement;
+ manualIntake: HTMLElement;
+}
+
+function searchMain(board: HTMLElement) {
+ const input = must("#search");
+ const hint = must("#search-hint");
+ const refs: DeckRefs = {
+ deck: must("#deck"),
+ status: must("#deck-status"),
+ groups: {
+ library: {
+ section: must("#group-library"),
+ count: must("#count-library"),
+ rows: must("#rows-library"),
+ },
+ tmdb: {
+ section: must("#group-tmdb"),
+ count: must("#count-tmdb"),
+ rows: must("#rows-tmdb"),
+ },
+ },
+ manualSection: must("#group-manual"),
+ manualIntake: must("#manual-intake"),
+ };
+
+ // The audience chip on a library row needs id → root. movieRoots() caches
+ // success, so a failed boot fetch is retried whenever an add panel opens.
+ let roots: Root[] = [];
+ const fetchRoots = () =>
+ movieRoots().then(
+ (fetched) => {
+ roots = fetched;
+ return fetched;
+ },
+ () => roots,
+ );
+ void fetchRoots();
+
+ let timer: number | undefined;
+ let controller: AbortController | null = null;
+
+ function showBoard() {
+ controller?.abort();
+ controller = null;
+ refs.deck.hidden = true;
+ board.hidden = false;
+ }
+
+ function showDeck() {
+ board.hidden = true;
+ refs.deck.hidden = false;
+ }
+
+ function setStatus(text: string | null, tone?: "fault") {
+ refs.status.hidden = text === null;
+ refs.status.textContent = text ?? "";
+ if (tone) {
+ refs.status.dataset.tone = tone;
+ } else {
+ delete refs.status.dataset.tone;
+ }
+ }
+
+ function clearGroups() {
+ for (const group of [refs.groups.library, refs.groups.tmdb]) {
+ group.section.hidden = true;
+ group.rows.replaceChildren();
+ }
+ refs.manualSection.hidden = true;
+ refs.manualIntake.replaceChildren();
+ }
+
+ async function run(query: string) {
+ controller?.abort();
+ controller = new AbortController();
+ showDeck();
+ setStatus("searching…");
+ const outcome = await searchTitles(query, controller.signal);
+ if (outcome.kind === "aborted") {
+ return;
+ }
+ if (outcome.kind === "error") {
+ clearGroups();
+ setStatus(`search failed — ${outcome.detail}`, "fault");
+ return;
+ }
+ render(outcome.response);
+ }
+
+ function render(response: SearchResponse) {
+ clearGroups();
+ if (
+ response.manual !== null &&
+ (response.kind === "magnet" || response.kind === "torrent_url")
+ ) {
+ setStatus(null);
+ refs.manualSection.hidden = false;
+ renderManual(refs.manualIntake, response.kind, response.manual);
+ return;
+ }
+
+ const inLibrary = new Set(response.library.map((movie) => movie.tmdb_id));
+ if (response.library.length === 0 && response.tmdb.length === 0) {
+ setStatus("no matches in library or on tmdb");
+ return;
+ }
+ setStatus(null);
+
+ if (response.library.length > 0) {
+ refs.groups.library.section.hidden = false;
+ refs.groups.library.count.textContent = String(response.library.length);
+ for (const movie of response.library) {
+ refs.groups.library.rows.append(libraryRow(movie, roots));
+ }
+ }
+ if (response.tmdb.length > 0) {
+ refs.groups.tmdb.section.hidden = false;
+ refs.groups.tmdb.count.textContent = String(response.tmdb.length);
+ for (const movie of response.tmdb) {
+ refs.groups.tmdb.rows.append(tmdbRow(movie, inLibrary.has(movie.tmdb_id), fetchRoots));
+ }
+ }
+ }
+
+ input.addEventListener("input", () => {
+ window.clearTimeout(timer);
+ const query = input.value.trim();
+ if (query === "") {
+ showBoard();
+ clearGroups();
+ setStatus(null);
+ return;
+ }
+ timer = window.setTimeout(() => {
+ void run(query);
+ }, DEBOUNCE_MS);
+ });
+
+ input.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ window.clearTimeout(timer);
+ const query = input.value.trim();
+ if (query !== "") {
+ void run(query);
+ }
+ } else if (event.key === "ArrowDown") {
+ const first = refs.deck.querySelector(".row-tmdb:not(:disabled)");
+ if (first) {
+ event.preventDefault();
+ first.focus();
+ }
+ } else if (event.key === "Escape") {
+ input.value = "";
+ showBoard();
+ clearGroups();
+ setStatus(null);
+ }
+ });
+
+ input.addEventListener("focus", () => {
+ hint.hidden = true;
+ });
+ input.addEventListener("blur", () => {
+ hint.hidden = false;
+ });
+
+ window.addEventListener("keydown", (event) => {
+ if (
+ event.key === "/" &&
+ document.activeElement !== input &&
+ !(document.activeElement instanceof HTMLInputElement) &&
+ !(document.activeElement instanceof HTMLTextAreaElement)
+ ) {
+ event.preventDefault();
+ input.focus();
+ input.select();
+ } else if (event.key === "Escape" && !refs.deck.hidden) {
+ input.value = "";
+ showBoard();
+ clearGroups();
+ setStatus(null);
+ input.focus();
+ }
+ });
+
+ refs.deck.addEventListener("keydown", (event) => {
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
+ return;
+ }
+ const target = event.target;
+ if (!(target instanceof HTMLElement) || !target.classList.contains("row-tmdb")) {
+ return;
+ }
+ event.preventDefault();
+ const rows = [...refs.deck.querySelectorAll(".row-tmdb:not(:disabled)")];
+ const index = rows.indexOf(target);
+ if (event.key === "ArrowUp" && index === 0) {
+ input.focus();
+ return;
+ }
+ rows[index + (event.key === "ArrowDown" ? 1 : -1)]?.focus();
+ });
+}
+
+function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanElement {
+ const span = document.createElement("span");
+ span.className = "chip readout";
+ span.textContent = text;
+ extra?.(span);
+ return span;
+}
+
+function rowTitle(title: string, year: number | null): HTMLElement {
+ const wrap = document.createElement("span");
+ wrap.className = "row-id";
+ const name = document.createElement("span");
+ name.className = "row-title";
+ name.textContent = title;
+ const when = document.createElement("span");
+ when.className = "row-year readout dim";
+ when.textContent = year === null ? "—" : String(year);
+ wrap.append(name, when);
+ return wrap;
+}
+
+function libraryRow(movie: LibraryMovie, roots: Root[]): HTMLLIElement {
+ const item = document.createElement("li");
+ item.className = "row";
+ const chips = document.createElement("span");
+ chips.className = "row-chips";
+ const root = roots.find((candidate) => candidate.id === movie.root_id);
+ chips.append(chip(root ? root.audience : `root ${movie.root_id}`));
+ chips.append(
+ chip(movie.state, (span) => {
+ span.dataset.movieState = movie.state;
+ }),
+ );
+ if (!movie.wanted) {
+ chips.append(chip("not wanted"));
+ }
+ if (movie.blocked) {
+ chips.append(chip("blocked"));
+ }
+ item.append(rowTitle(movie.title, movie.year), chips);
+ return item;
+}
+
+function tmdbRow(
+ movie: TmdbMovie,
+ inLibrary: boolean,
+ fetchRoots: () => Promise,
+): HTMLLIElement {
+ const item = document.createElement("li");
+
+ const row = document.createElement("button");
+ row.type = "button";
+ row.className = "row row-tmdb";
+ const chips = document.createElement("span");
+ chips.className = "row-chips";
+ chips.append(chip(movie.original_language));
+ if (inLibrary) {
+ chips.append(
+ chip("in library", (span) => {
+ span.dataset.verdict = "eligible";
+ }),
+ );
+ } else {
+ const add = document.createElement("span");
+ add.className = "row-add readout";
+ add.textContent = "add";
+ chips.append(add);
+ }
+ row.append(rowTitle(movie.title, movie.year), chips);
+ if (movie.overview) {
+ const overview = document.createElement("span");
+ overview.className = "row-overview";
+ overview.textContent = movie.overview;
+ row.append(overview);
+ }
+ item.append(row);
+
+ if (inLibrary) {
+ row.disabled = true;
+ return item;
+ }
+
+ row.setAttribute("aria-expanded", "false");
+ row.addEventListener("click", () => {
+ const open = item.querySelector(".add-panel");
+ if (open) {
+ open.remove();
+ row.setAttribute("aria-expanded", "false");
+ row.focus();
+ return;
+ }
+ void fetchRoots().then((available) => {
+ if (item.querySelector(".add-panel")) {
+ return;
+ }
+ const panel = addPanel(movie, available, row);
+ item.append(panel);
+ row.setAttribute("aria-expanded", "true");
+ panel.querySelector(".control")?.focus();
+ });
+ });
+ return item;
+}
+
+/** The §9.2 add flow: inline, root and policy pre-filled, one confirm. */
+function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTMLElement {
+ const panel = document.createElement("div");
+ panel.className = "add-panel";
+
+ let selected = roots.find((root) => root.audience === "main") ?? roots[0] ?? null;
+
+ const options = document.createElement("div");
+ options.className = "add-roots";
+ options.setAttribute("role", "group");
+ options.setAttribute("aria-label", "root folder");
+
+ const optionButtons: HTMLButtonElement[] = [];
+ for (const root of roots) {
+ const option = document.createElement("button");
+ option.type = "button";
+ option.className = "root-option";
+ option.setAttribute("aria-pressed", String(root === selected));
+
+ const audience = document.createElement("span");
+ audience.className = "root-audience";
+ audience.textContent = root.audience;
+ const policy = document.createElement("span");
+ policy.className = "root-policy readout dim";
+ policy.textContent = root.policy_name;
+ const path = document.createElement("span");
+ path.className = "root-path readout dim";
+ path.textContent = root.path;
+ option.append(audience, policy, path);
+
+ option.addEventListener("click", () => {
+ selected = root;
+ for (const other of optionButtons) {
+ other.setAttribute("aria-pressed", String(other === option));
+ }
+ });
+ optionButtons.push(option);
+ options.append(option);
+ }
+
+ const confirm = document.createElement("button");
+ confirm.type = "button";
+ confirm.className = "control";
+ confirm.textContent = "add to library";
+
+ const note = document.createElement("p");
+ note.className = "add-note readout";
+ note.setAttribute("role", "status");
+ note.hidden = true;
+
+ if (roots.length === 0) {
+ confirm.disabled = true;
+ note.hidden = false;
+ note.textContent = "roots unavailable — daemon down or database missing";
+ note.dataset.tone = "fault";
+ }
+
+ confirm.addEventListener("click", () => {
+ const root = selected;
+ if (!root) {
+ return;
+ }
+ confirm.disabled = true;
+ note.hidden = false;
+ delete note.dataset.tone;
+ note.textContent = "adding…";
+ void addMovie(movie, root.id).then((outcome) => {
+ if (outcome.kind === "error") {
+ confirm.disabled = false;
+ note.textContent = `add failed — ${outcome.detail}`;
+ note.dataset.tone = "fault";
+ return;
+ }
+ const already = outcome.kind === "conflict";
+ const done = document.createElement("p");
+ done.className = "add-done readout";
+ done.setAttribute("role", "status");
+ done.tabIndex = -1;
+ done.textContent = already
+ ? "already in library"
+ : `added to ${root.audience} — wanted, search follows`;
+ panel.replaceChildren(done);
+ done.focus();
+ row.disabled = true;
+ row.setAttribute("aria-expanded", "true");
+ row.querySelector(".row-add")?.replaceWith(
+ chip("in library", (span) => {
+ span.dataset.verdict = "eligible";
+ }),
+ );
+ });
+ });
+
+ const actions = document.createElement("div");
+ actions.className = "add-actions";
+ actions.append(confirm, note);
+ panel.append(options, actions);
+ return panel;
+}
+
+function renderManual(intake: HTMLElement, kind: "magnet" | "torrent_url", raw: string) {
+ const parsed = parseManualInput(kind, raw);
+ intake.replaceChildren();
+
+ const head = document.createElement("header");
+ head.className = "module-head";
+ const name = document.createElement("h3");
+ name.className = "module-name";
+ name.textContent = kind === "magnet" ? "magnet" : "torrent";
+ const status = document.createElement("span");
+ status.className = "module-status readout";
+ status.textContent = "recognized";
+ head.append(name, status);
+
+ const detail = document.createElement("p");
+ detail.className = "module-detail readout";
+ detail.textContent = parsed.name ?? parsed.raw;
+
+ intake.append(head, detail);
+ if (parsed.infohash) {
+ const hash = document.createElement("p");
+ hash.className = "module-detail readout dim";
+ hash.textContent = `btih ${parsed.infohash}`;
+ intake.append(hash);
+ }
+
+ const note = document.createElement("p");
+ note.className = "module-detail readout dim";
+ note.textContent = "manual grab lands with the release buckets — nothing sent yet";
+ intake.append(note);
}
main();
diff --git a/web/src/search.ts b/web/src/search.ts
new file mode 100644
index 0000000..82068d1
--- /dev/null
+++ b/web/src/search.ts
@@ -0,0 +1,146 @@
+// Hand-written mirror of arr-api's /api/search, /api/roots and POST
+// /api/movies schemas — same reasoning as health.ts: the generated client
+// (src/api/) is uncommitted, so CI's tsc cannot see it.
+
+export type SearchInputKind = "text" | "tmdb_id" | "imdb_id" | "magnet" | "torrent_url";
+
+export interface LibraryMovie {
+ id: number;
+ tmdb_id: number;
+ title: string;
+ year: number | null;
+ original_language: string | null;
+ root_id: number;
+ wanted: boolean;
+ state: "missing" | "grabbed" | "imported";
+ blocked: boolean;
+}
+
+export interface TmdbMovie {
+ tmdb_id: number;
+ title: string;
+ original_title: string;
+ original_language: string;
+ year: number | null;
+ overview: string | null;
+}
+
+export interface SearchResponse {
+ kind: SearchInputKind;
+ library: LibraryMovie[];
+ tmdb: TmdbMovie[];
+ manual: string | null;
+}
+
+export interface Root {
+ id: number;
+ kind: string;
+ audience: string;
+ path: string;
+ policy_id: number;
+ policy_name: string;
+}
+
+export type SearchOutcome =
+ | { kind: "results"; response: SearchResponse }
+ | { kind: "error"; detail: string }
+ | { kind: "aborted" };
+
+export async function searchTitles(query: string, signal: AbortSignal): Promise {
+ try {
+ const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal });
+ if (!response.ok) {
+ const detail = await errorDetail(response);
+ return { kind: "error", detail };
+ }
+ return { kind: "results", response: (await response.json()) as SearchResponse };
+ } catch (error) {
+ if (error instanceof DOMException && error.name === "AbortError") {
+ return { kind: "aborted" };
+ }
+ return { kind: "error", detail: "daemon unreachable" };
+ }
+}
+
+let rootsCache: Root[] | null = null;
+
+/** The movie roots, fetched once. The add flow pre-fills from these. */
+export async function movieRoots(): Promise {
+ if (rootsCache) {
+ return rootsCache;
+ }
+ const response = await fetch("/api/roots");
+ if (!response.ok) {
+ throw new Error(await errorDetail(response));
+ }
+ const roots = (await response.json()) as Root[];
+ rootsCache = roots.filter((root) => root.kind === "movie");
+ return rootsCache;
+}
+
+export type AddOutcome =
+ | { kind: "added"; movie: LibraryMovie }
+ | { kind: "conflict" }
+ | { kind: "error"; detail: string };
+
+export async function addMovie(movie: TmdbMovie, rootId: number): Promise {
+ try {
+ const response = await fetch("/api/movies", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ tmdb_id: movie.tmdb_id,
+ title: movie.title,
+ year: movie.year,
+ original_language: movie.original_language,
+ root_id: rootId,
+ }),
+ });
+ if (response.status === 409) {
+ return { kind: "conflict" };
+ }
+ if (!response.ok) {
+ return { kind: "error", detail: await errorDetail(response) };
+ }
+ return { kind: "added", movie: (await response.json()) as LibraryMovie };
+ } catch {
+ return { kind: "error", detail: "daemon unreachable" };
+ }
+}
+
+async function errorDetail(response: Response): Promise {
+ try {
+ const body = (await response.json()) as { error?: string };
+ return body.error ?? `http ${response.status}`;
+ } catch {
+ return `http ${response.status}`;
+ }
+}
+
+export interface ManualIntake {
+ kind: "magnet" | "torrent_url";
+ /** The display name a magnet advertises, when it carries one. */
+ name: string | null;
+ /** The btih infohash, when the magnet carries one. */
+ infohash: string | null;
+ raw: string;
+}
+
+/** Best-effort read of a pasted magnet or .torrent URL, for display only. */
+export function parseManualInput(kind: "magnet" | "torrent_url", raw: string): ManualIntake {
+ if (kind === "torrent_url") {
+ return { kind, name: raw.split("/").pop() ?? null, infohash: null, raw };
+ }
+ let name: string | null = null;
+ let infohash: string | null = null;
+ try {
+ const params = new URLSearchParams(raw.slice(raw.indexOf("?") + 1));
+ name = params.get("dn");
+ const urn = params.get("xt") ?? "";
+ const match = /^urn:btih:([0-9a-zA-Z]+)$/.exec(urn);
+ infohash = match?.[1]?.toLowerCase() ?? null;
+ } catch {
+ // display-only parsing; a malformed magnet still shows raw
+ }
+ return { kind, name, infohash, raw };
+}
diff --git a/web/src/style.css b/web/src/style.css
index c916b47..a89308c 100644
--- a/web/src/style.css
+++ b/web/src/style.css
@@ -277,6 +277,11 @@ body {
align-items: center;
}
+/* the deck swaps in over the board; flex would beat [hidden] otherwise */
+.board[hidden] {
+ display: none;
+}
+
.chain {
width: 100%;
}
@@ -467,6 +472,285 @@ body {
outline-offset: 2px;
}
+/* ---- unified search (§9.2) ------------------------------------------- */
+
+.rail-search {
+ position: relative;
+ flex: 1;
+ max-width: 30rem;
+ display: flex;
+ align-items: center;
+}
+
+.search-input {
+ width: 100%;
+ min-height: 2.75rem;
+ padding: 0 var(--space-8) 0 var(--space-3);
+ color: var(--ink);
+ background: var(--ground);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ box-shadow: inset 0 1px var(--lowlight);
+ caret-color: var(--accent);
+ transition: border-color 150ms var(--ease-out);
+}
+
+.search-input::placeholder {
+ color: var(--ink-faint);
+ opacity: 1;
+}
+
+.search-input:hover {
+ border-color: var(--line-strong);
+}
+
+.search-input:focus {
+ border-color: var(--accent);
+ outline: none;
+}
+
+.search-hint {
+ position: absolute;
+ right: var(--space-3);
+ padding: 0 var(--space-1);
+ font-size: var(--text-2xs);
+ color: var(--ink-faint);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ pointer-events: none;
+}
+
+/* ---- search deck ------------------------------------------------------ */
+
+.deck {
+ width: 100%;
+ max-width: 68rem;
+ margin: 0 auto;
+ padding: var(--space-8) var(--space-6) var(--space-12);
+ flex: 1;
+}
+
+.deck-status {
+ margin: 0 0 var(--space-4);
+ color: var(--ink-muted);
+}
+
+.deck-status[data-tone="fault"] {
+ color: var(--signal-fault);
+}
+
+.deck-group {
+ margin: 0 0 var(--space-8);
+}
+
+.deck-head {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-3);
+ padding: 0 var(--space-1) var(--space-2);
+ border-bottom: 1px solid var(--line);
+}
+
+/* the engraved micro-label is the heading itself, not a kicker */
+.deck-label {
+ margin: 0;
+ font-size: var(--text-2xs);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: var(--ink-muted);
+}
+
+.deck-count {
+ font-size: var(--text-xs);
+ color: var(--ink-faint);
+}
+
+.deck-rows {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.row {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: var(--space-1) var(--space-3);
+ width: 100%;
+ padding: var(--space-2) var(--space-1);
+ border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
+ text-align: left;
+}
+
+.row-id {
+ display: inline-flex;
+ align-items: baseline;
+ gap: var(--space-2);
+ min-width: 0;
+}
+
+/* titles are names: the display face, per the Readout Rule in design.json */
+.row-title {
+ font-family: var(--font-display);
+ font-size: var(--text-md);
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ color: var(--ink);
+}
+
+.row-year {
+ font-size: var(--text-xs);
+}
+
+.row-chips {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ margin-left: auto;
+}
+
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ min-height: 1.375rem;
+ padding: 0 var(--space-2);
+ background: var(--panel-raised);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ font-family: var(--font-readout);
+ font-size: var(--text-xs);
+ color: var(--ink-muted);
+}
+
+.chip[data-verdict="eligible"] {
+ color: var(--verdict-eligible);
+ border-color: oklch(from var(--verdict-eligible) l c h / 45%);
+}
+
+.chip[data-movie-state="imported"] {
+ color: var(--ink);
+}
+
+/* a TMDB row is the affordance: the whole row opens the add flow */
+.row-tmdb {
+ font: inherit;
+ background: none;
+ border: 0;
+ border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
+ cursor: pointer;
+ transition: background 150ms var(--ease-out);
+}
+
+.row-tmdb:hover:not(:disabled),
+.row-tmdb:focus-visible {
+ background: var(--panel);
+}
+
+.row-tmdb:disabled {
+ cursor: default;
+}
+
+.row-add {
+ font-size: var(--text-xs);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--accent);
+}
+
+.row-overview {
+ flex-basis: 100%;
+ overflow: hidden;
+ font-size: var(--text-xs);
+ color: var(--ink-faint);
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+/* ---- add flow (inline, root and policy pre-filled) -------------------- */
+
+.add-panel {
+ margin: 0 0 var(--space-2);
+ padding: var(--space-3) var(--space-3) var(--space-3);
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-top: 0;
+ border-radius: 0 0 var(--radius) var(--radius);
+ box-shadow: inset 0 1px var(--highlight);
+}
+
+.add-roots {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
+ gap: var(--space-2);
+}
+
+.root-option {
+ display: grid;
+ gap: var(--space-1);
+ padding: var(--space-2) var(--space-3);
+ font: inherit;
+ text-align: left;
+ color: var(--ink);
+ background: var(--panel-raised);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ box-shadow: inset 0 1px var(--highlight);
+ cursor: pointer;
+ transition: border-color 150ms var(--ease-out);
+}
+
+.root-option:hover {
+ border-color: var(--line-strong);
+}
+
+.root-option[aria-pressed="true"] {
+ border-color: var(--accent);
+}
+
+.root-audience {
+ font-family: var(--font-display);
+ font-size: var(--text-md);
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ line-height: 1;
+}
+
+.root-policy,
+.root-path {
+ font-size: var(--text-xs);
+}
+
+.add-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ margin: var(--space-3) 0 0;
+}
+
+.add-note {
+ margin: 0;
+ font-size: var(--text-xs);
+ color: var(--ink-muted);
+}
+
+.add-note[data-tone="fault"] {
+ color: var(--signal-fault);
+}
+
+.add-done {
+ margin: 0;
+ color: var(--verdict-eligible);
+}
+
+/* ---- manual intake ---------------------------------------------------- */
+
+.intake .module-detail {
+ overflow-wrap: anywhere;
+}
+
/* ---- small screens --------------------------------------------------- */
@media (max-width: 46rem) {
@@ -480,6 +764,21 @@ body {
order: 3;
}
+ .rail-search {
+ order: 4;
+ flex-basis: 100%;
+ max-width: none;
+ }
+
+ .deck {
+ padding: var(--space-6) var(--space-4) var(--space-8);
+ }
+
+ .row-chips {
+ margin-left: 0;
+ flex-basis: 100%;
+ }
+
.board {
padding: var(--space-6) var(--space-4);
}