Files
arr/web/index.html
T
Miguel PalhasandClaude Opus 5.5 52b11fda93 feat(web): one row primitive and a mobile shell
Every list now uses the row primitive with a fixed action column, so
actions never wrap. Rows use icons, headers use words. Status slots,
skeletons and a fixed hero grid stop the page jumping. Below 40rem a
bottom tab bar holds the four destinations. Tooltip-only meaning moves
to tap-to-explain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 17:28:22 +01:00

579 lines
28 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>arr</title>
<script type="module" src="/src/main.ts"></script>
</head>
<body>
<!--
THESIS: arr is a signal chain — TMDB and Prowlarr feed in, qBittorrent is
driven out — and health is lamps on that chain, read in one glance. Refuses
the admin-dashboard rut: no sidebar, no stat cards, no hero metric.
OWN-WORLD: broadcast master-control panel. Near-black charcoal ground,
machined panel modules with inset hairline borders, engraved uppercase
micro-labels, monospace readouts, status lamps (signal green / amber /
fault red) with a soft glow, phosphor-cyan for the one interactive control.
STORY: the operator opens the board, reads the master lamp, and knows in a
second whether arr can do its job — and which upstream is down, by name.
FIRST VIEWPORT: a top rail (wordmark + master lamp + version),
then the chain: TMDB and PROWLARR modules on the left feeding the central
arr module, QBITTORRENT driven on the right, joined by bus hairlines with
direction arrows. On a phone the chain stacks vertically.
FORM: broadcast master-control panel, #2 of 7 on the ordered list; external
roll (shell RANDOM → 2, witness 57910; concept-seed.mjs degraded to empty
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.
BUCKETS (§9.3, issue #31): a library row opens the release deck — the
policy engine's verdicts, inverted from Radarr: ELIGIBLE rows lead with
fixed-width attribute chips (score res source hdr audio size seed) in
aligned mono columns; WAIVED and REJECTED collapse to counts. The
release name is secondary, truncated, full on expand. Every rejected
row names its killing rule; one click on a waived row writes the
override and grabs. No horizontal scroll at any viewport. Esc returns
to the search deck.
LIBRARY (§4.2, issue #32): the LIBRARY control on the rail opens the
library deck — SERIES then MOVIES, machined rows with the title left
and chips right. Status is derived and only displayed: series carry a
§4.2 status chip (channel-violet activity, neutral satisfaction, the
mono word always present), movies carry their state chip. There is no
monitored flag anywhere; intent is wanted, at the leaf. The default
list shows what needs attention — airing and incomplete series,
wanted-and-missing movies — and everything satisfied collapses behind
ONE toggle with its count. A file imported under a waiver reads
honestly ("english, no dub") as a dashed amber chip, never as a clean
match. A movie row opens the release deck and returns here on Esc.
QUEUES (§5.2 + §5.7, issue #33): the QUEUES control opens the attention
deck — the only two places the app asks for a human. NO PT SOURCE holds
kids titles with no qualifying pt release; parked for months by design;
one click writes allow_english_audio and the normal search proceeds.
NEEDS DECISION holds titles that hard-failed twice on different
releases; the row opens the release deck to decide by hand. Both
sections always render, even empty — the operator visits to confirm
nothing needs them. The rail control carries an amber entry count,
refreshed with the health poll; amber is attention, cyan stays the
interaction hue.
SHELL (§9.7, issue 173): the library is the homepage — "/" renders the
library deck, and the wordmark on the rail is a link home. The signal
chain above is no longer a route: it lives as the first section of
/settings, its lamps still driven by the health poll, and the master
lamp stays on the rail so the one-glance read survives the move. Where
earlier notes here say "returns to the board", read "the library".
REVISION 2 (mobile first): the phone is the base layout. One sticky
one-line rail, and below 40rem a bottom tab bar carrying the four
destinations — library, search, queues, settings — which move into the
rail from 40rem. Every list is one row primitive (lead | body | actions)
whose action column never wraps; icons in rows, words in headers and
panels; every slot reserves its final size before data arrives. Detail
pages open with a back control and the title, then one action bar.
-->
<header class="rail" id="rail">
<div class="rail-inner">
<div class="rail-id">
<span class="lamp" id="master-lamp" data-state="probing" aria-hidden="true"></span>
<a class="wordmark" id="wordmark" href="/">arr</a>
</div>
<nav class="rail-nav" aria-label="destinations">
<a class="nav-link" href="/" data-dest="library">library</a>
<a class="nav-link" href="/queues" data-dest="queues"
>queues<span class="nav-count readout" data-role="queues-count" hidden></span
></a>
<a class="nav-link" href="/settings" data-dest="settings">settings</a>
</nav>
<div class="rail-search" id="rail-search">
<span class="rail-search-glyph" data-glyph="search" aria-hidden="true"></span>
<input
id="search"
class="search-input readout"
type="text"
spellcheck="false"
autocomplete="off"
enterkeyhint="search"
placeholder="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>
<button
type="button"
class="control control-icon rail-search-close"
id="search-close"
aria-label="close search"
data-glyph="close"
></button>
</div>
<button
type="button"
class="control control-icon rail-search-open"
id="search-open"
aria-label="search"
data-glyph="search"
></button>
<span class="readout dim rail-version" id="version" data-testid="version">v —</span>
</div>
</header>
<nav class="tabbar" id="tabbar" aria-label="destinations">
<a class="tab" href="/" data-dest="library"
><span data-glyph="library" aria-hidden="true"></span><span class="tab-label">library</span></a
>
<a class="tab" href="/search" data-dest="search"
><span data-glyph="search" aria-hidden="true"></span><span class="tab-label">search</span></a
>
<a class="tab" href="/queues" data-dest="queues"
><span data-glyph="warning" aria-hidden="true"></span><span class="tab-label">queues</span
><span class="tab-badge readout" data-role="queues-count" hidden></span
></a>
<a class="tab" href="/settings" data-dest="settings"
><span data-glyph="cog" aria-hidden="true"></span><span class="tab-label">settings</span></a
>
</nav>
<main class="deck" id="deck" hidden aria-label="search results">
<header class="page-bar">
<h2 class="page-title">search</h2>
<p class="head-status readout" id="deck-status" role="status" data-empty="true"></p>
</header>
<div class="segmented deck-filter" id="deck-filter" role="toolbar" aria-label="show only one kind" hidden>
<button type="button" class="seg" data-kind="all" aria-pressed="true">
all<span class="filter-count" data-role="count"></span>
</button>
<button type="button" class="seg" data-kind="movie" aria-pressed="false">
movies<span class="filter-count" data-role="count"></span>
</button>
<button type="button" class="seg" data-kind="series" aria-pressed="false">
series<span class="filter-count" data-role="count"></span>
</button>
</div>
<section class="deck-group" id="group-library" hidden aria-labelledby="label-library">
<header class="deck-head">
<h3 class="deck-label" id="label-library">in library</h3>
<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">
<h3 class="deck-label" id="label-tmdb">on tmdb</h3>
<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">
<h3 class="deck-label" id="label-manual">manual grab</h3>
</header>
<article class="module intake" id="manual-intake"></article>
</section>
</main>
<main class="deck" id="library" hidden aria-label="library">
<header class="page-bar">
<h2 class="page-title">library</h2>
<span class="deck-count readout" id="library-summary"></span>
<p class="head-status readout" id="library-status" role="status" data-empty="true"></p>
<fieldset class="segmented" aria-label="layout">
<button type="button" class="seg seg-icon" id="library-view-grid" aria-pressed="true" aria-label="poster grid" data-glyph="grid"></button>
<button type="button" class="seg seg-icon" id="library-view-list" aria-pressed="false" aria-label="list" data-glyph="list"></button>
</fieldset>
</header>
<div id="library-libraries"></div>
<section class="deck-group" id="library-series" hidden aria-labelledby="label-lib-series">
<header class="deck-head">
<h3 class="deck-label" id="label-lib-series">series</h3>
<span class="deck-count readout" id="count-lib-series"></span>
</header>
<ul class="deck-rows" id="rows-lib-series"></ul>
</section>
<section class="deck-group" id="library-movies" hidden aria-labelledby="label-lib-movies">
<header class="deck-head">
<h3 class="deck-label" id="label-lib-movies">movies</h3>
<span class="deck-count readout" id="count-lib-movies"></span>
</header>
<ul class="deck-rows" id="rows-lib-movies"></ul>
</section>
</main>
<main class="deck" id="queues" hidden aria-label="attention queues">
<header class="page-bar">
<h2 class="page-title">queues</h2>
<span class="deck-count readout" id="queues-summary"></span>
<p class="head-status readout" id="queues-status" role="status" data-empty="true"></p>
</header>
<section class="deck-group" id="queue-no-pt" hidden aria-labelledby="label-no-pt">
<header class="deck-head">
<h3 class="deck-label" id="label-no-pt">no pt source</h3>
<span class="deck-count readout" id="count-no-pt"></span>
</header>
<div class="group-note" data-explain-group>
<p>Kids titles with no Portuguese release yet.</p>
<p class="group-note-more" hidden>
They can sit here for months, by design. Allow English writes the movie's override
and its search goes ahead. A series waits for its own page.
</p>
</div>
<ul class="deck-rows" id="rows-no-pt"></ul>
</section>
<section class="deck-group" id="queue-decision" hidden aria-labelledby="label-decision">
<header class="deck-head">
<h3 class="deck-label" id="label-decision">needs decision</h3>
<span class="deck-count readout" id="count-decision"></span>
</header>
<div class="group-note" data-explain-group>
<p>Titles where two different releases failed.</p>
<p class="group-note-more" hidden>
Open the movie and pick a release by hand. Series entries are display-only for now.
</p>
</div>
<ul class="deck-rows" id="rows-decision"></ul>
</section>
<section class="deck-group" id="queue-subtitles" hidden aria-labelledby="label-subtitles">
<header class="deck-head">
<h3 class="deck-label" id="label-subtitles">missing subtitles</h3>
<span class="deck-count readout" id="count-subtitles"></span>
</header>
<div class="group-note" data-explain-group>
<p>A wanted subtitle language is missing.</p>
<p class="group-note-more" hidden>
Each chip says why: no candidate, a translation or provider failure, the daily budget,
or a timing sync that looked wrong. Open the title to search, translate or retry.
</p>
</div>
<ul class="deck-rows" id="rows-subtitles"></ul>
</section>
</main>
<!--
MOVIE PAGE (§9.6, issue 149): a library row opens /movies/{id} — one
machined module carries what the film IS: backdrop hotlinked from TMDB
under a scrim that sinks it into the console charcoal, poster framed at
left, Rajdhani title with year, TMDB rating and vote count, runtime and
genres, the tagline quoted, overview below. TRAILER resolves from the
metadata response's own key (no second call); TMDB, IMDb and Rotten
Tomatoes sit beside it — RT is a search URL built here, never a resolved
page. LIBRARY controls follow on their own row: wanted, blocked, root,
then search indexers and remove. ON DISK lists imported files
with ffprobe attribute tags and any §5.7 waiver, worded honestly. The
§9.3 deck closes the page unchanged — buckets, chips, score heat and
rejection rules untouched; /movies/{id}/releases lands here with the
deck section in view.
-->
<main class="deck detail" id="movie" hidden aria-label="movie detail">
<section class="hero" id="movie-hero" aria-label="title metadata">
<header class="page-head">
<button type="button" class="control control-icon" id="movie-back" aria-label="back" data-glyph="back"></button>
<h2 class="page-title" id="movie-title" tabindex="-1">&nbsp;</h2>
<span class="page-year readout" id="movie-year"></span>
</header>
<div class="hero-body">
<div class="hero-poster"><img class="movie-poster" id="movie-poster" alt="" /></div>
<div class="hero-info">
<span class="hero-chips" id="movie-chips"></span>
<p class="hero-rating readout" id="movie-rating"></p>
<p class="hero-meta readout" id="movie-meta"></p>
<p class="hero-tagline" id="movie-tagline"></p>
<p class="hero-overview" id="movie-overview"></p>
</div>
</div>
<p class="r-note hero-note readout" id="movie-note" role="status" hidden></p>
</section>
<div class="actionbar">
<button type="button" class="control" id="movie-sweep">search indexers</button>
<span class="actionbar-links" id="movie-actions"></span>
<button
type="button"
class="control control-icon"
id="movie-more"
aria-label="library settings for this title"
aria-expanded="false"
aria-controls="movie-more-panel"
data-glyph="more"
></button>
</div>
<div class="more-panel" id="movie-more-panel" hidden>
<div class="more-links" id="movie-links"></div>
<div class="more-toggles" id="movie-toggles"></div>
<p class="control-note readout" id="movie-blocked-note" hidden>
Targeted search is off. RSS still matches this title, so a release can still arrive.
</p>
<label class="field more-field">
<span class="field-label" id="movie-root-label">root</span>
<select class="form-input" id="movie-root" aria-labelledby="movie-root-label"></select>
</label>
<button
type="button"
class="control control-quiet"
data-kind="delete"
id="movie-remove"
aria-expanded="false"
aria-controls="remove-panel"
>
remove from library
</button>
<div class="remove-panel" id="remove-panel" hidden></div>
</div>
<p class="deck-status readout" id="movie-status" role="status" data-empty="true"></p>
<section class="deck-group" id="movie-files" aria-labelledby="label-disk">
<header class="deck-head">
<h3 class="section-title" id="label-disk">on disk</h3>
<span class="deck-count readout" id="count-disk"></span>
</header>
<ul class="deck-rows" id="rows-disk"></ul>
</section>
<section class="deck-group" id="movie-releases" aria-labelledby="label-releases">
<header class="deck-head">
<h3 class="section-title" id="label-releases">releases</h3>
</header>
<p class="deck-status readout" id="releases-status" role="status" data-empty="true"></p>
<div class="buckets" id="movie-buckets"></div>
</section>
</main>
<main class="deck" id="settings" hidden aria-label="settings">
<header class="page-bar">
<h2 class="page-title">settings</h2>
<span class="deck-count readout" id="settings-summary"></span>
<p class="head-status readout" id="settings-status" role="status" data-empty="true"></p>
</header>
<section class="deck-group" id="group-chain" aria-labelledby="label-chain">
<header class="deck-head">
<h3 class="section-title" id="label-chain">signal chain</h3>
</header>
<section class="chain-wrap" aria-label="signal chain">
<div class="chain">
<article class="module area-tmdb" data-check="tmdb">
<header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span>
<h4 class="module-name">tmdb</h4>
<span class="module-status readout" data-role="status">probing</span>
</header>
<p class="module-role">metadata in</p>
<p class="module-detail readout" data-role="detail">titles, languages, release dates</p>
</article>
<div class="bus area-t1" aria-hidden="true">
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="tmdb" aria-hidden="true">
<line x1="0" y1="4" x2="32" y2="4" />
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
</svg>
</div>
<article class="module area-prowlarr" data-check="prowlarr">
<header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span>
<h4 class="module-name">prowlarr</h4>
<span class="module-status readout" data-role="status">probing</span>
</header>
<p class="module-role">indexers in</p>
<p class="module-detail readout" data-role="detail">torznab search and rss</p>
</article>
<div class="bus area-t2" aria-hidden="true">
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="prowlarr" aria-hidden="true">
<line x1="0" y1="4" x2="32" y2="4" />
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
</svg>
</div>
<article class="module module-master area-master" data-check="master">
<header class="module-head">
<span class="lamp lamp-big" data-state="probing" aria-hidden="true"></span>
<h4 class="module-name">arr</h4>
<span class="module-status readout" data-role="status">probing</span>
</header>
<p class="module-role">reconcile core</p>
<p class="module-detail readout" data-role="detail">decide · find · fetch · import</p>
</article>
<div class="bus area-out" aria-hidden="true">
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="master" aria-hidden="true">
<line x1="0" y1="4" x2="32" y2="4" />
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
</svg>
</div>
<article class="module area-qbittorrent" data-check="qbit">
<header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span>
<h4 class="module-name">qbittorrent</h4>
<span class="module-status readout" data-role="status">probing</span>
</header>
<p class="module-role">torrents out</p>
<p class="module-detail readout" data-role="detail">webui · grab and seed</p>
</article>
</div>
</section>
<section class="chain-lane" id="chain-subs" aria-label="subtitle lane" hidden></section>
</section>
<section class="deck-group" id="group-roots" hidden aria-labelledby="label-roots">
<header class="deck-head">
<h3 class="section-title" id="label-roots">roots</h3>
<button type="button" class="control control-quiet head-action" id="root-add">add root</button>
</header>
<ul class="deck-rows" id="rows-roots"></ul>
</section>
<section class="deck-group" id="group-policies" hidden aria-labelledby="label-policies">
<header class="deck-head">
<h3 class="section-title" id="label-policies">policies</h3>
<button type="button" class="control control-quiet head-action" id="policy-add">add policy</button>
</header>
<div class="group-note">
<p>Editing a policy rescores every title's releases straight away.</p>
</div>
<ul class="deck-rows" id="rows-policies"></ul>
</section>
<section class="deck-group" id="group-subtitles" hidden aria-labelledby="label-settings-subtitles">
<header class="deck-head">
<h3 class="section-title" id="label-settings-subtitles">subtitles</h3>
</header>
<div class="group-note" data-explain-group>
<p>Provider logins and translator keys are not shown here.</p>
<p class="group-note-more" hidden>
They live in the daemon's config and environment (§10), along with the remote-command
template.
</p>
</div>
<div id="subtitles-body"></div>
</section>
</main>
<!--
SERIES DETAIL (§4.1 + §4.2 + §9.3, issue 129): a series row anywhere
opens /series/{id} — the title line carries the audience chip, the
derived status and the wanted/on-disk count (season 0 excluded, per
§4.2). Seasons are collapsible groups: engraved name, the tracked
toggle (the one way an operator wants a season — no bulk backfill,
§4.1), counts, search and deck. Episodes read as tag, title, air date,
state, then the imported file's probed attribute chips — ffprobe truth,
not name claims — with want/search/deck only where they can do
something: an unaired episode offers no search button at all.
-->
<main class="deck detail" id="series" hidden aria-label="series detail">
<section class="hero" id="series-hero" aria-label="title metadata">
<header class="page-head">
<button type="button" class="control control-icon" id="series-back" aria-label="back" data-glyph="back"></button>
<h2 class="page-title" id="series-title" tabindex="-1">&nbsp;</h2>
<span class="page-year readout" id="series-year"></span>
</header>
<div class="hero-body">
<div class="hero-poster"><img class="movie-poster" id="series-poster" alt="" /></div>
<div class="hero-info">
<!-- audience, episode counts and §4.2 status: operational state,
outranking the artwork, folded into this one header (issue 150) -->
<span class="hero-chips" id="series-chips"></span>
<p class="hero-rating readout" id="series-rating"></p>
<p class="hero-meta readout" id="series-meta"></p>
<p class="hero-tagline" id="series-tagline"></p>
<p class="hero-overview" id="series-overview"></p>
</div>
</div>
<p class="r-note hero-note readout" id="series-note" role="status" hidden></p>
</section>
<div class="actionbar">
<span class="actionbar-links" id="series-actions"></span>
<button
type="button"
class="control control-icon"
id="series-more"
aria-label="library settings for this title"
aria-expanded="false"
aria-controls="series-more-panel"
data-glyph="more"
></button>
</div>
<div class="more-panel" id="series-more-panel" hidden>
<div class="more-links" id="series-links"></div>
<label class="field more-field">
<span class="field-label" id="series-root-label">root</span>
<select
class="form-input"
id="series-root"
data-focus-id="series-root"
aria-labelledby="series-root-label"
></select>
</label>
<button
type="button"
class="control control-quiet"
data-kind="delete"
id="series-remove"
aria-expanded="false"
aria-controls="series-remove-panel"
>
remove from library
</button>
<div class="remove-panel" id="series-remove-panel" hidden></div>
</div>
<p class="deck-status readout" id="series-status" role="status" data-empty="true"></p>
<section class="deck-group" aria-labelledby="label-seasons">
<header class="deck-head">
<h3 class="section-title" id="label-seasons">seasons</h3>
</header>
<ul class="deck-rows seasons" id="rows-seasons"></ul>
</section>
</main>
<main class="deck detail" id="tv-releases" hidden aria-label="season and episode releases">
<header class="page-head">
<button type="button" class="control control-icon" id="tv-releases-back" aria-label="back" data-glyph="back"></button>
<div class="page-id">
<h2 class="page-title" id="tv-releases-title">&nbsp;</h2>
<span class="page-sub readout" id="tv-releases-sub"></span>
</div>
</header>
<div class="actionbar">
<button type="button" class="control" id="tv-releases-sweep">search indexers</button>
</div>
<p class="deck-status readout" id="tv-releases-status" role="status" data-empty="true"></p>
<!-- issue 227: a pack that downloaded in full and was condemned at
import (§5.7). It outlives the status line because it is a fact
about the season, not about the request in flight. -->
<section class="deck-notice" id="tv-releases-failure" hidden aria-labelledby="tv-failure-label">
<h3 class="deck-label" id="tv-failure-label">pack abandoned at import</h3>
<p class="notice-release readout" id="tv-failure-release"></p>
<p class="notice-line" id="tv-failure-what"></p>
<p class="notice-line" id="tv-failure-next"></p>
<p class="notice-line dim">
Nothing was imported, and the torrent was left where it is: nothing is deleted early to
satisfy the library (§7.3).
</p>
</section>
<div class="buckets" id="tv-buckets"></div>
</main>
</body>
</html>