Merge #173: library is the homepage

Closes #173
This commit is contained in:
Miguel Palhas
2026-08-24 16:40:38 +01:00
5 changed files with 143 additions and 173 deletions
+75 -66
View File
@@ -63,12 +63,18 @@
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".
-->
<header class="rail">
<div class="rail-inner">
<div class="rail-id">
<span class="lamp" id="master-lamp" data-state="probing" aria-hidden="true"></span>
<h1 class="wordmark">arr</h1>
<a class="wordmark" id="wordmark" href="/">arr</a>
</div>
<nav class="rail-nav" aria-label="views">
<button type="button" class="control" id="nav-library" aria-pressed="false">
@@ -361,6 +367,74 @@
</header>
<p class="deck-status readout" id="settings-status" role="status" hidden></p>
<section class="deck-group" id="group-chain" aria-labelledby="label-chain">
<header class="deck-head">
<h3 class="deck-label" id="label-chain">signal chain</h3>
</header>
<section class="chain" aria-label="signal chain">
<article class="module area-tmdb" data-check="tmdb">
<header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span>
<h2 class="module-name">tmdb</h2>
<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>
<h2 class="module-name">prowlarr</h2>
<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>
<h2 class="module-name">arr</h2>
<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-transmission" data-check="transmission">
<header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span>
<h2 class="module-name">transmission</h2>
<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">rpc · grab and seed</p>
</article>
</section>
</section>
<section class="deck-group" id="group-roots" hidden aria-labelledby="label-roots">
<header class="deck-head bucket-head">
<h3 class="deck-label" id="label-roots">roots</h3>
@@ -437,70 +511,5 @@
<div id="tv-buckets"></div>
</main>
<main class="board">
<section class="chain" aria-label="signal chain">
<article class="module area-tmdb" data-check="tmdb">
<header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span>
<h2 class="module-name">tmdb</h2>
<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>
<h2 class="module-name">prowlarr</h2>
<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>
<h2 class="module-name">arr</h2>
<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-transmission" data-check="transmission">
<header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span>
<h2 class="module-name">transmission</h2>
<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">rpc · grab and seed</p>
</article>
</section>
</main>
</body>
</html>
+47 -67
View File
@@ -123,7 +123,7 @@ function moduleRefs(name: string): ModuleRefs {
const status = root?.querySelector<HTMLElement>('[data-role="status"]');
const detail = root?.querySelector<HTMLElement>('[data-role="detail"]');
if (!lamp || !status || !detail) {
throw new Error(`board is missing the ${name} module`);
throw new Error(`signal chain is missing the ${name} module`);
}
return { lamp, status, detail, defaultDetail: detail.textContent ?? "" };
}
@@ -138,13 +138,13 @@ function setModule(refs: ModuleRefs, state: string, tone: Tone, word: string, de
function must<T extends Element>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) {
throw new Error(`board markup is missing ${selector}`);
throw new Error(`markup is missing ${selector}`);
}
return element;
}
function main() {
const board = must<HTMLElement>(".board");
const chain = must<HTMLElement>(".chain");
const masterLamp = must<HTMLElement>("#master-lamp");
const version = must<HTMLElement>("#version");
@@ -185,13 +185,13 @@ function main() {
function render(probe: Probe) {
if (!powered) {
powered = true;
board.classList.add("powered");
chain.classList.add("powered");
}
if (probe.kind === "unreachable") {
masterLamp.dataset.state = "unreachable";
version.textContent = "v —";
// the outage detail lives on the board's arr module — the header has no verdict line
// the outage detail lives on the chain's arr module — the header has no verdict line
setModule(master, "unreachable", "fault", "down", probe.detail);
for (const refs of Object.values(checks)) {
setModule(refs, "off", "idle", "no signal");
@@ -249,13 +249,14 @@ function main() {
// the tv deck registers its Escape handler before the series view does,
// so one Esc always steps back from the innermost layer first
const tvDeck = tvReleasesMain();
const seriesDetail = seriesMain(board, tvDeck, views);
const movieDetail = movieMain(board, views);
const library = libraryMain(board, movieDetail, seriesDetail, views);
const queues = queuesMain(board, movieDetail, views);
const settings = settingsMain(board, views);
const seriesDetail = seriesMain(tvDeck, views);
const movieDetail = movieMain(views);
const library = libraryMain(movieDetail, seriesDetail, views);
const goHome = () => library.open();
const queues = queuesMain(movieDetail, views, goHome);
const settings = settingsMain(views, goHome);
views.push(tvDeck, seriesDetail, movieDetail, library, queues, settings);
const search = searchMain(board, movieDetail, seriesDetail, views);
const search = searchMain(movieDetail, seriesDetail, views, goHome);
// a removed title must not survive on the surface the page opened over
movieDetail.setRemoved((parent) => {
switch (parent.kind) {
@@ -269,7 +270,7 @@ function main() {
search.restore(parent.query);
break;
default:
search.showIdle();
library.open();
break;
}
});
@@ -282,11 +283,10 @@ function main() {
// and back/forward reach the same view a click would have.
async function applyRoute(route: Route) {
switch (route.kind) {
case "board":
search.showIdle();
break;
case "library":
library.open();
// §9.7: "/" is the library — clear any stale query so the rail
// input matches the route on a cold load or back-navigation
search.showIdle();
break;
case "queues":
queues.open();
@@ -401,6 +401,15 @@ function main() {
}
}
// §9.7: the wordmark is a link home — intercepted so it navigates the
// SPA instead of reloading, but still a real anchor for the keyboard
const wordmark = must<HTMLAnchorElement>("#wordmark");
wordmark.addEventListener("click", (event) => {
event.preventDefault();
navigate({ kind: "library" });
search.showIdle();
});
window.addEventListener("popstate", () => {
void applyRoute(currentRoute());
});
@@ -425,17 +434,17 @@ interface DeckRefs {
}
interface SearchView {
/** Restores the board with the search box empty — the "/" route. */
/** Restores the library with the search box empty — the "/" route. */
showIdle: () => void;
/** Restores the deck for `query` — the "/search?q=" route. */
restore: (query: string) => void;
}
function searchMain(
board: HTMLElement,
movieDetail: MovieView,
seriesDetail: SeriesView,
views: HideableView[],
goHome: () => void,
): SearchView {
const input = must<HTMLInputElement>("#search");
const hint = must<HTMLElement>("#search-hint");
@@ -492,21 +501,17 @@ function searchMain(
let timer: number | undefined;
let controller: AbortController | null = null;
function showBoard() {
/** A cleared search lands on the library — the "/" route (§9.7). */
function showHome() {
controller?.abort();
controller = null;
for (const view of views) {
view.hide();
}
refs.deck.hidden = true;
board.hidden = false;
goHome();
}
function showDeck() {
for (const view of views) {
view.hide();
}
board.hidden = true;
refs.deck.hidden = false;
}
@@ -602,8 +607,8 @@ function searchMain(
window.clearTimeout(timer);
const query = input.value.trim();
if (query === "") {
navigate({ kind: "board" });
showBoard();
navigate({ kind: "library" });
showHome();
clearGroups();
setStatus(null);
return;
@@ -632,8 +637,8 @@ function searchMain(
}
} else if (event.key === "Escape") {
input.value = "";
navigate({ kind: "board" });
showBoard();
navigate({ kind: "library" });
showHome();
clearGroups();
setStatus(null);
}
@@ -658,8 +663,8 @@ function searchMain(
input.select();
} else if (event.key === "Escape" && !refs.deck.hidden) {
input.value = "";
navigate({ kind: "board" });
showBoard();
navigate({ kind: "library" });
showHome();
clearGroups();
setStatus(null);
input.focus();
@@ -690,7 +695,7 @@ function searchMain(
function showIdle() {
input.value = "";
showBoard();
showHome();
clearGroups();
setStatus(null);
}
@@ -1309,7 +1314,7 @@ function externalLink(label: string, href: string): HTMLAnchorElement {
return link;
}
function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
function movieMain(views: HideableView[]): MovieView {
const view = must<HTMLElement>("#movie");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#movie-back");
@@ -1360,7 +1365,7 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
let removed: ((parent: Route) => void) | null = null;
let origin: HTMLElement | null = null;
let returnTo: HTMLElement | null = null;
let parentRoute: Route = { kind: "board" };
let parentRoute: Route = { kind: "library" };
const dom: BucketsDom = { eligible, waived: collapsed.waived, rejected: collapsed.rejected };
const actions: ReleaseActions = {
reload: () => reloadReleases(),
@@ -1907,7 +1912,6 @@ function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
origin = from;
returnTo = container;
parentRoute = parent;
board.hidden = true;
deckEl.hidden = true;
view.hidden = false;
clearBuckets(dom);
@@ -2373,7 +2377,6 @@ function loadLibraryShape(): LibraryShape {
}
function libraryMain(
board: HTMLElement,
movieDetail: MovieView,
seriesDetail: SeriesView,
views: HideableView[],
@@ -2523,7 +2526,6 @@ function libraryMain(
for (const sibling of views) {
sibling.hide();
}
board.hidden = true;
deck.hidden = true;
view.hidden = false;
nav.setAttribute("aria-pressed", "true");
@@ -2536,19 +2538,12 @@ function libraryMain(
sequence += 1;
}
function close() {
hide();
board.hidden = false;
nav.focus();
}
// §9.7: the library is the homepage — the bottom layer. There is nothing
// beneath it, so the nav control never toggles off and Esc has no handler.
nav.addEventListener("click", () => {
if (view.hidden) {
navigate({ kind: "library" });
open();
} else {
navigate({ kind: "board" });
close();
}
});
@@ -2557,19 +2552,6 @@ function libraryMain(
render();
});
// capture, like the release deck: one Esc steps back one layer
window.addEventListener(
"keydown",
(event) => {
if (event.key === "Escape" && !view.hidden) {
event.stopImmediatePropagation();
navigate({ kind: "board" });
close();
}
},
true,
);
return { hide, open };
}
@@ -2951,7 +2933,7 @@ function tvReleasesMain(): TvReleasesView {
function close() {
const current = request;
navigate(current?.parentRoute ?? { kind: "board" });
navigate(current?.parentRoute ?? { kind: "library" });
hide();
if (current) {
current.returnTo.hidden = false;
@@ -2992,7 +2974,7 @@ interface SeriesView {
const PAD_TWO = (value: number): string => String(value).padStart(2, "0");
function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const view = must<HTMLElement>("#series");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#series-back");
@@ -3450,7 +3432,6 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
parentRoute = parent;
expanded = new Set();
focusKey = null;
board.hidden = true;
deckEl.hidden = true;
view.hidden = false;
clearRichDetail();
@@ -3507,7 +3488,7 @@ interface QueueGroup {
rows: HTMLUListElement;
}
function queuesMain(board: HTMLElement, movieDetail: MovieView, views: HideableView[]): QueuesView {
function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () => void): QueuesView {
const view = must<HTMLElement>("#queues");
const deck = must<HTMLElement>("#deck");
const nav = must<HTMLButtonElement>("#nav-queues");
@@ -3649,7 +3630,6 @@ function queuesMain(board: HTMLElement, movieDetail: MovieView, views: HideableV
for (const sibling of views) {
sibling.hide();
}
board.hidden = true;
deck.hidden = true;
view.hidden = false;
nav.setAttribute("aria-pressed", "true");
@@ -3664,7 +3644,7 @@ function queuesMain(board: HTMLElement, movieDetail: MovieView, views: HideableV
function close() {
hide();
board.hidden = false;
goHome();
nav.focus();
}
@@ -3673,7 +3653,7 @@ function queuesMain(board: HTMLElement, movieDetail: MovieView, views: HideableV
navigate({ kind: "queues" });
open();
} else {
navigate({ kind: "board" });
navigate({ kind: "library" });
close();
}
});
@@ -3684,7 +3664,7 @@ function queuesMain(board: HTMLElement, movieDetail: MovieView, views: HideableV
(event) => {
if (event.key === "Escape" && !view.hidden) {
event.stopImmediatePropagation();
navigate({ kind: "board" });
navigate({ kind: "library" });
close();
}
},
+4 -9
View File
@@ -3,7 +3,6 @@
// state passed to pushState, so a manually edited or copy-pasted URL works.
export type Route =
| { kind: "board" }
| { kind: "library" }
| { kind: "queues" }
| { kind: "settings" }
@@ -16,9 +15,6 @@ export type Route =
export function parseRoute(url: URL): Route {
const segments = url.pathname.split("/").filter(Boolean);
if (segments.length === 1 && segments[0] === "library") {
return { kind: "library" };
}
if (segments.length === 1 && segments[0] === "queues") {
return { kind: "queues" };
}
@@ -27,7 +23,7 @@ export function parseRoute(url: URL): Route {
}
if (segments.length === 1 && segments[0] === "search") {
const query = url.searchParams.get("q") ?? "";
return query === "" ? { kind: "board" } : { kind: "search", query };
return query === "" ? { kind: "library" } : { kind: "search", query };
}
if (segments.length === 2 && segments[0] === "movies") {
const movieId = Number(segments[1]);
@@ -70,15 +66,14 @@ export function parseRoute(url: URL): Route {
return { kind: "episodeReleases", episodeId };
}
}
return { kind: "board" };
// §9.7: the library is the homepage — "/" and every unrecognised path land here
return { kind: "library" };
}
export function routePath(route: Route): string {
switch (route.kind) {
case "board":
return "/";
case "library":
return "/library";
return "/";
case "queues":
return "/queues";
case "settings":
+4 -5
View File
@@ -226,7 +226,7 @@ interface SettingsView {
open: () => void;
}
export function settingsMain(board: HTMLElement, views: { hide: () => void }[]): SettingsView {
export function settingsMain(views: { hide: () => void }[], goHome: () => void): SettingsView {
const view = must<HTMLElement>("#settings");
const deck = must<HTMLElement>("#deck");
const nav = must<HTMLButtonElement>("#nav-settings");
@@ -705,7 +705,6 @@ export function settingsMain(board: HTMLElement, views: { hide: () => void }[]):
for (const sibling of views) {
sibling.hide();
}
board.hidden = true;
deck.hidden = true;
view.hidden = false;
nav.setAttribute("aria-pressed", "true");
@@ -720,7 +719,7 @@ export function settingsMain(board: HTMLElement, views: { hide: () => void }[]):
function close() {
hide();
board.hidden = false;
goHome();
nav.focus();
}
@@ -729,7 +728,7 @@ export function settingsMain(board: HTMLElement, views: { hide: () => void }[]):
navigate({ kind: "settings" });
open();
} else {
navigate({ kind: "board" });
navigate({ kind: "library" });
close();
}
});
@@ -748,7 +747,7 @@ export function settingsMain(board: HTMLElement, views: { hide: () => void }[]):
(event) => {
if (event.key === "Escape" && !view.hidden) {
event.stopImmediatePropagation();
navigate({ kind: "board" });
navigate({ kind: "library" });
close();
}
},
+13 -26
View File
@@ -156,6 +156,8 @@ body {
gap: var(--space-3);
}
/* §9.7: the wordmark is a link home — quiet at rest, cyan on approach
like every other interactive control */
.wordmark {
margin: 0;
font-family: var(--font-display);
@@ -163,6 +165,13 @@ body {
font-weight: 700;
letter-spacing: 0.1em;
line-height: 1;
color: var(--ink);
text-decoration: none;
}
.wordmark:hover,
.wordmark:focus-visible {
color: var(--accent-bright);
}
.rail-meta {
@@ -247,44 +256,26 @@ body {
}
}
.board.powered .lamp {
.chain.powered .lamp {
animation: lamp-strike 420ms var(--ease-out) both;
animation-delay: calc(var(--strike, 0) * 120ms);
}
.board.powered .lamp[data-state="off"] {
.chain.powered .lamp[data-state="off"] {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.lamp[data-state="probing"],
.board.powered .lamp {
.chain.powered .lamp {
animation: none;
}
}
/* ---- board and chain ------------------------------------------------- */
.board {
width: 100%;
max-width: 68rem;
margin: 0 auto;
padding: var(--space-12) var(--space-6);
flex: 1;
display: flex;
align-items: center;
}
/* the deck swaps in over the board; flex would beat [hidden] otherwise */
.board[hidden] {
display: none;
}
/* ---- signal chain (§9.7: a settings section, no route of its own) ----- */
.chain {
width: 100%;
}
.chain {
display: grid;
grid-template-columns: 1fr 2.5rem 1.2fr 2.5rem 1fr;
grid-template-areas:
@@ -1813,10 +1804,6 @@ body {
flex: 1;
}
.board {
padding: var(--space-6) var(--space-4);
}
.chain {
grid-template-columns: 1fr;
grid-template-areas: