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,
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user