Unified search box (#30) (#75)
ci / rust (push) Failing after 1m26s
ci / web (push) Successful in 29s
e2e / e2e (push) Successful in 1m55s

This commit was merged in pull request #75.
This commit is contained in:
2026-08-22 22:08:32 +01:00
parent ac6ed8b798
commit bf526b9198
8 changed files with 1113 additions and 1 deletions
+46
View File
@@ -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.
-->
<header class="rail">
<div class="rail-id">
<span class="lamp" id="master-lamp" data-state="probing" aria-hidden="true"></span>
<h1 class="wordmark">arr</h1>
</div>
<div class="rail-search">
<input
id="search"
class="search-input readout"
type="text"
spellcheck="false"
autocomplete="off"
placeholder="search · title / tmdb id / tt… / magnet"
aria-label="unified search — titles, TMDB or IMDb ids, magnets and .torrent links"
/>
<span class="search-hint readout" id="search-hint" aria-hidden="true">/</span>
</div>
<p class="rail-verdict readout" id="master-verdict" role="status">probing chain…</p>
<div class="rail-meta">
<span class="readout dim" id="version" data-testid="version">v —</span>
@@ -41,6 +60,33 @@
</div>
</header>
<main class="deck" id="deck" hidden>
<p class="deck-status readout" id="deck-status" role="status" hidden></p>
<section class="deck-group" id="group-library" hidden aria-labelledby="label-library">
<header class="deck-head">
<h2 class="deck-label" id="label-library">in library</h2>
<span class="deck-count readout" id="count-library"></span>
</header>
<ul class="deck-rows" id="rows-library"></ul>
</section>
<section class="deck-group" id="group-tmdb" hidden aria-labelledby="label-tmdb">
<header class="deck-head">
<h2 class="deck-label" id="label-tmdb">on tmdb</h2>
<span class="deck-count readout" id="count-tmdb"></span>
</header>
<ul class="deck-rows" id="rows-tmdb"></ul>
</section>
<section class="deck-group" id="group-manual" hidden aria-labelledby="label-manual">
<header class="deck-head">
<h2 class="deck-label" id="label-manual">manual grab</h2>
</header>
<article class="module intake" id="manual-intake"></article>
</section>
</main>
<main class="board">
<section class="chain" aria-label="signal chain">
<article class="module area-tmdb" data-check="tmdb">
+462
View File
@@ -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<HTMLInputElement>("#search");
const hint = must<HTMLElement>("#search-hint");
const refs: DeckRefs = {
deck: must<HTMLElement>("#deck"),
status: must<HTMLElement>("#deck-status"),
groups: {
library: {
section: must<HTMLElement>("#group-library"),
count: must<HTMLElement>("#count-library"),
rows: must<HTMLUListElement>("#rows-library"),
},
tmdb: {
section: must<HTMLElement>("#group-tmdb"),
count: must<HTMLElement>("#count-tmdb"),
rows: must<HTMLUListElement>("#rows-tmdb"),
},
},
manualSection: must<HTMLElement>("#group-manual"),
manualIntake: must<HTMLElement>("#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<HTMLElement>(".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<HTMLElement>(".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<Root[]>,
): 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<HTMLElement>(".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();
+146
View File
@@ -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<SearchOutcome> {
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<Root[]> {
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<AddOutcome> {
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<string> {
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 };
}
+299
View File
@@ -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);
}