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, bucketOf,
formatAudio, formatAudio,
formatHdr, formatHdr,
formatPublishDate,
formatResolution, formatResolution,
formatScore, formatScore,
formatSeeders, formatSeeders,
@@ -879,6 +878,28 @@ function releasesMain(): ReleasesView {
} }
setStatus(null); 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 // the API pre-sorts by bucket then score; splitting preserves order
const buckets = { const buckets = {
eligible: releases.filter((release) => bucketOf(release) === "eligible"), eligible: releases.filter((release) => bucketOf(release) === "eligible"),
@@ -890,7 +911,7 @@ function releasesMain(): ReleasesView {
eligible.count.textContent = String(buckets.eligible.length); eligible.count.textContent = String(buckets.eligible.length);
if (buckets.eligible.length > 0) { if (buckets.eligible.length > 0) {
for (const release of buckets.eligible) { for (const release of buckets.eligible) {
eligible.rows.append(releaseRow(release, movie, "eligible", actions)); eligible.rows.append(releaseRow(release, movie, "eligible", scoreStop, actions));
} }
} else { } else {
// §9.3: over-strict filters must be visible, not silently absent // §9.3: over-strict filters must be visible, not silently absent
@@ -907,7 +928,7 @@ function releasesMain(): ReleasesView {
const bucket = collapsed[name]; const bucket = collapsed[name];
bucket.section.hidden = false; bucket.section.hidden = false;
for (const release of rows) { 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); setToggle(bucket, rows.length);
} }
@@ -1240,21 +1261,30 @@ interface ReleaseActions {
notify: (text: string, tone?: "fault") => void; 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( function releaseRow(
release: MovieRelease, release: MovieRelease,
movie: LibraryMovie, movie: LibraryMovie,
bucket: "eligible" | "waived" | "rejected", bucket: "eligible" | "waived" | "rejected",
scoreStop: ScoreStopper,
actions: ReleaseActions, actions: ReleaseActions,
): HTMLLIElement { ): HTMLLIElement {
const item = document.createElement("li"); const item = document.createElement("li");
item.className = "rel"; item.className = "rel";
const line = document.createElement("button"); const line = document.createElement("div");
line.type = "button";
line.className = "rel-line"; line.className = "rel-line";
line.setAttribute("aria-expanded", "false");
const stop = scoreStop(release.score);
const columns: [string, string, string][] = [ const columns: [string, string, string][] = [
["cw-score", "score", formatScore(release.score)], ["cw-score", "score", formatScore(release.score)],
["cw-res", "resolution", formatResolution(release.parsed)], ["cw-res", "resolution", formatResolution(release.parsed)],
@@ -1272,9 +1302,9 @@ function releaseRow(
if (value === "—") { if (value === "—") {
span.classList.add("dim"); span.classList.add("dim");
} }
// the world's non-colour eligible mark rides the leading chip // the heat scale replaces the flat eligible green on this cell
if (bucket === "eligible" && width === "cw-score") { if (width === "cw-score" && stop !== null) {
span.dataset.verdict = "eligible"; span.dataset.score = String(stop);
} }
}), }),
); );
@@ -1342,41 +1372,6 @@ function releaseRow(
} }
item.append(note); 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; return item;
} }
-9
View File
@@ -361,15 +361,6 @@ export function ruleLabel(rule: string | null): string {
return RULE_LABEL[rule] ?? rule.replaceAll("_", " "); 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> { async function errorDetail(response: Response): Promise<string> {
try { try {
const body = (await response.json()) as { error?: string }; 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-waived: oklch(0.82 0.14 80);
--verdict-rejected: oklch(0.68 0.02 250); --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: /* library status (§4.2) — informational, never a severity ramp:
activity in one violet family, satisfaction in neutrals */ activity in one violet family, satisfaction in neutrals */
--status-airing: oklch(0.78 0.14 305); --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%); 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 { .rel-line {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -1002,20 +1012,6 @@ body {
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-1); 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 */ /* §9.3: the release name is secondary — evidence, not the primary key */
@@ -1044,25 +1040,34 @@ body {
color: var(--signal-fault); color: var(--signal-fault);
} }
.rel-full { /* score heat: data-score is the list-relative stop, 0 lowest … 6 highest */
flex-basis: 100%; .rel-line .cw-score[data-score="0"] {
padding: var(--space-2) var(--space-3); color: var(--score-0);
background: var(--panel); border-color: oklch(from var(--score-0) l c h / 45%);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: inset 0 1px var(--highlight);
} }
.rel-line .cw-score[data-score="1"] {
.rel-raw { color: var(--score-1);
margin: 0; border-color: oklch(from var(--score-1) l c h / 45%);
overflow-wrap: anywhere;
font-size: var(--text-xs);
color: var(--ink);
} }
.rel-line .cw-score[data-score="2"] {
.rel-meta { color: var(--score-2);
margin: var(--space-1) 0 0; border-color: oklch(from var(--score-2) l c h / 45%);
font-size: var(--text-2xs); }
.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) ---------------------------------------------- */ /* ---- library view (§4.2) ---------------------------------------------- */
@@ -1236,8 +1241,8 @@ body {
/* ---- narrow deck (§9.3 at phone widths) ------------------------------- */ /* ---- narrow deck (§9.3 at phone widths) ------------------------------- */
/* the full seven-column line is 36rem; under 40rem it cannot fit, so hdr /* 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 and audio leave the line and the five surviving columns tighten —
surviving columns tighten — aligned, never wrapped, never scrolled */ aligned, never wrapped, never scrolled */
@media (max-width: 40rem) { @media (max-width: 40rem) {
.colhead .cw, .colhead .cw,
.rel-line .chip { .rel-line .chip {