diff --git a/web/index.html b/web/index.html
index ef5ecfe..6d2267d 100644
--- a/web/index.html
+++ b/web/index.html
@@ -152,12 +152,14 @@
class="bucket-toggle readout"
id="library-toggle"
aria-expanded="false"
- aria-controls="library-series library-movies"
+ aria-controls="library-libraries library-series library-movies"
hidden
>
+
+
series
diff --git a/web/src/main.ts b/web/src/main.ts
index 035f05e..10cc0b8 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -3627,6 +3627,11 @@ interface LibraryGroup {
rows: HTMLUListElement;
}
+interface LibraryGridItem {
+ root_id: number;
+ item: HTMLLIElement;
+}
+
/* ---- §9.6 poster grid (#151) -------------------------------------------- */
type LibraryShape = "grid" | "list";
@@ -3658,6 +3663,7 @@ function libraryMain(
const status = must("#library-status");
const gridBtn = must("#library-view-grid");
const listBtn = must("#library-view-list");
+ const libraryRows = must("#library-libraries");
const groups: { series: LibraryGroup; movies: LibraryGroup } = {
series: {
section: must("#library-series"),
@@ -3708,6 +3714,8 @@ function libraryMain(
}
function clearGroups() {
+ libraryRows.replaceChildren();
+ libraryRows.hidden = true;
for (const group of [groups.series, groups.movies]) {
group.section.hidden = true;
group.rows.replaceChildren();
@@ -3726,6 +3734,43 @@ function libraryMain(
}
}
+ function renderLibraryRows(items: LibraryGridItem[]) {
+ libraryRows.replaceChildren();
+ libraryRows.hidden = items.length === 0;
+ const rows = [
+ ...roots.map((root) => ({ id: root.id, label: root.audience })),
+ ...[
+ ...new Set(
+ items
+ .filter((item) => !roots.some((root) => root.id === item.root_id))
+ .map((item) => item.root_id),
+ ),
+ ].map((id) => ({ id, label: `root ${id}` })),
+ ];
+ for (const root of rows) {
+ const titles = items.filter((item) => item.root_id === root.id);
+ if (titles.length === 0) {
+ continue;
+ }
+ const section = document.createElement("section");
+ section.className = "deck-group library-grid-group";
+ const heading = document.createElement("h3");
+ heading.className = "deck-label";
+ heading.textContent = root.label;
+ const count = document.createElement("span");
+ count.className = "deck-count readout";
+ count.textContent = String(titles.length);
+ const header = document.createElement("header");
+ header.className = "deck-head";
+ header.append(heading, count);
+ const rows = document.createElement("ul");
+ rows.className = "deck-rows grid-rows";
+ rows.append(...titles.map((item) => item.item));
+ section.append(header, rows);
+ libraryRows.append(section);
+ }
+ }
+
function render() {
const library = data;
if (!library) {
@@ -3754,9 +3799,21 @@ function libraryMain(
};
if (shape === "grid") {
- renderGroup(groups.series, seriesShown, (series) => seriesCard(series, roots, openSeries));
- renderGroup(groups.movies, moviesShown, (movie) => movieCard(movie, roots, openMovie));
+ groups.series.section.hidden = true;
+ groups.movies.section.hidden = true;
+ renderLibraryRows([
+ ...seriesShown.map((series) => ({
+ root_id: series.root_id,
+ item: seriesCard(series, openSeries),
+ })),
+ ...moviesShown.map((movie) => ({
+ root_id: movie.root_id,
+ item: movieCard(movie, openMovie),
+ })),
+ ]);
} else {
+ libraryRows.replaceChildren();
+ libraryRows.hidden = true;
renderGroup(groups.series, seriesShown, (series) => seriesRow(series, roots, openSeries));
renderGroup(groups.movies, moviesShown, (movie) => libraryRow(movie, roots, openMovie));
}
@@ -3865,15 +3922,14 @@ function seriesRow(
}
/**
- * One §9.6 grid tile (#151): poster at w342, title and year beneath, then
- * the chips the row view carries — a card is legible before it is pretty.
+ * One §9.6 grid tile (#151): poster, title and operational chips. The grid
+ * supplies the library context, so cards do not repeat it.
* A missing poster becomes a same-ratio placeholder carrying its title,
* so the grid never reflows and never has a hole in it.
*/
function libraryCard(
posterPath: string | null,
title: string,
- year: number | null,
chips: HTMLSpanElement[],
open: (origin: HTMLElement) => void,
): HTMLLIElement {
@@ -3911,15 +3967,9 @@ function libraryCard(
art.append(img);
}
- const id = document.createElement("span");
- id.className = "card-id";
const name = document.createElement("span");
name.className = "card-name";
name.textContent = title;
- const when = document.createElement("span");
- when.className = "card-year readout dim";
- when.textContent = year === null ? "—" : String(year);
- id.append(name, when);
const chipLine = document.createElement("span");
chipLine.className = "card-chips";
@@ -3929,7 +3979,7 @@ function libraryCard(
const body = document.createElement("span");
body.className = "card-body";
- body.append(id, chipLine);
+ body.append(name, chipLine);
card.append(art, body);
card.addEventListener("click", () => {
@@ -3941,11 +3991,9 @@ function libraryCard(
function seriesCard(
series: LibrarySeries,
- roots: Root[],
open: (id: number, origin: HTMLElement) => void,
): HTMLLIElement {
- const root = roots.find((candidate) => candidate.id === series.root_id);
- const chips = [chip(root ? root.audience : `root ${series.root_id}`)];
+ const chips: HTMLSpanElement[] = [];
if (series.wanted_episodes > 0) {
chips.push(
countsChip(
@@ -3966,22 +4014,14 @@ function seriesCard(
}),
);
}
- const rating = ratingChip(series.vote_average);
- if (rating !== null) {
- chips.push(rating);
- }
- return libraryCard(series.poster_path, series.title, series.year, chips, (origin) =>
- open(series.id, origin),
- );
+ return libraryCard(series.poster_path, series.title, chips, (origin) => open(series.id, origin));
}
function movieCard(
movie: LibraryMovie,
- roots: Root[],
open: (movie: LibraryMovie, origin: HTMLElement) => void,
): HTMLLIElement {
- const root = roots.find((candidate) => candidate.id === movie.root_id);
- const chips = [chip(root ? root.audience : `root ${movie.root_id}`)];
+ const chips: HTMLSpanElement[] = [];
chips.push(mediaStateChip(movie.state, movie.wanted));
const download = downloadChip("movie", movie.id);
if (download !== null) {
@@ -3997,13 +4037,7 @@ function movieCard(
if (movie.blocked) {
chips.push(chip(NO_TARGETED_SEARCH));
}
- const rating = ratingChip(movie.vote_average);
- if (rating !== null) {
- chips.push(rating);
- }
- return libraryCard(movie.poster_path, movie.title, movie.year, chips, (origin) =>
- open(movie, origin),
- );
+ return libraryCard(movie.poster_path, movie.title, chips, (origin) => open(movie, origin));
}
/* ---- tv release deck (§9.3 for season packs and single episodes) ------- */
diff --git a/web/src/style.css b/web/src/style.css
index ef174cd..48337bf 100644
--- a/web/src/style.css
+++ b/web/src/style.css
@@ -1480,11 +1480,11 @@ body {
border-color: var(--accent);
}
-/* auto-fill keeps every tile the same width down to a phone, where three
- narrow-but-legible columns beat two oversized ones */
+/* Wider tiles give artwork its due without forcing a desktop-style grid onto
+ a phone; the slightly wider crop avoids a wall of overly tall posters. */
.deck-rows.grid-rows {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
+ grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
gap: var(--space-4);
}
@@ -1517,7 +1517,7 @@ body {
/* the poster is framed evidence, like every other image on a panel */
.card-art {
display: block;
- aspect-ratio: 2 / 3;
+ aspect-ratio: 18 / 25;
}
.card-poster {
@@ -1556,15 +1556,7 @@ body {
padding: var(--space-2);
}
-.card-id {
- display: flex;
- align-items: baseline;
- gap: var(--space-1);
- min-width: 0;
-}
-
.card-name {
- flex: 1;
/* two clamped lines: a long title stays readable where a single
ellipsised line at tile width would not be */
display: -webkit-box;
@@ -1578,11 +1570,6 @@ body {
color: var(--ink);
}
-.card-year {
- flex: none;
- font-size: var(--text-2xs);
-}
-
.card-chips {
display: flex;
flex-wrap: wrap;