Library views with derived status (#83)
This commit was merged in pull request #83.
This commit is contained in:
@@ -43,12 +43,28 @@
|
||||
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.
|
||||
-->
|
||||
<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>
|
||||
<nav class="rail-nav" aria-label="views">
|
||||
<button type="button" class="control" id="nav-library" aria-pressed="false">
|
||||
library
|
||||
</button>
|
||||
</nav>
|
||||
<div class="rail-search">
|
||||
<input
|
||||
id="search"
|
||||
@@ -95,6 +111,38 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main class="deck" id="library" hidden aria-label="library">
|
||||
<header class="deck-head library-bar">
|
||||
<h2 class="deck-label">library</h2>
|
||||
<span class="deck-count readout" id="library-summary"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="bucket-toggle readout"
|
||||
id="library-toggle"
|
||||
aria-expanded="false"
|
||||
aria-controls="library-series library-movies"
|
||||
hidden
|
||||
></button>
|
||||
</header>
|
||||
<p class="deck-status readout" id="library-status" role="status" hidden></p>
|
||||
|
||||
<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 releases" id="releases" hidden>
|
||||
<header class="releases-head">
|
||||
<button type="button" class="control" id="releases-back">back</button>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Hand-written mirror of arr-api's /api/series and /api/movies list schemas —
|
||||
// same reasoning as search.ts: the generated client (src/api/) is
|
||||
// uncommitted, so CI's tsc cannot see it.
|
||||
|
||||
import type { LibraryMovie } from "./search";
|
||||
|
||||
/** §4.2 derived status — displayed, never editable. */
|
||||
export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended";
|
||||
|
||||
export interface LibrarySeries {
|
||||
id: number;
|
||||
tmdb_id: number;
|
||||
title: string;
|
||||
year: number | null;
|
||||
original_language: string | null;
|
||||
root_id: number;
|
||||
auto_track: boolean;
|
||||
upstream_ended: boolean;
|
||||
blocked: boolean;
|
||||
status: SeriesStatus;
|
||||
wanted_episodes: number;
|
||||
available_episodes: number;
|
||||
}
|
||||
|
||||
export type LibraryOutcome =
|
||||
| { kind: "results"; series: LibrarySeries[]; movies: LibraryMovie[] }
|
||||
| { kind: "error"; detail: string };
|
||||
|
||||
export async function fetchLibrary(): Promise<LibraryOutcome> {
|
||||
try {
|
||||
const [series, movies] = await Promise.all([fetch("/api/series"), fetch("/api/movies")]);
|
||||
if (!series.ok) {
|
||||
return { kind: "error", detail: await errorDetail(series) };
|
||||
}
|
||||
if (!movies.ok) {
|
||||
return { kind: "error", detail: await errorDetail(movies) };
|
||||
}
|
||||
return {
|
||||
kind: "results",
|
||||
series: (await series.json()) as LibrarySeries[],
|
||||
movies: (await movies.json()) as LibraryMovie[],
|
||||
};
|
||||
} catch {
|
||||
return { kind: "error", detail: "daemon unreachable" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* §4.2: the default list shows `airing` and `incomplete`; everything else
|
||||
* collapses behind one toggle. A satisfied title leaves by itself.
|
||||
*/
|
||||
export function seriesNeedsAttention(series: LibrarySeries): boolean {
|
||||
return series.status === "airing" || series.status === "incomplete";
|
||||
}
|
||||
|
||||
/**
|
||||
* The movie analogue of §4.2's default set: wanted and not yet on disk.
|
||||
* Available and unwanted titles are satisfied and collapse with the rest.
|
||||
*/
|
||||
export function movieNeedsAttention(movie: LibraryMovie): boolean {
|
||||
return movie.wanted && movie.state !== "available";
|
||||
}
|
||||
|
||||
/**
|
||||
* The §5.7 waiver, worded honestly: a file imported under
|
||||
* `allow_english_audio` reads as "english, no dub", not as a clean match.
|
||||
* The import pipeline records the relaxed rule; both the bare rule name and
|
||||
* an object carrying one are accepted.
|
||||
*/
|
||||
export function waiverLabel(waiver: unknown): string | null {
|
||||
const rule = waiverRule(waiver);
|
||||
if (rule === null) {
|
||||
return null;
|
||||
}
|
||||
switch (rule) {
|
||||
case "required_audio":
|
||||
return "english, no dub";
|
||||
case "resolution":
|
||||
return "below wanted resolution";
|
||||
default:
|
||||
return `waived · ${rule.replaceAll("_", " ")}`;
|
||||
}
|
||||
}
|
||||
|
||||
function waiverRule(waiver: unknown): string | null {
|
||||
if (waiver === null || waiver === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof waiver === "string") {
|
||||
return waiver;
|
||||
}
|
||||
if (typeof waiver === "object" && "rule" in waiver && typeof waiver.rule === "string") {
|
||||
return waiver.rule;
|
||||
}
|
||||
return "unknown_rule";
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
+241
-10
@@ -1,4 +1,11 @@
|
||||
import { type CheckStatus, type Probe, probeHealth } from "./health";
|
||||
import {
|
||||
fetchLibrary,
|
||||
type LibrarySeries,
|
||||
movieNeedsAttention,
|
||||
seriesNeedsAttention,
|
||||
waiverLabel,
|
||||
} from "./library";
|
||||
import {
|
||||
bucketOf,
|
||||
formatAudio,
|
||||
@@ -19,6 +26,7 @@ import {
|
||||
} from "./releases";
|
||||
import {
|
||||
addMovie,
|
||||
allRoots,
|
||||
type LibraryMovie,
|
||||
movieRoots,
|
||||
parseManualInput,
|
||||
@@ -183,7 +191,9 @@ function main() {
|
||||
}, POLL_MS);
|
||||
void probe();
|
||||
|
||||
searchMain(board, releasesMain());
|
||||
const releases = releasesMain();
|
||||
const library = libraryMain(board, releases);
|
||||
searchMain(board, releases, library);
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
@@ -199,7 +209,7 @@ interface DeckRefs {
|
||||
manualIntake: HTMLElement;
|
||||
}
|
||||
|
||||
function searchMain(board: HTMLElement, releases: ReleasesView) {
|
||||
function searchMain(board: HTMLElement, releases: ReleasesView, library: LibraryView) {
|
||||
const input = must<HTMLInputElement>("#search");
|
||||
const hint = must<HTMLElement>("#search-hint");
|
||||
const refs: DeckRefs = {
|
||||
@@ -241,12 +251,14 @@ function searchMain(board: HTMLElement, releases: ReleasesView) {
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
releases.hide();
|
||||
library.hide();
|
||||
refs.deck.hidden = true;
|
||||
board.hidden = false;
|
||||
}
|
||||
|
||||
function showDeck() {
|
||||
releases.hide();
|
||||
library.hide();
|
||||
board.hidden = true;
|
||||
refs.deck.hidden = false;
|
||||
}
|
||||
@@ -310,7 +322,11 @@ function searchMain(board: HTMLElement, releases: ReleasesView) {
|
||||
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, releases.open));
|
||||
refs.groups.library.rows.append(
|
||||
libraryRow(movie, roots, (target, origin) => {
|
||||
releases.open(target, origin, refs.deck);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (response.tmdb.length > 0) {
|
||||
@@ -441,6 +457,15 @@ function libraryRow(
|
||||
span.dataset.movieState = movie.state;
|
||||
}),
|
||||
);
|
||||
// §5.7 honesty: a waived import is never presented as a clean match
|
||||
const waiver = waiverLabel(movie.waiver);
|
||||
if (waiver !== null) {
|
||||
chips.append(
|
||||
chip(waiver, (span) => {
|
||||
span.dataset.verdict = "waived";
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!movie.wanted) {
|
||||
chips.append(chip("not wanted"));
|
||||
}
|
||||
@@ -623,7 +648,8 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML
|
||||
/* ---- release deck (§9.3, issue #31) ---------------------------------- */
|
||||
|
||||
interface ReleasesView {
|
||||
open: (movie: LibraryMovie, origin: HTMLElement) => void;
|
||||
/** Opens over `returnTo`, which back and Esc restore. */
|
||||
open: (movie: LibraryMovie, origin: HTMLElement, returnTo: HTMLElement) => void;
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
@@ -662,6 +688,7 @@ function releasesMain(): ReleasesView {
|
||||
|
||||
let current: LibraryMovie | null = null;
|
||||
let origin: HTMLElement | null = null;
|
||||
let returnTo: HTMLElement = deck;
|
||||
const actions: ReleaseActions = {
|
||||
reload: () => load(),
|
||||
notify: (text, tone) => setStatus(text, tone),
|
||||
@@ -770,12 +797,13 @@ function releasesMain(): ReleasesView {
|
||||
render(outcome.releases);
|
||||
}
|
||||
|
||||
function open(movie: LibraryMovie, from: HTMLElement) {
|
||||
function open(movie: LibraryMovie, from: HTMLElement, container: HTMLElement) {
|
||||
current = movie;
|
||||
origin = from;
|
||||
returnTo = container;
|
||||
title.textContent = movie.title;
|
||||
year.textContent = movie.year === null ? "—" : String(movie.year);
|
||||
deck.hidden = true;
|
||||
container.hidden = true;
|
||||
view.hidden = false;
|
||||
clearBuckets();
|
||||
sweep.disabled = false;
|
||||
@@ -793,19 +821,19 @@ function releasesMain(): ReleasesView {
|
||||
function close() {
|
||||
const target = origin;
|
||||
hide();
|
||||
deck.hidden = false;
|
||||
returnTo.hidden = false;
|
||||
target?.focus();
|
||||
}
|
||||
|
||||
back.addEventListener("click", close);
|
||||
|
||||
// capture + stopPropagation: one Escape steps back one layer, instead
|
||||
// of falling through to the search deck's own Escape handler
|
||||
// capture + stopImmediatePropagation: one Escape steps back one layer —
|
||||
// the library and search decks also listen for Escape on this window
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.key === "Escape" && !view.hidden) {
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
close();
|
||||
}
|
||||
},
|
||||
@@ -980,6 +1008,209 @@ function releaseRow(
|
||||
return item;
|
||||
}
|
||||
|
||||
/* ---- library view (§4.2, issue #32) ----------------------------------- */
|
||||
|
||||
interface LibraryView {
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
interface LibraryGroup {
|
||||
section: HTMLElement;
|
||||
count: HTMLElement;
|
||||
rows: HTMLUListElement;
|
||||
}
|
||||
|
||||
function libraryMain(board: HTMLElement, releases: ReleasesView): LibraryView {
|
||||
const view = must<HTMLElement>("#library");
|
||||
const deck = must<HTMLElement>("#deck");
|
||||
const nav = must<HTMLButtonElement>("#nav-library");
|
||||
const summary = must<HTMLElement>("#library-summary");
|
||||
const toggle = must<HTMLButtonElement>("#library-toggle");
|
||||
const status = must<HTMLElement>("#library-status");
|
||||
const groups: { series: LibraryGroup; movies: LibraryGroup } = {
|
||||
series: {
|
||||
section: must<HTMLElement>("#library-series"),
|
||||
count: must<HTMLElement>("#count-lib-series"),
|
||||
rows: must<HTMLUListElement>("#rows-lib-series"),
|
||||
},
|
||||
movies: {
|
||||
section: must<HTMLElement>("#library-movies"),
|
||||
count: must<HTMLElement>("#count-lib-movies"),
|
||||
rows: must<HTMLUListElement>("#rows-lib-movies"),
|
||||
},
|
||||
};
|
||||
|
||||
// §4.2: ONE toggle for everything the default view leaves out
|
||||
let showAll = false;
|
||||
let data: { series: LibrarySeries[]; movies: LibraryMovie[] } | null = null;
|
||||
let roots: Root[] = [];
|
||||
// guards a stale fetch from painting over a newer view
|
||||
let sequence = 0;
|
||||
|
||||
function setStatus(text: string | null, tone?: "fault") {
|
||||
status.hidden = text === null;
|
||||
status.textContent = text ?? "";
|
||||
if (tone) {
|
||||
status.dataset.tone = tone;
|
||||
} else {
|
||||
delete status.dataset.tone;
|
||||
}
|
||||
}
|
||||
|
||||
function clearGroups() {
|
||||
for (const group of [groups.series, groups.movies]) {
|
||||
group.section.hidden = true;
|
||||
group.rows.replaceChildren();
|
||||
}
|
||||
summary.textContent = "";
|
||||
toggle.hidden = true;
|
||||
}
|
||||
|
||||
function renderGroup<T>(group: LibraryGroup, items: T[], build: (item: T) => HTMLLIElement) {
|
||||
group.rows.replaceChildren();
|
||||
group.section.hidden = items.length === 0;
|
||||
group.count.textContent = String(items.length);
|
||||
for (const item of items) {
|
||||
group.rows.append(build(item));
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const library = data;
|
||||
if (!library) {
|
||||
return;
|
||||
}
|
||||
const total = library.series.length + library.movies.length;
|
||||
const satisfied =
|
||||
library.series.filter((series) => !seriesNeedsAttention(series)).length +
|
||||
library.movies.filter((movie) => !movieNeedsAttention(movie)).length;
|
||||
|
||||
summary.textContent = total === 0 ? "" : `${total} ${total === 1 ? "title" : "titles"}`;
|
||||
toggle.hidden = satisfied === 0;
|
||||
toggle.textContent = `${showAll ? "hide" : "show"} ${satisfied} satisfied`;
|
||||
toggle.setAttribute("aria-expanded", String(showAll));
|
||||
|
||||
const seriesShown = showAll ? library.series : library.series.filter(seriesNeedsAttention);
|
||||
const moviesShown = showAll ? library.movies : library.movies.filter(movieNeedsAttention);
|
||||
renderGroup(groups.series, seriesShown, (series) => seriesRow(series, roots));
|
||||
renderGroup(groups.movies, moviesShown, (movie) =>
|
||||
libraryRow(movie, roots, (target, origin) => {
|
||||
releases.open(target, origin, view);
|
||||
}),
|
||||
);
|
||||
|
||||
if (total === 0) {
|
||||
setStatus("library is empty — the search box above adds titles");
|
||||
} else if (seriesShown.length + moviesShown.length === 0) {
|
||||
setStatus("nothing needs attention");
|
||||
} else {
|
||||
setStatus(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
sequence += 1;
|
||||
const ticket = sequence;
|
||||
setStatus("reading library…");
|
||||
const [outcome, fetchedRoots] = await Promise.all([
|
||||
fetchLibrary(),
|
||||
allRoots().catch(() => roots),
|
||||
]);
|
||||
if (ticket !== sequence) {
|
||||
return;
|
||||
}
|
||||
roots = fetchedRoots;
|
||||
if (outcome.kind === "error") {
|
||||
clearGroups();
|
||||
setStatus(`library unavailable — ${outcome.detail}`, "fault");
|
||||
return;
|
||||
}
|
||||
data = { series: outcome.series, movies: outcome.movies };
|
||||
render();
|
||||
}
|
||||
|
||||
function open() {
|
||||
releases.hide();
|
||||
board.hidden = true;
|
||||
deck.hidden = true;
|
||||
view.hidden = false;
|
||||
nav.setAttribute("aria-pressed", "true");
|
||||
void load();
|
||||
}
|
||||
|
||||
function hide() {
|
||||
view.hidden = true;
|
||||
nav.setAttribute("aria-pressed", "false");
|
||||
sequence += 1;
|
||||
}
|
||||
|
||||
function close() {
|
||||
hide();
|
||||
board.hidden = false;
|
||||
nav.focus();
|
||||
}
|
||||
|
||||
nav.addEventListener("click", () => {
|
||||
if (view.hidden) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
toggle.addEventListener("click", () => {
|
||||
showAll = !showAll;
|
||||
render();
|
||||
});
|
||||
|
||||
// capture, like the release deck: one Esc steps back one layer
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.key === "Escape" && !view.hidden) {
|
||||
event.stopImmediatePropagation();
|
||||
close();
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
return { hide };
|
||||
}
|
||||
|
||||
/**
|
||||
* One series with its §4.2 derived status: displayed, never editable.
|
||||
* No release deck yet — per-season and per-episode actions are issue #39.
|
||||
*/
|
||||
function seriesRow(series: LibrarySeries, 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 === series.root_id);
|
||||
chips.append(chip(root ? root.audience : `root ${series.root_id}`));
|
||||
if (series.wanted_episodes > 0) {
|
||||
chips.append(
|
||||
chip(`${series.available_episodes}/${series.wanted_episodes} eps`, (span) => {
|
||||
span.setAttribute(
|
||||
"aria-label",
|
||||
`${series.available_episodes} of ${series.wanted_episodes} wanted episodes on disk`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (series.blocked) {
|
||||
chips.append(chip("blocked"));
|
||||
}
|
||||
chips.append(
|
||||
chip(series.status, (span) => {
|
||||
span.dataset.status = series.status;
|
||||
}),
|
||||
);
|
||||
item.append(rowTitle(series.title, series.year), chips);
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderManual(intake: HTMLElement, kind: "magnet" | "torrent_url", raw: string) {
|
||||
const parsed = parseManualInput(kind, raw);
|
||||
intake.replaceChildren();
|
||||
|
||||
+11
-5
@@ -12,8 +12,10 @@ export interface LibraryMovie {
|
||||
original_language: string | null;
|
||||
root_id: number;
|
||||
wanted: boolean;
|
||||
state: "missing" | "grabbed" | "imported";
|
||||
state: "missing" | "downloading" | "available";
|
||||
blocked: boolean;
|
||||
/** The §5.7 rule relaxed to allow an import, when a file carries one. */
|
||||
waiver?: unknown;
|
||||
}
|
||||
|
||||
export interface TmdbMovie {
|
||||
@@ -64,8 +66,8 @@ export async function searchTitles(query: string, signal: AbortSignal): Promise<
|
||||
|
||||
let rootsCache: Root[] | null = null;
|
||||
|
||||
/** The movie roots, fetched once. The add flow pre-fills from these. */
|
||||
export async function movieRoots(): Promise<Root[]> {
|
||||
/** Every root, fetched once. Audience chips resolve root ids through these. */
|
||||
export async function allRoots(): Promise<Root[]> {
|
||||
if (rootsCache) {
|
||||
return rootsCache;
|
||||
}
|
||||
@@ -73,11 +75,15 @@ export async function movieRoots(): Promise<Root[]> {
|
||||
if (!response.ok) {
|
||||
throw new Error(await errorDetail(response));
|
||||
}
|
||||
const roots = (await response.json()) as Root[];
|
||||
rootsCache = roots.filter((root) => root.kind === "movie");
|
||||
rootsCache = (await response.json()) as Root[];
|
||||
return rootsCache;
|
||||
}
|
||||
|
||||
/** The movie roots. The add flow pre-fills from these. */
|
||||
export async function movieRoots(): Promise<Root[]> {
|
||||
return (await allRoots()).filter((root) => root.kind === "movie");
|
||||
}
|
||||
|
||||
export type AddOutcome =
|
||||
| { kind: "added"; movie: LibraryMovie }
|
||||
| { kind: "conflict" }
|
||||
|
||||
+64
-1
@@ -97,6 +97,11 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* a scrollbar appearing must not reflow the rail's wrap */
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
@@ -467,6 +472,12 @@ body {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* the engaged view control stays lit while its surface is up */
|
||||
.control[aria-pressed="true"] {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-bright);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
@@ -641,10 +652,34 @@ body {
|
||||
color: var(--verdict-rejected);
|
||||
}
|
||||
|
||||
.chip[data-movie-state="imported"] {
|
||||
.chip[data-movie-state="available"] {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* library status (§4.2): informational, never a severity ramp — activity in
|
||||
the channel-violet family, satisfaction in neutrals; the word is always
|
||||
present, so the state survives with colour removed */
|
||||
.chip[data-status="airing"] {
|
||||
color: var(--status-airing);
|
||||
border-color: oklch(from var(--status-airing) l c h / 45%);
|
||||
}
|
||||
|
||||
.chip[data-status="incomplete"] {
|
||||
color: var(--status-incomplete);
|
||||
}
|
||||
|
||||
.chip[data-status="waiting"] {
|
||||
color: var(--status-waiting);
|
||||
}
|
||||
|
||||
.chip[data-status="complete"] {
|
||||
color: var(--status-complete);
|
||||
}
|
||||
|
||||
.chip[data-status="ended"] {
|
||||
color: var(--status-ended);
|
||||
}
|
||||
|
||||
/* a TMDB row is the affordance: the whole row opens the add flow */
|
||||
.row-tmdb {
|
||||
font: inherit;
|
||||
@@ -952,6 +987,23 @@ body {
|
||||
font-size: var(--text-2xs);
|
||||
}
|
||||
|
||||
/* ---- library view (§4.2) ---------------------------------------------- */
|
||||
|
||||
.rail-nav {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* the library's master line: total readout left, the one §4.2 toggle right */
|
||||
.library-bar {
|
||||
margin: 0 0 var(--space-6);
|
||||
}
|
||||
|
||||
/* the toggle's own padding would inset its text past the chip column's
|
||||
right rag; drop it so SATISFIED sits flush with the chips below */
|
||||
.library-bar .bucket-toggle {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ---- manual intake ---------------------------------------------------- */
|
||||
|
||||
.intake .module-detail {
|
||||
@@ -977,6 +1029,17 @@ body {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* deterministic first row: identity, views, then meta pushed right —
|
||||
never re-wrapped by content or scrollbar changes */
|
||||
.rail-nav {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.rail-meta {
|
||||
order: 2;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.deck {
|
||||
padding: var(--space-6) var(--space-4) var(--space-8);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user