diff --git a/.impeccable/surfaces/web-src-main-ts.md b/.impeccable/surfaces/web-src-main-ts.md index 5e82744..05b7b8c 100644 --- a/.impeccable/surfaces/web-src-main-ts.md +++ b/.impeccable/surfaces/web-src-main-ts.md @@ -2,13 +2,13 @@ version: 1 slug: "web-src-main-ts" primary_target: "web/src/main.ts" -related_targets: ["web/index.html","web/src/search.ts","web/src/style.css"] +related_targets: ["web/index.html","web/src/search.ts","web/src/releases.ts","web/src/style.css"] --- -# Surface: unified search (web/src/main.ts, §9.2) +# Surface: unified search + release deck (web/src/main.ts, §9.2 + §9.3) Mode: Operate. The operator finds or adds a title without knowing which of -the two they are doing. +the two they are doing, and picks a release from decisions, not name dumps. - Audience: the household operator; desktop mostly, phone sometimes. - Task: one rail-mounted command input. Typing swaps the chain board for the @@ -17,10 +17,23 @@ the two they are doing. engraved micro-label headings, machined rows, chips on the right. - Add flow: inline panel under a TMDB row — root radio cards (audience name, policy name, path), main pre-selected, one confirm. Never a separate page. +- Release deck (§9.3, issue #31): a library row opens the buckets view. + ELIGIBLE leads with fixed-width attribute chips in aligned mono columns + (rem-width `.cw-*` classes shared by chips and the column header, so the + pseudo-table survives flex wrapping); WAIVED and REJECTED collapse to + counts behind cyan show/hide toggles. Release name is a secondary + truncated line, full string plus indexer/date/bytes on row expand. Every + waived/rejected row carries a verdict chip naming the rule. Grab is its + own control per row; on a waived row it is "waive + grab" and writes the + per-title override (rule→override mapping in releases.ts — bookkeeping, + never policy; verdicts always come from the API). Esc steps back one + layer: deck → search results → board. - Manual intake: magnet / .torrent input routes to a MANUAL GRAB panel showing - parsed display name and btih; grab wiring lands with the release buckets - (issue #31/#22), and the panel says so honestly. + parsed display name and btih; magnet grab wiring is issue #22, and the + panel says so honestly. - Constraints: 250 ms debounce, AbortController cancellation, token-gated CSS, - cyan for interaction only, dead-air rule (no idle animation). -- Unresolved: library rows are informational until the library view (#32) - gives them a destination; TMDB search is movie-only until series land (#39). + cyan for interaction only, dead-air rule (no idle animation), no horizontal + scroll at any viewport (§9.3 acceptance criterion). +- Unresolved: releases land in the persisted store only once the daemon + consumes search commands (#68-adjacent); TMDB search is movie-only until + series land (#39). diff --git a/web/index.html b/web/index.html index dcd8d88..ddc7fcc 100644 --- a/web/index.html +++ b/web/index.html @@ -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. -->
@@ -87,6 +95,85 @@ +
+
+ +
+

—

+ — +
+ +
+ + + + + + + +
+
diff --git a/web/src/main.ts b/web/src/main.ts index 458283d..1db5cff 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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("#search"); const hint = must("#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("#releases"); + const deck = must("#deck"); + const back = must("#releases-back"); + const title = must("#releases-title"); + const year = must("#releases-year"); + const sweep = must("#releases-sweep"); + const status = must("#releases-status"); + const eligible: BucketRefs & { count: HTMLElement } = { + section: must("#bucket-eligible"), + count: must("#count-eligible"), + rows: must("#rows-eligible"), + }; + const collapsed = { + waived: { + section: must("#bucket-waived"), + toggle: must("#toggle-waived"), + wrap: must("#wrap-waived"), + rows: must("#rows-waived"), + }, + rejected: { + section: must("#bucket-rejected"), + toggle: must("#toggle-rejected"), + wrap: must("#wrap-rejected"), + rows: must("#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; + 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(); diff --git a/web/src/releases.ts b/web/src/releases.ts new file mode 100644 index 0000000..534617b --- /dev/null +++ b/web/src/releases.ts @@ -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 { + 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 { + 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 { + 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 | 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 { + 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 }; + 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 = { + 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 = { + DolbyVision: "DV", + Hdr10Plus: "HDR10+", + Hdr10: "HDR10", + Hdr: "HDR", + Hlg: "HLG", + Sdr: "SDR", +}; + +const LANGUAGE_LABEL: Record = { + 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 = { + 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 { + try { + const body = (await response.json()) as { error?: string }; + return body.error ?? `http ${response.status}`; + } catch { + return `http ${response.status}`; + } +} diff --git a/web/src/style.css b/web/src/style.css index a89308c..017e051 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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); }