feat(web): plainer words for grab, blocked, deck

Three words the operator reads change; nothing underneath does. Waivers
are still written, recorded and served under their existing names, the
`blocked` column and flag keep theirs, and the release deck keeps its
name in DESIGN.md §9.3 and in the code.

- `waive + grab` reads `force grab`, and its accessible name says which
  rule the click relaxes. The `waived` bucket reads `below policy`, and
  so does the verdict chip on its rows — an operator can act on "below
  policy" and cannot act on the name the record keeps.
- A waived import already said what was relaxed for two rules; `size`
  joins them and the fallback names the rule rather than badging it
  `waived`.
- `blocked` reads `no targeted search`, everywhere the flag surfaces.
  §6.3 gives it one effect and a bare toggle hid it: the accessible name
  carries the RSS half at rest, and a note under the controls spells it
  out while the flag is on.
- The season and episode controls are already the #230 search icon; only
  their labels still said "deck". They now say what the click does.

`just ci` passes through the gate: 509 tests, biome, tsc, tokens.
Verified in a real browser (agent-browser) on the library, movie detail,
series detail, the episode release view, queues and settings, at 1440
and 390 wide — no horizontal scroll at either.

Refs #232
This commit is contained in:
Miguel Palhas
2026-08-25 11:43:14 +01:00
parent cfa0eaa77d
commit a6720f4c06
4 changed files with 81 additions and 27 deletions
+6 -3
View File
@@ -252,7 +252,7 @@
id="movie-blocked" id="movie-blocked"
aria-pressed="false" aria-pressed="false"
> >
blocked no targeted search
</button> </button>
<label class="movie-root-field"> <label class="movie-root-field">
<span class="field-label readout dim" id="movie-root-label">root</span> <span class="field-label readout dim" id="movie-root-label">root</span>
@@ -278,6 +278,9 @@
</svg> </svg>
</button> </button>
</div> </div>
<p class="control-note readout dim" id="movie-blocked-note" hidden>
targeted search is off for this title. RSS still matches it, so a release can still arrive.
</p>
<div class="remove-panel" id="remove-panel" hidden></div> <div class="remove-panel" id="remove-panel" hidden></div>
</section> </section>
@@ -314,7 +317,7 @@
<section class="deck-group" id="bucket-waived" hidden aria-labelledby="label-waived"> <section class="deck-group" id="bucket-waived" hidden aria-labelledby="label-waived">
<header class="deck-head bucket-head"> <header class="deck-head bucket-head">
<h3 class="deck-label" id="label-waived">waived</h3> <h3 class="deck-label" id="label-waived">below policy</h3>
<button <button
type="button" type="button"
class="bucket-toggle readout" class="bucket-toggle readout"
@@ -455,7 +458,7 @@
</button> </button>
</header> </header>
<p class="queue-note readout dim"> <p class="queue-note readout dim">
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.
</p> </p>
<ul class="deck-rows" id="rows-policies"></ul> <ul class="deck-rows" id="rows-policies"></ul>
</section> </section>
+6 -1
View File
@@ -70,6 +70,9 @@ export function movieNeedsAttention(movie: LibraryMovie): boolean {
* `allow_english_audio` reads as "english, no dub", not as a clean match. * `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 * The import pipeline records the relaxed rule; both the bare rule name and
* an object carrying one are accepted. * 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 { export function waiverLabel(waiver: unknown): string | null {
const rule = waiverRule(waiver); const rule = waiverRule(waiver);
@@ -81,8 +84,10 @@ export function waiverLabel(waiver: unknown): string | null {
return "english, no dub"; return "english, no dub";
case "resolution": case "resolution":
return "below wanted resolution"; return "below wanted resolution";
case "size":
return "below the size floor";
default: default:
return `waived · ${rule.replaceAll("_", " ")}`; return `${rule.replaceAll("_", " ")} relaxed`;
} }
} }
+58 -23
View File
@@ -720,6 +720,18 @@ function searchMain(
return { showIdle, restore }; 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 { function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanElement {
const span = document.createElement("span"); const span = document.createElement("span");
span.className = "chip readout"; span.className = "chip readout";
@@ -931,7 +943,7 @@ function libraryRow(
chips.append(chip("not wanted")); chips.append(chip("not wanted"));
} }
if (movie.blocked) { if (movie.blocked) {
chips.append(chip("blocked")); chips.append(chip(NO_TARGETED_SEARCH));
} }
const rating = ratingChip(movie.vote_average); const rating = ratingChip(movie.vote_average);
if (rating !== null) { if (rating !== null) {
@@ -964,7 +976,7 @@ function librarySeriesRow(
const root = roots.find((candidate) => candidate.id === series.root_id); const root = roots.find((candidate) => candidate.id === series.root_id);
chips.append(chip(root ? root.audience : `root ${series.root_id}`)); chips.append(chip(root ? root.audience : `root ${series.root_id}`));
if (series.blocked) { if (series.blocked) {
chips.append(chip("blocked")); chips.append(chip(NO_TARGETED_SEARCH));
} }
const rating = ratingChip(series.vote_average); const rating = ratingChip(series.vote_average);
if (rating !== null) { if (rating !== null) {
@@ -1352,6 +1364,10 @@ function movieMain(views: HideableView[]): MovieView {
const actionsEl = must<HTMLElement>("#movie-actions"); const actionsEl = must<HTMLElement>("#movie-actions");
const wanted = must<HTMLButtonElement>("#movie-wanted"); const wanted = must<HTMLButtonElement>("#movie-wanted");
const blocked = must<HTMLButtonElement>("#movie-blocked"); const blocked = must<HTMLButtonElement>("#movie-blocked");
const blockedNote = must<HTMLElement>("#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<HTMLSelectElement>("#movie-root"); const rootSelect = must<HTMLSelectElement>("#movie-root");
const sweep = must<HTMLButtonElement>("#movie-sweep"); const sweep = must<HTMLButtonElement>("#movie-sweep");
const remove = must<HTMLButtonElement>("#movie-remove"); const remove = must<HTMLButtonElement>("#movie-remove");
@@ -1474,7 +1490,7 @@ function movieMain(views: HideableView[]): MovieView {
); );
} }
if (movie.blocked) { 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)); wanted.setAttribute("aria-pressed", String(movie.wanted));
blocked.setAttribute("aria-pressed", String(movie.blocked)); 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(); paintRootOptions();
// a root list that does not carry the title's own root still shows it — // 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 // 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) => { void updateMovie(movie.id, { blocked: !movie.blocked }).then((outcome) => {
if (outcome.kind === "error") { if (outcome.kind === "error") {
blocked.disabled = false; blocked.disabled = false;
setStatus(`blocked failed — ${outcome.detail}`, "fault"); setStatus(`targeted search change failed — ${outcome.detail}`, "fault");
return; return;
} }
void refreshMovie().then(() => { 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 // §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 // where a rule can be waived, the count says so rather than leaving the
// way out folded inside a collapsed bucket. // way out folded inside a collapsed bucket.
const waivable = releases.filter(overridable).length; const forceable = releases.filter(overridable).length;
const none = document.createElement("li"); const none = document.createElement("li");
none.className = "rel rel-none readout dim"; none.className = "rel rel-none readout dim";
none.textContent = none.textContent =
waivable > 0 forceable > 0
? `none — every candidate was waived or rejected by policy; ${waivable} can be waived` ? `none — every candidate is below policy or rejected; ${forceable} can be force grabbed`
: "none — every candidate was waived or rejected by policy"; : "none — every candidate is below policy or rejected";
dom.eligible.rows.append(none); dom.eligible.rows.append(none);
} }
for (const name of ["waived", "rejected"] as const) { for (const name of ["waived", "rejected"] as const) {
@@ -2310,7 +2330,7 @@ function buildBucketDom(root: HTMLElement): BucketsDom {
const dom: BucketsDom = { const dom: BucketsDom = {
eligible: { section: eligibleSection, count: eligibleCount, rows: eligibleRows }, eligible: { section: eligibleSection, count: eligibleCount, rows: eligibleRows },
waived: collapsed("waived"), waived: collapsed("below policy"),
rejected: collapsed("rejected"), rejected: collapsed("rejected"),
}; };
root.append(dom.eligible.section, dom.waived.section, dom.rejected.section); root.append(dom.eligible.section, dom.waived.section, dom.rejected.section);
@@ -2361,11 +2381,15 @@ function releaseRow(
} }
if (bucket !== "eligible") { if (bucket !== "eligible") {
// A rejected row always names its rule; a waived one cannot — the // A rejected row always names its rule; a waived one cannot — the
// `releases` CHECK allows `rejected_rule` only on a rejection. Say // `releases` CHECK allows `rejected_rule` only on a rejection. So a
// `waived` plainly rather than calling a classified row unclassified. // waived row says the plainer thing the operator can act on, "below
const verdict = release.rejected_rule // policy", rather than the name the record keeps for it.
? `${bucket} · ${ruleLabel(release.rejected_rule)}` const verdict =
: bucket; bucket === "waived"
? "below policy"
: release.rejected_rule
? `rejected · ${ruleLabel(release.rejected_rule)}`
: "rejected";
line.append( line.append(
chip(verdict, (span) => { chip(verdict, (span) => {
span.dataset.verdict = bucket; span.dataset.verdict = bucket;
@@ -2392,7 +2416,14 @@ function releaseRow(
const grab = document.createElement("button"); const grab = document.createElement("button");
grab.type = "button"; grab.type = "button";
grab.className = "control rel-grab"; 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.addEventListener("click", () => {
grab.disabled = true; grab.disabled = true;
note.hidden = false; note.hidden = false;
@@ -2665,7 +2696,7 @@ function seriesRow(
); );
} }
if (series.blocked) { if (series.blocked) {
chips.append(chip("blocked")); chips.append(chip(NO_TARGETED_SEARCH));
} }
chips.append( chips.append(
chip(series.status, (span) => { chip(series.status, (span) => {
@@ -2777,7 +2808,7 @@ function seriesCard(
); );
} }
if (series.blocked) { if (series.blocked) {
chips.push(chip("blocked")); chips.push(chip(NO_TARGETED_SEARCH));
} }
chips.push( chips.push(
chip(series.status, (span) => { chip(series.status, (span) => {
@@ -2814,7 +2845,7 @@ function movieCard(
chips.push(chip("not wanted")); chips.push(chip("not wanted"));
} }
if (movie.blocked) { if (movie.blocked) {
chips.push(chip("blocked")); chips.push(chip(NO_TARGETED_SEARCH));
} }
const rating = ratingChip(movie.vote_average); const rating = ratingChip(movie.vote_average);
if (rating !== null) { if (rating !== null) {
@@ -2901,7 +2932,11 @@ function tvReleasesMain(): TvReleasesView {
grab: (release, bucket) => { grab: (release, bucket) => {
const current = request; const current = request;
if (!current) { 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") { if (bucket !== "eligible") {
return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule); 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) { 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"; deckBtn.className = "control control-icon";
const deckLabel = const deckLabel =
season.number === 0 season.number === 0
? "open the specials release deck" ? "search releases for specials"
: `open the season ${PAD_TWO(season.number)} release deck`; : `search releases for season ${PAD_TWO(season.number)}`;
deckBtn.setAttribute("aria-label", deckLabel); deckBtn.setAttribute("aria-label", deckLabel);
deckBtn.title = deckLabel; deckBtn.title = deckLabel;
deckBtn.append(icon("search")); deckBtn.append(icon("search"));
@@ -3663,7 +3698,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const deckBtn = document.createElement("button"); const deckBtn = document.createElement("button");
deckBtn.type = "button"; deckBtn.type = "button";
deckBtn.className = "control control-icon"; 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.setAttribute("aria-label", deckLabel);
deckBtn.title = deckLabel; deckBtn.title = deckLabel;
deckBtn.append(icon("search")); deckBtn.append(icon("search"));
+11
View File
@@ -1748,6 +1748,17 @@ body {
padding: 0 var(--space-3); 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 */ /* wanted and blocked read as rules, pressed = on — the tracked-toggle idiom */
.movie-controls .control[aria-pressed="true"] { .movie-controls .control[aria-pressed="true"] {
color: var(--accent-bright); color: var(--accent-bright);