feat(web): score heat scale, drop release row expand
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:
+40
-45
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user