Manual search buckets and attribute chips (#79)
ci / rust (push) Successful in 1m54s
ci / web (push) Successful in 27s
e2e / e2e (push) Successful in 1m21s

This commit was merged in pull request #79.
This commit is contained in:
2026-08-22 22:42:50 +01:00
parent f26f278a75
commit e650a762f9
5 changed files with 991 additions and 14 deletions
+87
View File
@@ -35,6 +35,14 @@
a pasted magnet or .torrent routes to a manual-intake panel. There is
no separate add page, ever. Esc returns to the board; "/" focuses the
box from anywhere.
BUCKETS (§9.3, issue #31): a library row opens the release deck — the
policy engine's verdicts, inverted from Radarr: ELIGIBLE rows lead with
fixed-width attribute chips (score res source hdr audio size seed) in
aligned mono columns; WAIVED and REJECTED collapse to counts. The
release name is secondary, truncated, full on expand. Every rejected
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.
-->
<header class="rail">
<div class="rail-id">
@@ -87,6 +95,85 @@
</section>
</main>
<main class="deck releases" id="releases" hidden>
<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>
</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">
<h3 class="deck-label" id="label-eligible">eligible</h3>
<span class="deck-count readout" id="count-eligible"></span>
</header>
<div class="colhead" aria-hidden="true">
<span class="cw cw-score">score</span>
<span class="cw cw-res">res</span>
<span class="cw cw-src">source</span>
<span class="cw cw-hdr">hdr</span>
<span class="cw cw-aud">audio</span>
<span class="cw cw-size">size</span>
<span class="cw cw-seed">seed</span>
</div>
<ul class="deck-rows" id="rows-eligible"></ul>
</section>
<section class="deck-group" id="bucket-waived" hidden aria-labelledby="label-waived">
<header class="deck-head bucket-head">
<h3 class="deck-label" id="label-waived">waived</h3>
<button
type="button"
class="bucket-toggle readout"
id="toggle-waived"
aria-expanded="false"
aria-controls="wrap-waived"
></button>
</header>
<div id="wrap-waived" hidden>
<div class="colhead" aria-hidden="true">
<span class="cw cw-score">score</span>
<span class="cw cw-res">res</span>
<span class="cw cw-src">source</span>
<span class="cw cw-hdr">hdr</span>
<span class="cw cw-aud">audio</span>
<span class="cw cw-size">size</span>
<span class="cw cw-seed">seed</span>
</div>
<ul class="deck-rows" id="rows-waived"></ul>
</div>
</section>
<section class="deck-group" id="bucket-rejected" hidden aria-labelledby="label-rejected">
<header class="deck-head bucket-head">
<h3 class="deck-label" id="label-rejected">rejected</h3>
<button
type="button"
class="bucket-toggle readout"
id="toggle-rejected"
aria-expanded="false"
aria-controls="wrap-rejected"
></button>
</header>
<div id="wrap-rejected" hidden>
<div class="colhead" aria-hidden="true">
<span class="cw cw-score">score</span>
<span class="cw cw-res">res</span>
<span class="cw cw-src">source</span>
<span class="cw cw-hdr">hdr</span>
<span class="cw cw-aud">audio</span>
<span class="cw cw-size">size</span>
<span class="cw cw-seed">seed</span>
</div>
<ul class="deck-rows" id="rows-rejected"></ul>
</div>
</section>
</main>
<main class="board">
<section class="chain" aria-label="signal chain">
<article class="module area-tmdb" data-check="tmdb">
+400 -6
View File
@@ -1,4 +1,22 @@
import { type CheckStatus, type Probe, probeHealth } from "./health";
import {
bucketOf,
formatAudio,
formatHdr,
formatPublishDate,
formatResolution,
formatScore,
formatSeeders,
formatSize,
formatSource,
grabRelease,
type MovieRelease,
movieReleases,
queueSearch,
ruleLabel,
waiveAndGrab,
waiverOverride,
} from "./releases";
import {
addMovie,
type LibraryMovie,
@@ -165,7 +183,7 @@ function main() {
}, POLL_MS);
void probe();
searchMain(board);
searchMain(board, releasesMain());
}
const DEBOUNCE_MS = 250;
@@ -181,7 +199,7 @@ interface DeckRefs {
manualIntake: HTMLElement;
}
function searchMain(board: HTMLElement) {
function searchMain(board: HTMLElement, releases: ReleasesView) {
const input = must<HTMLInputElement>("#search");
const hint = must<HTMLElement>("#search-hint");
const refs: DeckRefs = {
@@ -222,11 +240,13 @@ function searchMain(board: HTMLElement) {
function showBoard() {
controller?.abort();
controller = null;
releases.hide();
refs.deck.hidden = true;
board.hidden = false;
}
function showDeck() {
releases.hide();
board.hidden = true;
refs.deck.hidden = false;
}
@@ -290,7 +310,7 @@ function searchMain(board: HTMLElement) {
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));
refs.groups.library.rows.append(libraryRow(movie, roots, releases.open));
}
}
if (response.tmdb.length > 0) {
@@ -403,9 +423,15 @@ function rowTitle(title: string, year: number | null): HTMLElement {
return wrap;
}
function libraryRow(movie: LibraryMovie, roots: Root[]): HTMLLIElement {
function libraryRow(
movie: LibraryMovie,
roots: Root[],
open: (movie: LibraryMovie, origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
item.className = "row";
const row = document.createElement("button");
row.type = "button";
row.className = "row row-tmdb";
const chips = document.createElement("span");
chips.className = "row-chips";
const root = roots.find((candidate) => candidate.id === movie.root_id);
@@ -421,7 +447,15 @@ function libraryRow(movie: LibraryMovie, roots: Root[]): HTMLLIElement {
if (movie.blocked) {
chips.append(chip("blocked"));
}
item.append(rowTitle(movie.title, movie.year), chips);
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "releases";
chips.append(affordance);
row.append(rowTitle(movie.title, movie.year), chips);
row.addEventListener("click", () => {
open(movie, row);
});
item.append(row);
return item;
}
@@ -586,6 +620,366 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML
return panel;
}
/* ---- release deck (§9.3, issue #31) ---------------------------------- */
interface ReleasesView {
open: (movie: LibraryMovie, origin: HTMLElement) => void;
hide: () => void;
}
interface BucketRefs {
section: HTMLElement;
rows: HTMLUListElement;
}
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 status = must<HTMLElement>("#releases-status");
const eligible: BucketRefs & { count: HTMLElement } = {
section: must<HTMLElement>("#bucket-eligible"),
count: must<HTMLElement>("#count-eligible"),
rows: must<HTMLUListElement>("#rows-eligible"),
};
const collapsed = {
waived: {
section: must<HTMLElement>("#bucket-waived"),
toggle: must<HTMLButtonElement>("#toggle-waived"),
wrap: must<HTMLElement>("#wrap-waived"),
rows: must<HTMLUListElement>("#rows-waived"),
},
rejected: {
section: must<HTMLElement>("#bucket-rejected"),
toggle: must<HTMLButtonElement>("#toggle-rejected"),
wrap: must<HTMLElement>("#wrap-rejected"),
rows: must<HTMLUListElement>("#rows-rejected"),
},
} as const;
let current: LibraryMovie | null = null;
let origin: HTMLElement | null = null;
const actions: ReleaseActions = {
reload: () => load(),
notify: (text, tone) => setStatus(text, tone),
};
// guards a stale fetch from painting over a newer view
let sequence = 0;
let refetchTimer: number | undefined;
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 clearBuckets() {
eligible.section.hidden = true;
eligible.rows.replaceChildren();
for (const bucket of [collapsed.waived, collapsed.rejected]) {
bucket.section.hidden = true;
bucket.wrap.hidden = true;
bucket.toggle.setAttribute("aria-expanded", "false");
bucket.rows.replaceChildren();
}
}
function setToggle(bucket: (typeof collapsed)["waived"], count: number) {
const open = !bucket.wrap.hidden;
bucket.toggle.textContent = open ? `hide ${count}` : `show ${count}`;
bucket.toggle.setAttribute("aria-expanded", String(open));
}
for (const bucket of [collapsed.waived, collapsed.rejected]) {
bucket.toggle.addEventListener("click", () => {
bucket.wrap.hidden = !bucket.wrap.hidden;
setToggle(bucket, bucket.rows.childElementCount);
});
}
function render(releases: MovieRelease[]) {
const movie = current;
if (!movie) {
return;
}
clearBuckets();
if (releases.length === 0) {
setStatus("no releases indexed for this title — search indexers runs a sweep now");
return;
}
setStatus(null);
// the API pre-sorts by bucket then score; splitting preserves order
const buckets = {
eligible: releases.filter((release) => bucketOf(release) === "eligible"),
waived: releases.filter((release) => bucketOf(release) === "waived"),
rejected: releases.filter((release) => bucketOf(release) === "rejected"),
};
eligible.section.hidden = false;
eligible.count.textContent = String(buckets.eligible.length);
if (buckets.eligible.length > 0) {
for (const release of buckets.eligible) {
eligible.rows.append(releaseRow(release, movie, "eligible", actions));
}
} else {
// §9.3: over-strict filters must be visible, not silently absent
const none = document.createElement("li");
none.className = "rel rel-none readout dim";
none.textContent = "none — every candidate was waived or rejected by policy";
eligible.rows.append(none);
}
for (const name of ["waived", "rejected"] as const) {
const rows = buckets[name];
if (rows.length === 0) {
continue;
}
const bucket = collapsed[name];
bucket.section.hidden = false;
for (const release of rows) {
bucket.rows.append(releaseRow(release, movie, name, actions));
}
setToggle(bucket, rows.length);
}
}
async function load() {
const movie = current;
if (!movie) {
return;
}
sequence += 1;
const ticket = sequence;
setStatus("reading releases…");
const outcome = await movieReleases(movie.id);
if (ticket !== sequence || current !== movie) {
return;
}
if (outcome.kind === "error") {
clearBuckets();
setStatus(`releases unavailable — ${outcome.detail}`, "fault");
return;
}
render(outcome.releases);
}
function open(movie: LibraryMovie, from: HTMLElement) {
current = movie;
origin = from;
title.textContent = movie.title;
year.textContent = movie.year === null ? "—" : String(movie.year);
deck.hidden = true;
view.hidden = false;
clearBuckets();
sweep.disabled = false;
back.focus();
void load();
}
function hide() {
view.hidden = true;
current = null;
sequence += 1;
window.clearTimeout(refetchTimer);
}
function close() {
const target = origin;
hide();
deck.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
window.addEventListener(
"keydown",
(event) => {
if (event.key === "Escape" && !view.hidden) {
event.stopPropagation();
close();
}
},
true,
);
sweep.addEventListener("click", () => {
const movie = current;
if (!movie) {
return;
}
sweep.disabled = true;
void queueSearch(movie.id).then((outcome) => {
if (current !== movie) {
return;
}
sweep.disabled = false;
if (outcome.kind === "error") {
setStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
setStatus("search queued — indexers answer asynchronously, rereading shortly");
window.clearTimeout(refetchTimer);
refetchTimer = window.setTimeout(() => {
void load();
}, 3000);
});
});
return { open, hide };
}
interface ReleaseActions {
reload: () => Promise<void>;
notify: (text: string, tone?: "fault") => void;
}
/** One classified release: chips lead, the name is secondary (§9.3). */
function releaseRow(
release: MovieRelease,
movie: LibraryMovie,
bucket: "eligible" | "waived" | "rejected",
actions: ReleaseActions,
): HTMLLIElement {
const item = document.createElement("li");
item.className = "rel";
const line = document.createElement("button");
line.type = "button";
line.className = "rel-line";
line.setAttribute("aria-expanded", "false");
const columns: [string, string, string][] = [
["cw-score", "score", formatScore(release.score)],
["cw-res", "resolution", formatResolution(release.parsed)],
["cw-src", "source", formatSource(release.parsed)],
["cw-hdr", "hdr", formatHdr(release.parsed)],
["cw-aud", "audio", formatAudio(release.parsed)],
["cw-size", "size", formatSize(release.size)],
["cw-seed", "seeders", formatSeeders(release.seeders)],
];
for (const [width, label, value] of columns) {
line.append(
chip(value, (span) => {
span.classList.add("cw", width);
span.setAttribute("aria-label", value === "—" ? `${label} unclaimed` : `${label} ${value}`);
if (value === "—") {
span.classList.add("dim");
}
// the world's non-colour eligible mark rides the leading chip
if (bucket === "eligible" && width === "cw-score") {
span.dataset.verdict = "eligible";
}
}),
);
}
if (bucket !== "eligible") {
line.append(
chip(`${bucket} · ${ruleLabel(release.rejected_rule)}`, (span) => {
span.dataset.verdict = bucket;
}),
);
}
const name = document.createElement("span");
name.className = "rel-name readout";
name.textContent = release.name;
line.append(name);
item.append(line);
const note = document.createElement("span");
note.className = "rel-note readout";
note.setAttribute("role", "status");
note.hidden = true;
if (bucket !== "rejected") {
const grab = document.createElement("button");
grab.type = "button";
grab.className = "control rel-grab";
const writesOverride = bucket === "waived" && waiverOverride(release.rejected_rule) !== null;
grab.textContent = writesOverride ? "waive + grab" : "grab";
grab.addEventListener("click", () => {
grab.disabled = true;
note.hidden = false;
delete note.dataset.tone;
note.textContent = "grabbing…";
const action =
bucket === "waived"
? waiveAndGrab(movie.id, release.id, release.rejected_rule)
: grabRelease(movie.id, release.id).then((outcome) =>
outcome.kind === "done"
? ({ kind: "done", overrideWritten: false } as const)
: ({ kind: "error", detail: outcome.detail, overrideWritten: false } as const),
);
void action.then((outcome) => {
const text =
outcome.kind === "error"
? `${outcome.overrideWritten ? "override written · " : ""}grab failed — ${outcome.detail}`
: `${outcome.overrideWritten ? "override written · " : ""}grab sent`;
if (outcome.kind === "error") {
grab.disabled = false;
note.textContent = text;
note.dataset.tone = "fault";
} else {
note.textContent = text;
}
if (outcome.overrideWritten) {
// the override changed classification; reread the verdicts and
// keep the outcome visible past the re-render
void actions.reload().then(() => {
actions.notify(text, outcome.kind === "error" ? "fault" : undefined);
});
}
});
});
item.append(grab);
}
item.append(note);
const full = document.createElement("div");
full.className = "rel-full";
full.hidden = true;
line.addEventListener("click", () => {
if (full.childElementCount === 0) {
const raw = document.createElement("p");
raw.className = "rel-raw readout";
raw.textContent = release.name;
full.append(raw);
const meta = document.createElement("p");
meta.className = "rel-meta readout dim";
const published = formatPublishDate(release.publish_date);
meta.textContent = [
`indexer ${release.indexer_id}`,
published === null ? null : `published ${published}`,
`${release.size} bytes`,
]
.filter((part) => part !== null)
.join(" · ");
full.append(meta);
if (bucket === "waived" && waiverOverride(release.rejected_rule) !== null) {
const waiver = document.createElement("p");
waiver.className = "rel-meta readout dim";
waiver.textContent = "waive + grab writes this title's override, then grabs";
full.append(waiver);
}
}
full.hidden = !full.hidden;
line.setAttribute("aria-expanded", String(!full.hidden));
});
item.append(full);
return item;
}
function renderManual(intake: HTMLElement, kind: "magnet" | "torrent_url", raw: string) {
const parsed = parseManualInput(kind, raw);
intake.replaceChildren();
+261
View File
@@ -0,0 +1,261 @@
// Hand-written mirror of arr-api's /api/movies/{id}/releases, grab and
// override schemas — same reasoning as search.ts: the generated client
// (src/api/) is uncommitted, so CI's tsc cannot see it.
export type Bucket = "eligible" | "waived" | "rejected";
/** What the release name claims (arr-parse `NameClaims`, §5.6). */
export interface ParsedClaims {
resolution?: string | null;
source?: string | null;
codec?: string | null;
hdr?: string[];
languages?: string[];
}
export interface MovieRelease {
id: number;
indexer_id: number;
guid: string;
name: string;
size: number;
seeders: number | null;
publish_date: string | null;
download_url: string;
parsed: ParsedClaims;
score: number | null;
verdict: string | null;
rejected_rule: string | null;
}
export type ReleasesOutcome =
| { kind: "results"; releases: MovieRelease[] }
| { kind: "error"; detail: string };
export async function movieReleases(movieId: number): Promise<ReleasesOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}/releases`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "results", releases: (await response.json()) as MovieRelease[] };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
export type ActionOutcome = { kind: "done" } | { kind: "error"; detail: string };
/** §6.2 manual trigger: one targeted sweep, user-initiated. */
export async function queueSearch(movieId: number): Promise<ActionOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}/search`, { method: "POST" });
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
export async function grabRelease(movieId: number, releaseId: number): Promise<ActionOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}/releases/${releaseId}/grab`, {
method: "POST",
});
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/**
* The per-title override one click on a waived row writes (§5.2, §9.3).
* Mapping a rule name to its override is bookkeeping, not policy — the
* verdict itself always comes from the API.
*/
export function waiverOverride(rule: string | null): Record<string, unknown> | null {
switch (rule) {
case "required_audio":
return { allow_english_audio: true };
case "resolution":
return { only_4k: false };
default:
return null;
}
}
export type WaiveOutcome =
| { kind: "done"; overrideWritten: boolean }
| { kind: "error"; detail: string; overrideWritten: boolean };
/** One click on a waived row: write the override, then grab (§9.3). */
export async function waiveAndGrab(
movieId: number,
releaseId: number,
rule: string | null,
): Promise<WaiveOutcome> {
const override = waiverOverride(rule);
let overrideWritten = false;
if (override) {
try {
const current = await fetch(`/api/movies/${movieId}`);
if (!current.ok) {
return { kind: "error", detail: await errorDetail(current), overrideWritten };
}
const movie = (await current.json()) as { overrides: Record<string, unknown> };
const patch = await fetch(`/api/movies/${movieId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ overrides: { ...movie.overrides, ...override } }),
});
if (!patch.ok) {
return { kind: "error", detail: await errorDetail(patch), overrideWritten };
}
overrideWritten = true;
} catch {
return { kind: "error", detail: "daemon unreachable", overrideWritten };
}
}
const grabbed = await grabRelease(movieId, releaseId);
if (grabbed.kind === "error") {
return { kind: "error", detail: grabbed.detail, overrideWritten };
}
return { kind: "done", overrideWritten };
}
export function bucketOf(release: MovieRelease): Bucket {
if (release.verdict === "eligible") {
return "eligible";
}
if (release.verdict === "waived") {
return "waived";
}
return "rejected";
}
/* ---- chip formatting: parsed attributes to fixed-width mono values ---- */
const SOURCE_LABEL: Record<string, string> = {
Cam: "cam",
Telesync: "ts",
Telecine: "tc",
Screener: "scr",
Dvd: "dvd",
Hdtv: "hdtv",
WebRip: "webrip",
WebDl: "web-dl",
BluRay: "bluray",
Remux: "remux",
};
const HDR_LABEL: Record<string, string> = {
DolbyVision: "DV",
Hdr10Plus: "HDR10+",
Hdr10: "HDR10",
Hdr: "HDR",
Hlg: "HLG",
Sdr: "SDR",
};
const LANGUAGE_LABEL: Record<string, string> = {
PtBr: "pt-BR",
PtPt: "pt-PT",
Portuguese: "pt",
English: "en",
French: "fr",
German: "de",
Spanish: "es",
Italian: "it",
Multi: "multi",
Dual: "dual",
};
/** §5.7 rule names as short chip words. */
const RULE_LABEL: Record<string, string> = {
required_audio: "audio",
dub_blacklist: "dub",
portuguese_unverified: "pt unverified",
dolby_vision_profile: "dv profile",
resolution: "resolution",
source: "source",
size: "size",
};
export function formatScore(score: number | null): string {
return score === null ? "—" : String(Math.round(score));
}
export function formatResolution(parsed: ParsedClaims): string {
return parsed.resolution ?? "—";
}
export function formatSource(parsed: ParsedClaims): string {
if (!parsed.source) {
return "—";
}
return SOURCE_LABEL[parsed.source] ?? parsed.source.toLowerCase();
}
export function formatHdr(parsed: ParsedClaims): string {
const markers = parsed.hdr ?? [];
if (markers.length === 0) {
return "—";
}
return markers.map((marker) => HDR_LABEL[marker] ?? marker).join(" ");
}
export function formatAudio(parsed: ParsedClaims): string {
const markers = parsed.languages ?? [];
if (markers.length === 0) {
return "—";
}
return markers.map((marker) => LANGUAGE_LABEL[marker] ?? marker.toLowerCase()).join(" ");
}
export function formatSize(bytes: number): string {
if (bytes <= 0) {
return "—";
}
const gib = bytes / 2 ** 30;
if (gib >= 100) {
return `${Math.round(gib)}G`;
}
if (gib >= 1) {
return `${gib.toFixed(1)}G`;
}
return `${Math.round(bytes / 2 ** 20)}M`;
}
export function formatSeeders(seeders: number | null): string {
return seeders === null ? "—" : String(seeders);
}
export function ruleLabel(rule: string | null): string {
if (rule === null) {
return "unclassified";
}
return RULE_LABEL[rule] ?? rule.replaceAll("_", " ");
}
/** The publish date as a calendar day; the exact instant is noise here. */
export function formatPublishDate(date: string | null): string | null {
if (date === null) {
return null;
}
const day = date.slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : date;
}
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}`;
}
}
+222
View File
@@ -629,6 +629,18 @@ body {
border-color: oklch(from var(--verdict-eligible) l c h / 45%);
}
/* the dashed border is the visible mark of a relaxed rule */
.chip[data-verdict="waived"] {
color: var(--verdict-waived);
border-style: dashed;
border-color: oklch(from var(--verdict-waived) l c h / 55%);
}
/* rejected is the policy working, never fault red */
.chip[data-verdict="rejected"] {
color: var(--verdict-rejected);
}
.chip[data-movie-state="imported"] {
color: var(--ink);
}
@@ -745,6 +757,201 @@ body {
color: var(--verdict-eligible);
}
/* ---- release deck (§9.3) ---------------------------------------------- */
.releases-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
margin: 0 0 var(--space-6);
}
.releases-id {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
gap: var(--space-2);
}
.releases-title {
margin: 0;
font-family: var(--font-display);
font-size: var(--text-lg);
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1.1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.releases-year {
font-size: var(--text-xs);
}
.bucket-head {
align-items: center;
}
.bucket-toggle {
margin-left: auto;
min-height: 2.75rem;
padding: 0 var(--space-2);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
background: none;
border: 0;
cursor: pointer;
transition: color 150ms var(--ease-out);
}
.bucket-toggle:hover {
color: var(--accent-bright);
}
/* fixed-width mono columns: chips and header labels share rem widths, so
the pseudo-table survives font-size differences and flex wrapping alike */
.cw {
display: inline-block;
flex: none;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 1.25rem;
}
.cw-score {
width: 4.5rem;
text-align: right;
}
.cw-res {
width: 4rem;
}
.cw-src {
width: 4.5rem;
}
.cw-hdr {
width: 6rem;
}
.cw-aud {
width: 7.5rem;
}
.cw-size {
width: 4.5rem;
text-align: right;
}
.cw-seed {
width: 3.5rem;
text-align: right;
}
.colhead {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
padding: var(--space-2) var(--space-1) 0;
}
.colhead .cw {
padding: 0 var(--space-2);
border: 1px solid transparent;
font-family: var(--font-readout);
font-size: var(--text-2xs);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ink-faint);
}
.rel {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-1) var(--space-2);
padding: var(--space-2) var(--space-1);
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
}
/* the whole line is the expand affordance; grab is its own control */
.rel-line {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-1);
padding: 0;
font: inherit;
text-align: left;
color: inherit;
background: none;
border: 0;
border-radius: var(--radius);
cursor: pointer;
transition: background 150ms var(--ease-out);
}
.rel-line:hover,
.rel-line:focus-visible {
background: var(--panel);
}
/* §9.3: the release name is secondary — evidence, not the primary key */
.rel-name {
flex-basis: 100%;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: var(--text-xs);
color: var(--ink-faint);
}
.rel-none {
padding: var(--space-2) var(--space-1);
font-size: var(--text-xs);
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
}
.rel-note {
font-size: var(--text-xs);
color: var(--ink-muted);
}
.rel-note[data-tone="fault"] {
color: var(--signal-fault);
}
.rel-full {
flex-basis: 100%;
padding: var(--space-2) var(--space-3);
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: inset 0 1px var(--highlight);
}
.rel-raw {
margin: 0;
overflow-wrap: anywhere;
font-size: var(--text-xs);
color: var(--ink);
}
.rel-meta {
margin: var(--space-1) 0 0;
font-size: var(--text-2xs);
}
/* ---- manual intake ---------------------------------------------------- */
.intake .module-detail {
@@ -779,6 +986,21 @@ body {
flex-basis: 100%;
}
.releases-id {
order: -1;
flex-basis: 100%;
}
/* the row and the column header must wrap at the same width, so the
grab control drops below the line instead of narrowing it */
.rel-line {
flex-basis: 100%;
}
.rel-grab {
margin-left: auto;
}
.board {
padding: var(--space-6) var(--space-4);
}