feat(web): score heat scale, drop release row expand
ci / web (push) Successful in 1m28s
ci / rust (push) Successful in 2m33s
e2e / e2e (push) Successful in 3m13s

Score cells interpolate red-to-green relative to the scores in the
current deck; the click-to-expand panel only repeated row content.
This commit is contained in:
Miguel Palhas
2026-08-23 11:13:11 +01:00
parent d6345c5305
commit 9f0ccb0416
3 changed files with 79 additions and 88 deletions
+40 -45
View File
@@ -18,7 +18,6 @@ import {
bucketOf,
formatAudio,
formatHdr,
formatPublishDate,
formatResolution,
formatScore,
formatSeeders,
@@ -879,6 +878,28 @@ function releasesMain(): ReleasesView {
}
setStatus(null);
// score colour is relative to this list (issue 111): highest green,
// lowest red, seven interpolated stops between
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const release of releases) {
if (release.score !== null) {
if (release.score < min) {
min = release.score;
}
if (release.score > max) {
max = release.score;
}
}
}
const scoreStop = (score: number | null): number | null => {
if (score === null || min > max) {
return null;
}
// all-equal scores are jointly the best the list offers
return max === min ? SCORE_STOPS : Math.round(((score - min) / (max - min)) * SCORE_STOPS);
};
// the API pre-sorts by bucket then score; splitting preserves order
const buckets = {
eligible: releases.filter((release) => bucketOf(release) === "eligible"),
@@ -890,7 +911,7 @@ function releasesMain(): ReleasesView {
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));
eligible.rows.append(releaseRow(release, movie, "eligible", scoreStop, actions));
}
} else {
// §9.3: over-strict filters must be visible, not silently absent
@@ -907,7 +928,7 @@ function releasesMain(): ReleasesView {
const bucket = collapsed[name];
bucket.section.hidden = false;
for (const release of rows) {
bucket.rows.append(releaseRow(release, movie, name, actions));
bucket.rows.append(releaseRow(release, movie, name, scoreStop, actions));
}
setToggle(bucket, rows.length);
}
@@ -1240,21 +1261,30 @@ interface ReleaseActions {
notify: (text: string, tone?: "fault") => void;
}
/** One classified release: chips lead, the name is secondary (§9.3). */
/** Stops in the score heat scale — keep in step with the --score-* tokens. */
const SCORE_STOPS = 6;
type ScoreStopper = (score: number | null) => number | null;
/**
* One classified release: chips lead, the name is secondary (§9.3). The
* score chip is coloured on the list-relative heat scale (issue 111);
* nothing on the line is clickable — grab is its own control.
*/
function releaseRow(
release: MovieRelease,
movie: LibraryMovie,
bucket: "eligible" | "waived" | "rejected",
scoreStop: ScoreStopper,
actions: ReleaseActions,
): HTMLLIElement {
const item = document.createElement("li");
item.className = "rel";
const line = document.createElement("button");
line.type = "button";
const line = document.createElement("div");
line.className = "rel-line";
line.setAttribute("aria-expanded", "false");
const stop = scoreStop(release.score);
const columns: [string, string, string][] = [
["cw-score", "score", formatScore(release.score)],
["cw-res", "resolution", formatResolution(release.parsed)],
@@ -1272,9 +1302,9 @@ function releaseRow(
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";
// the heat scale replaces the flat eligible green on this cell
if (width === "cw-score" && stop !== null) {
span.dataset.score = String(stop);
}
}),
);
@@ -1342,41 +1372,6 @@ function releaseRow(
}
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);
// hdr and audio leave the chip line under 40rem; the expand carries them
meta.textContent = [
`indexer ${release.indexer_id}`,
published === null ? null : `published ${published}`,
`hdr ${formatHdr(release.parsed)}`,
`audio ${formatAudio(release.parsed)}`,
`${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;
}
-9
View File
@@ -361,15 +361,6 @@ export function ruleLabel(rule: string | null): string {
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 };
+39 -34
View File
@@ -51,6 +51,16 @@
--verdict-waived: oklch(0.82 0.14 80);
--verdict-rejected: oklch(0.68 0.02 250);
/* score heat scale (issue #111) — one list-relative gradient from lowest
red to highest green; JS picks the stop, colour stays in tokens */
--score-0: oklch(0.68 0.19 25);
--score-1: oklch(0.7 0.185 46);
--score-2: oklch(0.72 0.175 67);
--score-3: oklch(0.75 0.165 88);
--score-4: oklch(0.77 0.16 108);
--score-5: oklch(0.79 0.165 129);
--score-6: oklch(0.8 0.17 150);
/* library status (§4.2) — informational, never a severity ramp:
activity in one violet family, satisfaction in neutrals */
--status-airing: oklch(0.78 0.14 305);
@@ -994,7 +1004,7 @@ body {
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
}
/* the whole line is the expand affordance; grab is its own control */
/* the line is plain content now — grab is its own control */
.rel-line {
flex: 1;
min-width: 0;
@@ -1002,20 +1012,6 @@ body {
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 */
@@ -1044,25 +1040,34 @@ body {
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);
/* score heat: data-score is the list-relative stop, 0 lowest … 6 highest */
.rel-line .cw-score[data-score="0"] {
color: var(--score-0);
border-color: oklch(from var(--score-0) l c h / 45%);
}
.rel-raw {
margin: 0;
overflow-wrap: anywhere;
font-size: var(--text-xs);
color: var(--ink);
.rel-line .cw-score[data-score="1"] {
color: var(--score-1);
border-color: oklch(from var(--score-1) l c h / 45%);
}
.rel-meta {
margin: var(--space-1) 0 0;
font-size: var(--text-2xs);
.rel-line .cw-score[data-score="2"] {
color: var(--score-2);
border-color: oklch(from var(--score-2) l c h / 45%);
}
.rel-line .cw-score[data-score="3"] {
color: var(--score-3);
border-color: oklch(from var(--score-3) l c h / 45%);
}
.rel-line .cw-score[data-score="4"] {
color: var(--score-4);
border-color: oklch(from var(--score-4) l c h / 45%);
}
.rel-line .cw-score[data-score="5"] {
color: var(--score-5);
border-color: oklch(from var(--score-5) l c h / 45%);
}
.rel-line .cw-score[data-score="6"] {
color: var(--score-6);
border-color: oklch(from var(--score-6) l c h / 45%);
}
/* ---- library view (§4.2) ---------------------------------------------- */
@@ -1236,8 +1241,8 @@ body {
/* ---- narrow deck (§9.3 at phone widths) ------------------------------- */
/* the full seven-column line is 36rem; under 40rem it cannot fit, so hdr
and audio leave the line (the expand row carries them) and the five
surviving columns tighten — aligned, never wrapped, never scrolled */
and audio leave the line and the five surviving columns tighten —
aligned, never wrapped, never scrolled */
@media (max-width: 40rem) {
.colhead .cw,
.rel-line .chip {