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