diff --git a/web/index.html b/web/index.html index 67b6572..add0cda 100644 --- a/web/index.html +++ b/web/index.html @@ -252,7 +252,7 @@ id="movie-blocked" aria-pressed="false" > - blocked + no targeted search root @@ -278,6 +278,9 @@ + + targeted search is off for this title. RSS still matches it, so a release can still arrive. + @@ -314,7 +317,7 @@ - waived + below policy - a policy edit rescores search decks immediately — open any title's releases to check it. + a policy edit rescores every title's releases immediately — open any title to check it. diff --git a/web/src/library.ts b/web/src/library.ts index ad6531f..517ba94 100644 --- a/web/src/library.ts +++ b/web/src/library.ts @@ -70,6 +70,9 @@ export function movieNeedsAttention(movie: LibraryMovie): boolean { * `allow_english_audio` reads as "english, no dub", not as a clean match. * The import pipeline records the relaxed rule; both the bare rule name and * an object carrying one are accepted. + * + * Every arm names what was relaxed, never what the record calls it — the + * operator can act on "english, no dub" and cannot act on "waived". */ export function waiverLabel(waiver: unknown): string | null { const rule = waiverRule(waiver); @@ -81,8 +84,10 @@ export function waiverLabel(waiver: unknown): string | null { return "english, no dub"; case "resolution": return "below wanted resolution"; + case "size": + return "below the size floor"; default: - return `waived · ${rule.replaceAll("_", " ")}`; + return `${rule.replaceAll("_", " ")} relaxed`; } } diff --git a/web/src/main.ts b/web/src/main.ts index 9aabc27..e5aac0c 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -720,6 +720,18 @@ function searchMain( return { showIdle, restore }; } +/** + * What the operator reads for the stored `blocked` flag. §6.3 gives it one + * effect — it stops the targeted-search lane and leaves RSS matching on — + * and "blocked" reads as "block this title outright", which is the wrong + * promise. The flag, the column and the API keep their name; only the words + * on screen say what it does. [`BLOCKED_EFFECT`] carries the RSS half. + */ +const NO_TARGETED_SEARCH = "no targeted search"; + +/** The half a three-word label cannot hold, for titles and accessible names. */ +const BLOCKED_EFFECT = "arr stops searching for this title; RSS still matches it"; + function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanElement { const span = document.createElement("span"); span.className = "chip readout"; @@ -931,7 +943,7 @@ function libraryRow( chips.append(chip("not wanted")); } if (movie.blocked) { - chips.append(chip("blocked")); + chips.append(chip(NO_TARGETED_SEARCH)); } const rating = ratingChip(movie.vote_average); if (rating !== null) { @@ -964,7 +976,7 @@ function librarySeriesRow( const root = roots.find((candidate) => candidate.id === series.root_id); chips.append(chip(root ? root.audience : `root ${series.root_id}`)); if (series.blocked) { - chips.append(chip("blocked")); + chips.append(chip(NO_TARGETED_SEARCH)); } const rating = ratingChip(series.vote_average); if (rating !== null) { @@ -1352,6 +1364,10 @@ function movieMain(views: HideableView[]): MovieView { const actionsEl = must("#movie-actions"); const wanted = must("#movie-wanted"); const blocked = must("#movie-blocked"); + const blockedNote = must("#movie-blocked-note"); + // the accessible name carries what three words cannot, at rest and on + blocked.setAttribute("aria-label", `${NO_TARGETED_SEARCH} — ${BLOCKED_EFFECT}`); + blocked.title = BLOCKED_EFFECT; const rootSelect = must("#movie-root"); const sweep = must("#movie-sweep"); const remove = must("#movie-remove"); @@ -1474,7 +1490,7 @@ function movieMain(views: HideableView[]): MovieView { ); } if (movie.blocked) { - chipsEl.append(chip("blocked")); + chipsEl.append(chip(NO_TARGETED_SEARCH)); } } @@ -1495,6 +1511,10 @@ function movieMain(views: HideableView[]): MovieView { } wanted.setAttribute("aria-pressed", String(movie.wanted)); blocked.setAttribute("aria-pressed", String(movie.blocked)); + // §6.3 is the whole point of the flag and a bare toggle hides it: the + // note appears when the flag is on, which is when the RSS half is the + // thing that surprises. At rest the label and the title carry it. + blockedNote.hidden = !movie.blocked; paintRootOptions(); // a root list that does not carry the title's own root still shows it — // a select lying by omission would make the next change move it blindly @@ -1549,7 +1569,7 @@ function movieMain(views: HideableView[]): MovieView { void updateMovie(movie.id, { blocked: !movie.blocked }).then((outcome) => { if (outcome.kind === "error") { blocked.disabled = false; - setStatus(`blocked failed — ${outcome.detail}`, "fault"); + setStatus(`targeted search change failed — ${outcome.detail}`, "fault"); return; } void refreshMovie().then(() => { @@ -2217,13 +2237,13 @@ function paintBuckets(dom: BucketsDom, releases: MovieRelease[], actions: Releas // §9.3: over-strict filters must be visible, not silently absent — and // where a rule can be waived, the count says so rather than leaving the // way out folded inside a collapsed bucket. - const waivable = releases.filter(overridable).length; + const forceable = releases.filter(overridable).length; const none = document.createElement("li"); none.className = "rel rel-none readout dim"; none.textContent = - waivable > 0 - ? `none — every candidate was waived or rejected by policy; ${waivable} can be waived` - : "none — every candidate was waived or rejected by policy"; + forceable > 0 + ? `none — every candidate is below policy or rejected; ${forceable} can be force grabbed` + : "none — every candidate is below policy or rejected"; dom.eligible.rows.append(none); } for (const name of ["waived", "rejected"] as const) { @@ -2310,7 +2330,7 @@ function buildBucketDom(root: HTMLElement): BucketsDom { const dom: BucketsDom = { eligible: { section: eligibleSection, count: eligibleCount, rows: eligibleRows }, - waived: collapsed("waived"), + waived: collapsed("below policy"), rejected: collapsed("rejected"), }; root.append(dom.eligible.section, dom.waived.section, dom.rejected.section); @@ -2361,11 +2381,15 @@ function releaseRow( } if (bucket !== "eligible") { // A rejected row always names its rule; a waived one cannot — the - // `releases` CHECK allows `rejected_rule` only on a rejection. Say - // `waived` plainly rather than calling a classified row unclassified. - const verdict = release.rejected_rule - ? `${bucket} · ${ruleLabel(release.rejected_rule)}` - : bucket; + // `releases` CHECK allows `rejected_rule` only on a rejection. So a + // waived row says the plainer thing the operator can act on, "below + // policy", rather than the name the record keeps for it. + const verdict = + bucket === "waived" + ? "below policy" + : release.rejected_rule + ? `rejected · ${ruleLabel(release.rejected_rule)}` + : "rejected"; line.append( chip(verdict, (span) => { span.dataset.verdict = bucket; @@ -2392,7 +2416,14 @@ function releaseRow( const grab = document.createElement("button"); grab.type = "button"; grab.className = "control rel-grab"; - grab.textContent = writesOverride ? "waive + grab" : "grab"; + // "force grab" says what the click does; the accessible name says what + // it forces past, since the rule is the reason the row is not eligible. + grab.textContent = writesOverride ? "force grab" : "grab"; + if (writesOverride) { + const forced = `force grab — relaxes ${ruleLabel(release.rejected_rule)} for this title and grabs anyway`; + grab.setAttribute("aria-label", forced); + grab.title = forced; + } grab.addEventListener("click", () => { grab.disabled = true; note.hidden = false; @@ -2665,7 +2696,7 @@ function seriesRow( ); } if (series.blocked) { - chips.append(chip("blocked")); + chips.append(chip(NO_TARGETED_SEARCH)); } chips.append( chip(series.status, (span) => { @@ -2777,7 +2808,7 @@ function seriesCard( ); } if (series.blocked) { - chips.push(chip("blocked")); + chips.push(chip(NO_TARGETED_SEARCH)); } chips.push( chip(series.status, (span) => { @@ -2814,7 +2845,7 @@ function movieCard( chips.push(chip("not wanted")); } if (movie.blocked) { - chips.push(chip("blocked")); + chips.push(chip(NO_TARGETED_SEARCH)); } const rating = ratingChip(movie.vote_average); if (rating !== null) { @@ -2901,7 +2932,11 @@ function tvReleasesMain(): TvReleasesView { grab: (release, bucket) => { const current = request; if (!current) { - return Promise.resolve({ kind: "error", detail: "deck closed", overrideWritten: false }); + return Promise.resolve({ + kind: "error", + detail: "releases closed", + overrideWritten: false, + }); } if (bucket !== "eligible") { return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule); @@ -3306,7 +3341,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { }), ); if (current.blocked) { - chipsEl.append(chip("blocked")); + chipsEl.append(chip(NO_TARGETED_SEARCH)); } } @@ -3495,8 +3530,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { deckBtn.className = "control control-icon"; const deckLabel = season.number === 0 - ? "open the specials release deck" - : `open the season ${PAD_TWO(season.number)} release deck`; + ? "search releases for specials" + : `search releases for season ${PAD_TWO(season.number)}`; deckBtn.setAttribute("aria-label", deckLabel); deckBtn.title = deckLabel; deckBtn.append(icon("search")); @@ -3663,7 +3698,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { const deckBtn = document.createElement("button"); deckBtn.type = "button"; deckBtn.className = "control control-icon"; - const deckLabel = `open the ${episodeTag(seasonNumber, episode.number)} release deck`; + const deckLabel = `search releases for ${episodeTag(seasonNumber, episode.number)}`; deckBtn.setAttribute("aria-label", deckLabel); deckBtn.title = deckLabel; deckBtn.append(icon("search")); diff --git a/web/src/style.css b/web/src/style.css index ab58117..6bc612b 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1748,6 +1748,17 @@ body { padding: 0 var(--space-3); } +/* The one control whose effect is narrower than its name suggests (§6.3) + says so under the row while it is on: three words on the button, the + half they cannot hold on the line below. Aligned with the row's own + padding so it reads as a note on the controls, not a new section. */ +.control-note { + margin: 0; + padding: 0 var(--space-1) var(--space-2); + color: var(--ink-muted); + font-size: var(--text-xs); +} + /* wanted and blocked read as rules, pressed = on — the tracked-toggle idiom */ .movie-controls .control[aria-pressed="true"] { color: var(--accent-bright);
+ targeted search is off for this title. RSS still matches it, so a release can still arrive. +
- a policy edit rescores search decks immediately — open any title's releases to check it. + a policy edit rescores every title's releases immediately — open any title to check it.