feat(web): movie detail page at /movies/{id}

This commit is contained in:
Miguel Palhas
2026-08-24 00:00:18 +01:00
parent 8af85606a0
commit fdaaeccb40
7 changed files with 1181 additions and 181 deletions
+97 -19
View File
@@ -183,33 +183,110 @@
<span class="deck-count readout" id="count-decision"></span>
</header>
<p class="queue-note readout dim">
hard-failed twice on different releases — open a movie's releases and decide by hand;
hard-failed twice on different releases — open the movie's page and decide by hand;
series entries are display-only for now.
</p>
<ul class="deck-rows" id="rows-decision"></ul>
</section>
</main>
<main class="deck releases" id="releases" hidden>
<!--
MOVIE PAGE (§9.6, issue 149): a library row opens /movies/{id} — one
machined module carries what the film IS: backdrop hotlinked from TMDB
under a scrim that sinks it into the console charcoal, poster framed at
left, Rajdhani title with year, TMDB rating and vote count, runtime and
genres, the tagline quoted, overview below. TRAILER resolves from the
metadata response's own key (no second call); TMDB, IMDb and Rotten
Tomatoes sit beside it — RT is a search URL built here, never a resolved
page. LIBRARY controls follow on their own row: wanted, blocked, root,
then search indexers and remove. CAST is the top 10 billed, each photo
linking out to that person's TMDB page. ON DISK lists imported files
with ffprobe attribute tags and any §5.7 waiver, worded honestly. The
§9.3 deck closes the page unchanged — buckets, chips, score heat and
rejection rules untouched; /movies/{id}/releases lands here with the
deck section in view.
-->
<main class="deck" id="movie" hidden aria-label="movie detail">
<header class="releases-head">
<button type="button" class="control" id="releases-back">back</button>
<div class="releases-id">
<h2 class="releases-title" id="releases-title"></h2>
<span class="releases-year readout dim" id="releases-year"></span>
</div>
<button type="button" class="control" id="releases-sweep">search indexers</button>
<button
type="button"
class="control control-quiet"
id="releases-remove"
aria-expanded="false"
aria-controls="remove-panel"
>
remove
</button>
<button type="button" class="control" id="movie-back">back</button>
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
</header>
<div class="remove-panel" id="remove-panel" hidden></div>
<p class="deck-status readout" id="releases-status" role="status" hidden></p>
<section class="module movie-hero" id="movie-hero" aria-label="title metadata">
<div class="movie-body">
<img class="movie-poster" id="movie-poster" alt="" hidden />
<div class="movie-info">
<div class="movie-idline">
<h2 class="movie-title" id="movie-title"></h2>
<span class="movie-year readout dim" id="movie-year"></span>
</div>
<span class="row-chips movie-chips" id="movie-chips"></span>
<p class="movie-rating readout" id="movie-rating" hidden></p>
<p class="movie-meta readout dim" id="movie-meta" hidden></p>
<p class="movie-tagline" id="movie-tagline" hidden></p>
<p class="movie-overview" id="movie-overview" hidden></p>
<div class="movie-actions" id="movie-actions"></div>
</div>
</div>
</section>
<section class="deck-group" id="movie-controls" aria-label="library controls">
<header class="deck-head">
<h3 class="deck-label">library</h3>
</header>
<div class="movie-controls">
<button type="button" class="control control-quiet" id="movie-wanted" aria-pressed="false">
wanted
</button>
<button
type="button"
class="control control-quiet"
id="movie-blocked"
aria-pressed="false"
>
blocked
</button>
<label class="movie-root-field">
<span class="field-label readout dim" id="movie-root-label">root</span>
<select class="form-input movie-root" id="movie-root" aria-labelledby="movie-root-label">
</select>
</label>
<span class="movie-controls-space"></span>
<button type="button" class="control" id="movie-sweep">search indexers</button>
<button
type="button"
class="control control-quiet"
id="movie-remove"
aria-expanded="false"
aria-controls="remove-panel"
>
remove
</button>
</div>
<div class="remove-panel" id="remove-panel" hidden></div>
</section>
<section class="deck-group" id="movie-cast" hidden aria-labelledby="label-cast">
<header class="deck-head">
<h3 class="deck-label" id="label-cast">cast</h3>
</header>
<ul class="cast-grid" id="rows-cast"></ul>
</section>
<section class="deck-group" id="movie-files" hidden aria-labelledby="label-disk">
<header class="deck-head">
<h3 class="deck-label" id="label-disk">on disk</h3>
<span class="deck-count readout" id="count-disk"></span>
</header>
<ul class="deck-rows" id="rows-disk"></ul>
</section>
<section class="deck-group" id="movie-releases" aria-labelledby="label-releases">
<header class="deck-head">
<h3 class="deck-label" id="label-releases">releases</h3>
</header>
<p class="deck-status readout" id="releases-status" role="status" hidden></p>
<section class="deck-group" id="bucket-eligible" hidden aria-labelledby="label-eligible">
<header class="deck-head">
@@ -277,6 +354,7 @@
<ul class="deck-rows" id="rows-rejected"></ul>
</div>
</section>
</section>
</main>
<main class="deck" id="settings" hidden aria-label="settings">
+600 -140
View File
@@ -6,6 +6,21 @@ import {
seriesNeedsAttention,
waiverLabel,
} from "./library";
import {
fileName,
formatMetaLine,
formatRating,
formatVoteCount,
imdbLink,
type MetadataCastMember,
movieMetadata,
rottenTomatoesSearch,
tmdbImage,
tmdbMovieLink,
tmdbPersonLink,
updateMovie,
youtubeLink,
} from "./movie";
import {
type AttentionMovie,
type AttentionQueues,
@@ -17,6 +32,7 @@ import {
} from "./queues";
import {
bucketOf,
type FilesOutcome,
formatAudio,
formatHdr,
formatResolution,
@@ -32,6 +48,7 @@ import {
movieFiles,
movieReleases,
movieSearchState,
probedAttributeTags,
queueSearch,
removeMovie,
ruleLabel,
@@ -230,18 +247,18 @@ function main() {
// views hide each other on open; the array is shared and filled once
const views: HideableView[] = [];
const releases = releasesMain();
// 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 library = libraryMain(board, releases, seriesDetail, views);
const queues = queuesMain(board, releases, views);
const movieDetail = movieMain(board, views);
const library = libraryMain(board, movieDetail, seriesDetail, views);
const queues = queuesMain(board, movieDetail, views);
const settings = settingsMain(board, views);
views.push(tvDeck, seriesDetail, library, queues, settings);
const search = searchMain(board, releases, seriesDetail, views);
// a removed title must not survive on the surface the deck opened over
releases.setRemoved((parent) => {
views.push(tvDeck, seriesDetail, movieDetail, library, queues, settings);
const search = searchMain(board, movieDetail, seriesDetail, views);
// a removed title must not survive on the surface the page opened over
movieDetail.setRemoved((parent) => {
switch (parent.kind) {
case "library":
library.open();
@@ -281,6 +298,7 @@ function main() {
case "search":
search.restore(route.query);
break;
case "movie":
case "releases": {
const movie = await fetchMovie(route.movieId);
if (!movie) {
@@ -291,9 +309,19 @@ function main() {
// no origin click to restore focus to on a deep link — the library
// rail button is the closest stand-in
library.open();
releases.open(movie, must<HTMLElement>("#nav-library"), must<HTMLElement>("#library"), {
kind: "library",
});
await movieDetail.open(
route.movieId,
must<HTMLElement>("#nav-library"),
must<HTMLElement>("#library"),
{
kind: "library",
},
);
// `/movies/{id}/releases` keeps resolving (#149): same page, deck
// section in view
if (route.kind === "releases") {
must<HTMLElement>("#movie-releases").scrollIntoView({ block: "start" });
}
break;
}
case "series": {
@@ -406,7 +434,7 @@ interface SearchView {
function searchMain(
board: HTMLElement,
releases: ReleasesView,
movieDetail: MovieView,
seriesDetail: SeriesView,
views: HideableView[],
): SearchView {
@@ -453,13 +481,21 @@ function searchMain(
});
};
/** A movie hit opens its §9.6 page (#149); the deck section is on it. */
const openMovie = (movie: LibraryMovie, origin: HTMLElement) => {
navigate({ kind: "movie", movieId: movie.id });
void movieDetail.open(movie.id, origin, refs.deck, {
kind: "search",
query: input.value.trim(),
});
};
let timer: number | undefined;
let controller: AbortController | null = null;
function showBoard() {
controller?.abort();
controller = null;
releases.hide();
for (const view of views) {
view.hide();
}
@@ -468,7 +504,6 @@ function searchMain(
}
function showDeck() {
releases.hide();
for (const view of views) {
view.hide();
}
@@ -546,12 +581,7 @@ function searchMain(
refs.groups.library.section.hidden = false;
refs.groups.library.count.textContent = String(response.library.length);
for (const movie of libraryMovies) {
refs.groups.library.rows.append(
libraryRow(movie, roots, (target, origin) => {
navigate({ kind: "releases", movieId: target.id });
releases.open(target, origin, refs.deck, { kind: "search", query: input.value.trim() });
}),
);
refs.groups.library.rows.append(libraryRow(movie, roots, openMovie));
}
for (const series of librarySeries) {
refs.groups.library.rows.append(librarySeriesRow(series, roots, openSeries));
@@ -736,7 +766,7 @@ function libraryRow(
}
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "releases";
affordance.textContent = "open";
chips.append(affordance);
row.append(rowTitle(movie.title, movie.year), chips);
row.addEventListener("click", () => {
@@ -1024,19 +1054,23 @@ function addPanel(
return panel;
}
/* ---- release deck (§9.3, issue #31) ---------------------------------- */
/* ---- movie page (§9.6, issue #149) ------------------------------------ */
interface ReleasesView {
/** Opens over `returnTo`, which back and Esc restore; `parentRoute` is where they navigate. */
interface MovieView {
hide: () => void;
/**
* Opens `/movies/{id}` over `returnTo`, which back and Esc restore;
* `parentRoute` is where they navigate. The deep link passes the library
* surface, exactly as a series deep link does.
*/
open: (
movie: LibraryMovie,
movieId: number,
origin: HTMLElement,
returnTo: HTMLElement,
parentRoute: Route,
) => void;
hide: () => void;
) => Promise<void>;
/**
* Called with the deck's parent route once a title is removed, so the
* Called with the page's parent route once a title is removed, so the
* surface underneath repaints instead of listing something that is gone.
*/
setRemoved: (handler: (parent: Route) => void) => void;
@@ -1085,16 +1119,33 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) {
});
}
function releasesMain(): ReleasesView {
const view = must<HTMLElement>("#releases");
const deck = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#releases-back");
const title = must<HTMLElement>("#releases-title");
const year = must<HTMLElement>("#releases-year");
const sweep = must<HTMLButtonElement>("#releases-sweep");
const remove = must<HTMLButtonElement>("#releases-remove");
function movieMain(board: HTMLElement, views: HideableView[]): MovieView {
const view = must<HTMLElement>("#movie");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#movie-back");
const statusEl = must<HTMLElement>("#movie-status");
const hero = must<HTMLElement>("#movie-hero");
const poster = must<HTMLImageElement>("#movie-poster");
const titleEl = must<HTMLElement>("#movie-title");
const yearEl = must<HTMLElement>("#movie-year");
const chipsEl = must<HTMLElement>("#movie-chips");
const ratingEl = must<HTMLElement>("#movie-rating");
const metaEl = must<HTMLElement>("#movie-meta");
const taglineEl = must<HTMLElement>("#movie-tagline");
const overviewEl = must<HTMLElement>("#movie-overview");
const actionsEl = must<HTMLElement>("#movie-actions");
const wanted = must<HTMLButtonElement>("#movie-wanted");
const blocked = must<HTMLButtonElement>("#movie-blocked");
const rootSelect = must<HTMLSelectElement>("#movie-root");
const sweep = must<HTMLButtonElement>("#movie-sweep");
const remove = must<HTMLButtonElement>("#movie-remove");
const removeWrap = must<HTMLElement>("#remove-panel");
const status = must<HTMLElement>("#releases-status");
const castSection = must<HTMLElement>("#movie-cast");
const castRows = must<HTMLUListElement>("#rows-cast");
const filesSection = must<HTMLElement>("#movie-files");
const diskCount = must<HTMLElement>("#count-disk");
const diskRows = must<HTMLUListElement>("#rows-disk");
const releaseStatus = must<HTMLElement>("#releases-status");
const eligible: BucketsDom["eligible"] = {
section: must<HTMLElement>("#bucket-eligible"),
count: must<HTMLElement>("#count-eligible"),
@@ -1115,19 +1166,21 @@ function releasesMain(): ReleasesView {
},
} as const;
let movieId: number | null = null;
let current: LibraryMovie | null = null;
let roots: Root[] = [];
let removed: ((parent: Route) => void) | null = null;
let origin: HTMLElement | null = null;
let returnTo: HTMLElement = deck;
let returnTo: HTMLElement | null = null;
let parentRoute: Route = { kind: "board" };
const dom: BucketsDom = { eligible, waived: collapsed.waived, rejected: collapsed.rejected };
const actions: ReleaseActions = {
reload: () => load(),
notify: (text, tone) => setStatus(text, tone),
reload: () => reloadReleases(),
notify: (text, tone) => setReleaseStatus(text, tone),
grab: async (release, bucket) => {
const movie = current;
if (!movie) {
return { kind: "error", detail: "deck closed", overrideWritten: false };
return { kind: "error", detail: "page closed", overrideWritten: false };
}
if (bucket === "waived") {
return waiveAndGrab(movie.id, release.id, release.rejected_rule);
@@ -1148,57 +1201,421 @@ function releasesMain(): ReleasesView {
const SWEEP_WAIT_MS = 150_000;
function setStatus(text: string | null, tone?: "fault", busy = false) {
status.hidden = text === null;
statusEl.hidden = text === null;
if (busy && text !== null) {
const lamp = document.createElement("span");
lamp.className = "lamp";
lamp.dataset.state = "probing";
status.replaceChildren(lamp, document.createTextNode(text));
statusEl.replaceChildren(lamp, document.createTextNode(text));
} else {
status.textContent = text ?? "";
statusEl.textContent = text ?? "";
}
if (tone) {
status.dataset.tone = tone;
statusEl.dataset.tone = tone;
} else {
delete status.dataset.tone;
delete statusEl.dataset.tone;
}
}
function render(releases: MovieRelease[]) {
/** The releases section speaks beside its own buckets, not from the page top. */
function setReleaseStatus(text: string | null, tone?: "fault", busy = false) {
releaseStatus.hidden = text === null;
if (busy && text !== null) {
const lamp = document.createElement("span");
lamp.className = "lamp";
lamp.dataset.state = "probing";
releaseStatus.replaceChildren(lamp, document.createTextNode(text));
} else {
releaseStatus.textContent = text ?? "";
}
if (tone) {
releaseStatus.dataset.tone = tone;
} else {
delete releaseStatus.dataset.tone;
}
}
/* ---- identity: stored fields first, rich detail on top ---- */
function paintIdentity() {
const movie = current;
if (!movie) {
return;
}
titleEl.textContent = movie.title;
yearEl.textContent = movie.year === null ? "" : String(movie.year);
chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === movie.root_id);
chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`));
chipsEl.append(
chip(movie.state, (span) => {
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) {
chipsEl.append(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
}
if (movie.blocked) {
chipsEl.append(chip("blocked"));
}
}
function paintRootOptions() {
rootSelect.replaceChildren();
for (const root of roots.filter((candidate) => candidate.kind === "movie")) {
const option = document.createElement("option");
option.value = String(root.id);
option.textContent = `${root.audience} · ${root.policy_name}`;
rootSelect.append(option);
}
}
function paintControls() {
const movie = current;
if (!movie) {
return;
}
wanted.setAttribute("aria-pressed", String(movie.wanted));
blocked.setAttribute("aria-pressed", String(movie.blocked));
paintRootOptions();
// a root list that does not carry the title's own root still shows it —
// a select lying by omission would make the next change move it blindly
if (rootSelect.value !== String(movie.root_id)) {
const option = document.createElement("option");
option.value = String(movie.root_id);
option.textContent = `root ${movie.root_id}`;
rootSelect.append(option);
}
rootSelect.value = String(movie.root_id);
sweep.disabled = false;
}
async function refreshMovie() {
const id = movieId;
if (id === null) {
return;
}
const fresh = await fetchMovie(id);
if (fresh === null || movieId !== id) {
return;
}
current = fresh;
paintIdentity();
paintControls();
}
wanted.addEventListener("click", () => {
const movie = current;
if (!movie) {
return;
}
wanted.disabled = true;
void updateMovie(movie.id, { wanted: !movie.wanted }).then((outcome) => {
if (outcome.kind === "error") {
wanted.disabled = false;
setStatus(`wanted failed — ${outcome.detail}`, "fault");
return;
}
void refreshMovie().then(() => {
wanted.disabled = false;
});
});
});
blocked.addEventListener("click", () => {
const movie = current;
if (!movie) {
return;
}
blocked.disabled = true;
void updateMovie(movie.id, { blocked: !movie.blocked }).then((outcome) => {
if (outcome.kind === "error") {
blocked.disabled = false;
setStatus(`blocked failed — ${outcome.detail}`, "fault");
return;
}
void refreshMovie().then(() => {
blocked.disabled = false;
});
});
});
rootSelect.addEventListener("change", () => {
const movie = current;
if (!movie) {
return;
}
const next = Number(rootSelect.value);
if (!Number.isInteger(next) || next === movie.root_id) {
return;
}
rootSelect.disabled = true;
void updateMovie(movie.id, { root_id: next }).then((outcome) => {
rootSelect.disabled = false;
if (outcome.kind === "error") {
rootSelect.value = String(movie.root_id);
setStatus(`root change failed — ${outcome.detail}`, "fault");
return;
}
void refreshMovie();
});
});
/* ---- §9.6 rich detail: one request, images hotlinked ---- */
function clearRichDetail() {
hero.classList.remove("has-backdrop");
hero.style.removeProperty("--backdrop");
poster.hidden = true;
poster.removeAttribute("src");
ratingEl.hidden = true;
ratingEl.replaceChildren();
metaEl.hidden = true;
taglineEl.hidden = true;
overviewEl.hidden = true;
actionsEl.replaceChildren();
castSection.hidden = true;
castRows.replaceChildren();
}
/** The drawn star before a TMDB rating — no glyph standing in for an icon. */
function starIcon(): SVGSVGElement {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 12 12");
svg.setAttribute("class", "star");
svg.setAttribute("aria-hidden", "true");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute(
"d",
"M6 0.8 L7.5 4.2 L11.2 4.6 L8.4 7.1 L9.2 10.8 L6 8.9 L2.8 10.8 L3.6 7.1 L0.8 4.6 L4.5 4.2 Z",
);
svg.append(path);
return svg;
}
/** External links read as quiet controls; they leave the app entirely. */
function externalLink(label: string, href: string): HTMLAnchorElement {
const link = document.createElement("a");
link.className = "control control-quiet";
link.href = href;
link.target = "_blank";
link.rel = "noreferrer";
link.textContent = label;
return link;
}
function castItem(member: MetadataCastMember): HTMLLIElement {
const item = document.createElement("li");
item.className = "cast-item";
const link = document.createElement("a");
link.className = "cast-link";
link.href = tmdbPersonLink(member.tmdb_id);
link.target = "_blank";
link.rel = "noreferrer";
const photo = document.createElement("span");
photo.className = "cast-photo";
const image = tmdbImage(member.profile_path, "w185");
if (image !== null) {
const img = document.createElement("img");
img.src = image;
img.alt = "";
img.loading = "lazy";
photo.append(img);
} else {
photo.classList.add("cast-photo-none");
}
const name = document.createElement("span");
name.className = "cast-name";
name.textContent = member.name;
const character = document.createElement("span");
character.className = "cast-character readout dim";
character.textContent = member.character;
link.setAttribute("aria-label", `${member.name} as ${member.character} — on TMDB`);
link.append(photo, name, character);
link.title = `${member.name}${member.character}`;
item.append(link);
return item;
}
async function loadMetadata(id: number, ticket: number) {
const outcome = await movieMetadata(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "error") {
// the stored identity stands; the actions row says why the rest is absent
const note = document.createElement("p");
note.className = "add-note readout";
note.dataset.tone = "fault";
note.textContent = `metadata unavailable — ${outcome.detail}`;
actionsEl.append(note);
return;
}
const detail = outcome.metadata;
const posterUrl = tmdbImage(detail.poster_path, "w342");
if (posterUrl !== null && current !== null) {
poster.src = posterUrl;
poster.alt = `${current.title} poster`;
poster.hidden = false;
}
const backdropUrl = tmdbImage(detail.backdrop_path, "w1280");
if (backdropUrl !== null) {
hero.classList.add("has-backdrop");
hero.style.setProperty("--backdrop", `url("${backdropUrl}")`);
}
if (detail.vote_count > 0) {
ratingEl.hidden = false;
ratingEl.append(starIcon(), document.createTextNode(`${formatRating(detail.vote_average)} `));
const votes = document.createElement("span");
votes.className = "dim";
votes.textContent = `(${formatVoteCount(detail.vote_count)})`;
ratingEl.append(votes);
}
const metaLine = formatMetaLine(detail.runtime, detail.genres);
if (metaLine !== "") {
metaEl.hidden = false;
metaEl.textContent = metaLine;
}
if (detail.tagline !== null && detail.tagline !== "") {
taglineEl.hidden = false;
taglineEl.textContent = `${detail.tagline}`;
}
if (detail.overview !== null && detail.overview !== "") {
overviewEl.hidden = false;
overviewEl.textContent = detail.overview;
}
// the trailer resolves from this response's own key — no second call.
// Hidden when absent rather than shown dead (§9.6).
if (detail.trailer !== null) {
const trailer = document.createElement("a");
trailer.className = "control";
trailer.href = youtubeLink(detail.trailer.youtube_key);
trailer.target = "_blank";
trailer.rel = "noreferrer";
trailer.textContent = "trailer";
actionsEl.append(trailer);
}
actionsEl.append(externalLink("tmdb", tmdbMovieLink(detail.tmdb_id)));
if (detail.imdb_id !== null) {
actionsEl.append(externalLink("imdb", imdbLink(detail.imdb_id)));
}
if (current !== null) {
actionsEl.append(
externalLink("rotten tomatoes", rottenTomatoesSearch(current.title, current.year)),
);
}
if (detail.cast.length > 0 && current !== null) {
castSection.hidden = false;
for (const member of detail.cast) {
castRows.append(castItem(member));
}
}
}
/* ---- on disk: ffprobe truth and honest waivers (§5.6, §5.7) ---- */
function paintFiles(outcome: FilesOutcome) {
diskRows.replaceChildren();
filesSection.hidden = false;
if (outcome.kind === "error") {
diskCount.textContent = "";
const row = document.createElement("li");
row.className = "rel rel-none readout";
row.dataset.tone = "fault";
row.textContent = `files unreadable — ${outcome.detail}`;
diskRows.append(row);
return;
}
const files = outcome.files;
diskCount.textContent = String(files.length);
if (files.length === 0) {
const row = document.createElement("li");
row.className = "rel rel-none readout dim";
row.textContent = "nothing on disk yet — imports land here";
diskRows.append(row);
return;
}
for (const file of files) {
const item = document.createElement("li");
item.className = "rel disk-row";
const line = document.createElement("div");
line.className = "rel-line";
const name = document.createElement("span");
name.className = "disk-name readout";
name.textContent = fileName(file.path);
name.title = file.path;
line.append(name);
for (const attribute of probedAttributeTags(file.probed)) {
line.append(chip(attribute));
}
line.append(chip(formatSize(file.size)));
const waiver = waiverLabel(file.waiver);
if (waiver !== null) {
line.append(
chip(waiver, (span) => {
span.dataset.verdict = "waived";
}),
);
}
item.append(line);
diskRows.append(item);
}
}
/* ---- releases: the §9.3 deck, unchanged, as the closing section ---- */
function render(releases: MovieRelease[]) {
if (!current) {
return;
}
if (!paintBuckets(dom, releases, actions)) {
return;
}
setStatus(null);
setReleaseStatus(null);
}
async function load() {
const movie = current;
if (!movie) {
async function loadReleases(id: number, ticket: number) {
window.clearTimeout(pollTimer);
setReleaseStatus("reading releases…");
const outcome = await movieReleases(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "error") {
clearBuckets(dom);
setReleaseStatus(`releases unavailable — ${outcome.detail}`, "fault");
return;
}
if (outcome.releases.length === 0) {
clearBuckets(dom);
await emptyVerdict(id, ticket);
return;
}
render(outcome.releases);
}
/** Refetch verdicts only — the reload an override-written grab triggers. */
async function reloadReleases() {
const id = movieId;
if (id === null) {
return;
}
sequence += 1;
const ticket = sequence;
window.clearTimeout(pollTimer);
setStatus("reading releases…");
const outcome = await movieReleases(movie.id);
if (ticket !== sequence || current !== movie) {
const outcome = await movieReleases(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "error") {
if (outcome.kind === "results" && !paintBuckets(dom, outcome.releases, actions)) {
clearBuckets(dom);
setStatus(`releases unavailable — ${outcome.detail}`, "fault");
return;
}
if (outcome.releases.length === 0) {
clearBuckets(dom);
await emptyVerdict(movie, ticket);
return;
}
render(outcome.releases);
}
/**
@@ -1206,46 +1623,46 @@ function releasesMain(): ReleasesView {
* has not landed yet, or a sweep that landed and found nothing. Only the
* movie's `last_searched_at` can tell them apart.
*/
async function emptyVerdict(movie: LibraryMovie, ticket: number) {
const outcome = await movieSearchState(movie.id);
if (ticket !== sequence || current !== movie) {
async function emptyVerdict(id: number, ticket: number) {
const outcome = await movieSearchState(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "state" && outcome.movie.last_searched_at !== null) {
setStatus(
setReleaseStatus(
`no releases found — sweep finished ${formatSweepAge(outcome.movie.last_searched_at)}; search indexers runs a new one`,
);
return;
}
if (outcome.kind === "state" && sweepExpected(outcome.movie)) {
// first sweep still in flight — visibly different from "nothing found"
watchSweep(movie, null, ticket);
watchSweep(id, null, ticket);
return;
}
setStatus("no releases indexed for this title — search indexers runs a sweep now");
setReleaseStatus("no releases indexed for this title — search indexers runs a sweep now");
}
/** Poll the movie until `last_searched_at` moves off `baseline`. */
function watchSweep(movie: LibraryMovie, baseline: string | null, ticket: number) {
function watchSweep(id: number, baseline: string | null, ticket: number) {
sweep.disabled = true;
setStatus("sweeping indexers…", undefined, true);
setReleaseStatus("sweeping indexers…", undefined, true);
const deadline = Date.now() + SWEEP_WAIT_MS;
const tick = async () => {
if (ticket !== sequence || current !== movie) {
if (ticket !== sequence || movieId !== id) {
return;
}
const outcome = await movieSearchState(movie.id);
if (ticket !== sequence || current !== movie) {
const outcome = await movieSearchState(id);
if (ticket !== sequence || movieId !== id) {
return;
}
if (outcome.kind === "state" && outcome.movie.last_searched_at !== baseline) {
sweep.disabled = false;
void load();
await loadReleases(id, ticket);
return;
}
if (Date.now() >= deadline) {
sweep.disabled = false;
setStatus(
setReleaseStatus(
"sweep has not landed yet — it may be waiting out its backoff; results appear here once it runs",
);
return;
@@ -1259,6 +1676,8 @@ function releasesMain(): ReleasesView {
}, SWEEP_POLL_MS);
}
/* ---- removal confirmation, unchanged from the deck ---- */
/** Tears the confirmation down without stealing focus from a caller. */
function clearRemove() {
removeWrap.hidden = true;
@@ -1300,59 +1719,6 @@ function releasesMain(): ReleasesView {
}
});
function open(movie: LibraryMovie, from: HTMLElement, container: HTMLElement, parent: Route) {
current = movie;
origin = from;
returnTo = container;
parentRoute = parent;
title.textContent = movie.title;
year.textContent = movie.year === null ? "—" : String(movie.year);
container.hidden = true;
view.hidden = false;
clearBuckets(dom);
clearRemove();
sweep.disabled = false;
back.focus();
void load();
}
function hide() {
view.hidden = true;
current = null;
clearRemove();
sequence += 1;
window.clearTimeout(pollTimer);
}
function close() {
const target = origin;
navigate(parentRoute);
hide();
returnTo.hidden = false;
target?.focus();
}
back.addEventListener("click", close);
// 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) {
return;
}
event.stopImmediatePropagation();
// the confirmation is the innermost layer: Esc abandons it, not the deck
if (removeWrap.hidden) {
close();
} else {
closeRemove();
}
},
true,
);
sweep.addEventListener("click", () => {
const movie = current;
if (!movie) {
@@ -1372,18 +1738,114 @@ function releasesMain(): ReleasesView {
}
if (outcome.kind === "error") {
sweep.disabled = false;
setStatus(`search failed — ${outcome.detail}`, "fault");
setReleaseStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
sequence += 1;
window.clearTimeout(pollTimer);
watchSweep(movie, baseline, sequence);
watchSweep(movie.id, baseline, sequence);
})();
});
/* ---- open, close, layers ---- */
async function load() {
const id = movieId;
if (id === null) {
return;
}
sequence += 1;
const ticket = sequence;
window.clearTimeout(pollTimer);
const [movie, filesOutcome, fetchedRoots] = await Promise.all([
fetchMovie(id),
movieFiles(id),
allRoots().catch(() => roots),
]);
if (ticket !== sequence || movieId !== id) {
return;
}
roots = fetchedRoots;
if (movie === null) {
setStatus("movie unavailable", "fault");
return;
}
current = movie;
clearRichDetail();
paintIdentity();
paintControls();
paintFiles(filesOutcome);
void loadMetadata(id, ticket);
await loadReleases(id, ticket);
}
async function open(id: number, from: HTMLElement, container: HTMLElement, parent: Route) {
// this page is one of `views`, so the loop below calls its own hide()
// and would wipe the state assigned after it — hide first, then take state
for (const sibling of views) {
sibling.hide();
}
movieId = id;
current = null;
origin = from;
returnTo = container;
parentRoute = parent;
board.hidden = true;
deckEl.hidden = true;
view.hidden = false;
clearBuckets(dom);
clearRemove();
clearRichDetail();
filesSection.hidden = true;
diskRows.replaceChildren();
sweep.disabled = false;
back.focus();
await load();
}
function hide() {
view.hidden = true;
movieId = null;
current = null;
sequence += 1;
window.clearTimeout(pollTimer);
clearRemove();
}
function close() {
const target = origin;
navigate(parentRoute);
hide();
if (returnTo) {
returnTo.hidden = false;
}
target?.focus();
}
back.addEventListener("click", close);
// 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) {
return;
}
event.stopImmediatePropagation();
// the confirmation is the innermost layer: Esc abandons it, not the page
if (removeWrap.hidden) {
close();
} else {
closeRemove();
}
},
true,
);
return {
open,
hide,
open,
setRemoved: (handler: (parent: Route) => void) => {
removed = handler;
},
@@ -1776,7 +2238,7 @@ interface LibraryGroup {
function libraryMain(
board: HTMLElement,
releases: ReleasesView,
movieDetail: MovieView,
seriesDetail: SeriesView,
views: HideableView[],
): LibraryView {
@@ -1859,8 +2321,8 @@ function libraryMain(
);
renderGroup(groups.movies, moviesShown, (movie) =>
libraryRow(movie, roots, (target, origin) => {
navigate({ kind: "releases", movieId: target.id });
releases.open(target, origin, view, { kind: "library" });
navigate({ kind: "movie", movieId: target.id });
void movieDetail.open(target.id, origin, view, { kind: "library" });
}),
);
@@ -1895,7 +2357,6 @@ function libraryMain(
}
function open() {
releases.hide();
for (const sibling of views) {
sibling.hide();
}
@@ -2669,7 +3130,7 @@ interface QueueGroup {
rows: HTMLUListElement;
}
function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableView[]): QueuesView {
function queuesMain(board: HTMLElement, movieDetail: MovieView, views: HideableView[]): QueuesView {
const view = must<HTMLElement>("#queues");
const deck = must<HTMLElement>("#deck");
const nav = must<HTMLButtonElement>("#nav-queues");
@@ -2742,8 +3203,8 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV
}
const openReleases = (movie: LibraryMovie, origin: HTMLElement) => {
navigate({ kind: "releases", movieId: movie.id });
releases.open(movie, origin, view, { kind: "queues" });
navigate({ kind: "movie", movieId: movie.id });
void movieDetail.open(movie.id, origin, view, { kind: "queues" });
};
/** Drops an emptied item without a refetch: the override IS written. */
@@ -2808,7 +3269,6 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV
}
function open() {
releases.hide();
for (const sibling of views) {
sibling.hide();
}
@@ -2923,7 +3383,7 @@ function noPtRow(
}
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "releases";
affordance.textContent = "open";
chips.append(affordance);
line.append(rowTitle(movie.title, movie.year), chips);
line.addEventListener("click", () => {
+142
View File
@@ -0,0 +1,142 @@
// Hand-written mirror of arr-api's /api/movies/{id}/metadata and PATCH
// /api/movies/{id} schemas — same reasoning as search.ts: the generated
// client (src/api/) is uncommitted, so CI's tsc cannot see it.
import { type ActionOutcome, errorDetail } from "./releases";
/** One of the top-billed cast members (§9.6). */
export interface MetadataCastMember {
tmdb_id: number;
name: string;
character: string;
/** Path fragment exactly as TMDB sends it; the browser composes the URL. */
profile_path: string | null;
}
/** The one trailer worth showing, resolved by the §9.6 rule in arr-meta. */
export interface MetadataTrailer {
youtube_key: string;
name: string;
}
/** Rich detail for one library movie's §9.6 page (#146). */
export interface MovieMetadata {
tmdb_id: number;
overview: string | null;
tagline: string | null;
genres: string[];
/** Minutes. */
runtime: number | null;
status: string;
poster_path: string | null;
backdrop_path: string | null;
vote_average: number;
vote_count: number;
homepage: string | null;
imdb_id: string | null;
cast: MetadataCastMember[];
trailer: MetadataTrailer | null;
}
export type MetadataOutcome =
| { kind: "metadata"; metadata: MovieMetadata }
| { kind: "error"; detail: string };
/** One request for everything above the release deck (§9.6). */
export async function movieMetadata(movieId: number): Promise<MetadataOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}/metadata`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "metadata", metadata: (await response.json()) as MovieMetadata };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/**
* The per-title library controls (#149): wanted, blocked and root all ride
* PATCH /api/movies/{id}. Partial bodies only — overrides stay untouched.
*/
export async function updateMovie(
movieId: number,
patch: { wanted?: boolean; blocked?: boolean; root_id?: number },
): Promise<ActionOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(patch),
});
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/* ---- §9.6 link composition -------------------------------------------- */
/** TMDB image URL from a path fragment — the browser picks the size. */
export function tmdbImage(path: string | null, size: string): string | null {
return path === null ? null : `https://image.tmdb.org/t/p/${size}${path}`;
}
export function tmdbMovieLink(tmdbId: number): string {
return `https://www.themoviedb.org/movie/${tmdbId}`;
}
export function tmdbPersonLink(tmdbId: number): string {
return `https://www.themoviedb.org/person/${tmdbId}`;
}
export function imdbLink(imdbId: string): string {
return `https://www.imdb.com/title/${imdbId}/`;
}
/**
* The Rotten Tomatoes link is a *search* URL built in the browser (§9.6) —
* never a resolved title page, which this service has no id for.
*/
export function rottenTomatoesSearch(title: string, year: number | null): string {
const query = year === null ? title : `${title} ${year}`;
return `https://www.rottentomatoes.com/search?search=${encodeURIComponent(query)}`;
}
export function youtubeLink(key: string): string {
return `https://www.youtube.com/watch?v=${key}`;
}
/** `8.2` with one decimal, as the readout spells a TMDB rating. */
export function formatRating(voteAverage: number): string {
return voteAverage.toFixed(1);
}
/** `4,821` — grouped votes, matching the issue's own mockup. */
export function formatVoteCount(voteCount: number): string {
return voteCount.toLocaleString("en-US");
}
/** `166 min · Science Fiction, Adventure`, skipping what detail left out. */
export function formatMetaLine(runtime: number | null, genres: string[]): string {
const parts: string[] = [];
if (runtime !== null && runtime > 0) {
parts.push(`${runtime} min`);
}
if (genres.length > 0) {
parts.push(genres.join(", "));
}
return parts.join(" · ");
}
/**
* The file's name inside its §7.4 folder — the audit surface the layout
* section promises (`ls` shows what the file is). The folder itself stays
* with the removal confirmation.
*/
export function fileName(path: string): string {
return path.slice(path.lastIndexOf("/") + 1);
}
+33
View File
@@ -257,6 +257,39 @@ export function bucketOf(release: MovieRelease): Bucket {
return "rejected";
}
/**
* The probed attributes a file row shows as chips (§5.6, §7.4) — ffprobe
* truth, not name claims. Shared by movie and episode file rows; the probe
* JSON arrives untyped from the API, so the shape is narrowed here.
*/
export function probedAttributeTags(probed: unknown): string[] {
if (probed === null || typeof probed !== "object") {
return [];
}
const probe = probed as {
resolution?: string | null;
source?: string | null;
hdr?: string | null;
audio_tracks?: { language?: string | null }[] | null;
};
const tags: string[] = [];
if (probe.resolution) {
tags.push(probe.resolution);
}
if (probe.source) {
tags.push(probe.source);
}
if (probe.hdr && probe.hdr !== "SDR") {
tags.push(probe.hdr);
}
for (const track of probe.audio_tracks ?? []) {
if (track.language) {
tags.push(track.language);
}
}
return tags;
}
/* ---- chip formatting: parsed attributes to fixed-width mono values ---- */
const SOURCE_LABEL: Record<string, string> = {
+9
View File
@@ -8,6 +8,7 @@ export type Route =
| { kind: "queues" }
| { kind: "settings" }
| { kind: "search"; query: string }
| { kind: "movie"; movieId: number }
| { kind: "releases"; movieId: number }
| { kind: "series"; seriesId: number }
| { kind: "seasonReleases"; seriesId: number; seasonNumber: number }
@@ -28,6 +29,12 @@ export function parseRoute(url: URL): Route {
const query = url.searchParams.get("q") ?? "";
return query === "" ? { kind: "board" } : { kind: "search", query };
}
if (segments.length === 2 && segments[0] === "movies") {
const movieId = Number(segments[1]);
if (Number.isInteger(movieId) && movieId > 0) {
return { kind: "movie", movieId };
}
}
if (segments.length === 3 && segments[0] === "movies" && segments[2] === "releases") {
const movieId = Number(segments[1]);
if (Number.isInteger(movieId) && movieId > 0) {
@@ -78,6 +85,8 @@ export function routePath(route: Route): string {
return "/settings";
case "search":
return `/search?q=${encodeURIComponent(route.query)}`;
case "movie":
return `/movies/${route.movieId}`;
case "releases":
return `/movies/${route.movieId}/releases`;
case "series":
+3 -22
View File
@@ -3,7 +3,7 @@
// (src/api/) is uncommitted, so CI's tsc cannot see it.
import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases";
import { errorDetail, waiverOverride } from "./releases";
import { errorDetail, probedAttributeTags, waiverOverride } from "./releases";
/** §4.2 derived status — displayed, never editable. */
export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended";
@@ -277,26 +277,7 @@ export function formatAirDate(airDate: string | null): string {
return airDate ?? "—";
}
/** The probed attributes a file row shows as chips (§5.6, §7.4). */
/** The probed attributes an episode file row shows as chips (§5.6, §7.4). */
export function fileAttributeTags(file: EpisodeFile): string[] {
const probed = file.probed;
if (!probed) {
return [];
}
const tags: string[] = [];
if (probed.resolution) {
tags.push(probed.resolution);
}
if (probed.source) {
tags.push(probed.source);
}
if (probed.hdr && probed.hdr !== "SDR") {
tags.push(probed.hdr);
}
for (const track of probed.audio_tracks ?? []) {
if (track.language) {
tags.push(track.language);
}
}
return tags;
return probedAttributeTags(file.probed);
}
+297
View File
@@ -1284,6 +1284,274 @@ body {
overflow-wrap: anywhere;
}
/* ---- movie page (§9.6, issue #149) ------------------------------------- */
/* The hero is one machined module carrying what the film IS. The backdrop
hotlinks from image.tmdb.org (§9.6) and sinks into the console charcoal
under a scrim, so the readouts keep their contrast — a photo never sits
behind text bare. */
.movie-hero {
position: relative;
overflow: hidden;
margin: 0 0 var(--space-8);
}
/* the backdrop layer: painted only when detail supplied one */
.movie-hero::before {
content: "";
position: absolute;
inset: 0;
background-image: var(--backdrop);
background-size: cover;
background-position: center 20%;
opacity: 0;
transition: opacity 400ms var(--ease-out);
}
.movie-hero.has-backdrop::before {
opacity: 1;
}
/* the scrim: ground at the bottom, heavy everywhere the text sits */
.movie-hero::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(
to top,
oklch(from var(--ground) l c h / 92%) 0%,
oklch(from var(--ground) l c h / 72%) 45%,
oklch(from var(--ground) l c h / 40%) 100%
),
oklch(from var(--ground) l c h / 35%);
}
.movie-body {
position: relative;
z-index: 1;
display: flex;
gap: var(--space-6);
align-items: flex-start;
}
/* the poster is framed evidence, like every other image on a panel */
.movie-poster {
flex: none;
width: 10.5rem;
aspect-ratio: 2 / 3;
object-fit: cover;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
box-shadow: 0 2px 12px oklch(from var(--lowlight) l c h / 75%);
}
.movie-info {
min-width: 0;
display: grid;
gap: var(--space-2);
padding: var(--space-6) 0;
}
.movie-idline {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
}
/* the page's voice: bigger than a deck title, same display face */
.movie-title {
margin: 0;
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1.05;
}
.movie-year {
font-size: var(--text-sm);
}
.movie-chips {
margin-left: 0;
}
/* rating and meta are readouts on one line each; the star is drawn */
.movie-rating {
display: inline-flex;
align-items: center;
gap: var(--space-1);
margin: 0;
color: var(--ink);
}
.star {
width: 0.75rem;
height: 0.75rem;
fill: var(--ink-muted);
}
.movie-meta {
margin: 0;
}
.movie-tagline {
margin: var(--space-1) 0 0;
font-family: var(--font-display);
font-size: var(--text-md);
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ink-muted);
}
.movie-overview {
max-width: 65ch;
margin: var(--space-1) 0 0;
color: var(--ink);
}
.movie-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-2);
}
.movie-actions .add-note[data-tone="fault"] {
margin: 0;
color: var(--signal-fault);
}
/* external links wear the control skin; anchors must drop the underline */
.movie-actions a.control {
text-decoration: none;
}
/* library controls follow the hero on their own row: state left, errands right */
.movie-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
padding: var(--space-2) var(--space-1);
}
.movie-controls .control {
min-height: 2.25rem;
padding: 0 var(--space-3);
}
/* wanted and blocked read as rules, pressed = on — the tracked-toggle idiom */
.movie-controls .control[aria-pressed="true"] {
color: var(--accent-bright);
border-color: var(--accent);
}
.movie-root-field {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.movie-root {
width: auto;
min-width: 11rem;
}
.movie-controls-space {
flex: 1;
}
/* cast: top billed, each linking out to TMDB (§9.6) */
.cast-grid {
margin: 0;
padding: var(--space-3) var(--space-1) 0;
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
gap: var(--space-4) var(--space-2);
}
.cast-link {
display: grid;
gap: var(--space-1);
justify-items: start;
color: inherit;
text-decoration: none;
min-width: 0;
}
.cast-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.cast-photo {
width: 100%;
aspect-ratio: 2 / 3;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel-raised);
transition: border-color 150ms var(--ease-out);
}
.cast-photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* no profile photo: dead air, not a broken frame */
.cast-photo-none::before {
content: "";
display: block;
width: 40%;
aspect-ratio: 1;
margin: 30% auto 0;
border-radius: 50%;
border: 1px solid var(--line);
}
.cast-link:hover .cast-photo,
.cast-link:focus-visible .cast-photo {
border-color: var(--accent);
}
.cast-name {
font-size: var(--text-xs);
color: var(--ink);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
}
.cast-character {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
}
/* on-disk rows share the deck's rel grammar: name leads, chips trail */
.disk-name {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--ink);
}
.rel[data-tone="fault"] {
color: var(--signal-fault);
}
/* ---- small screens --------------------------------------------------- */
@media (max-width: 46rem) {
@@ -1357,6 +1625,35 @@ body {
margin-left: auto;
}
/* the hero stacks: poster above the info block, both full width */
.movie-body {
flex-direction: column;
align-items: stretch;
gap: var(--space-3);
}
.movie-poster {
width: 8.5rem;
}
.movie-info {
padding-bottom: var(--space-4);
}
/* state controls wrap to their own line, errands stay right-aligned */
.movie-controls-space {
display: none;
}
.movie-root {
min-width: 0;
flex: 1;
}
.cast-grid {
grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr));
}
.board {
padding: var(--space-6) var(--space-4);
}