feat(web): library homepage, chain in settings

Implements the §9.7 shell rulings (issue #173): "/" renders the library
and the board route is gone — every former board target (Esc, cleared
search, detail parent routes, the not-found fallback) lands on the
library. The signal chain moves into /settings as its first section,
lamps still driven by the health poll; the master lamp stays on the
rail. The wordmark is now a real anchor that navigates home.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-24 16:39:07 +01:00
parent 60642d8709
commit 7ef24088a5
5 changed files with 143 additions and 173 deletions
+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();
}
},