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 -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.
* 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`;
}
}
+58 -23
View File
@@ -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<HTMLElement>("#movie-actions");
const wanted = must<HTMLButtonElement>("#movie-wanted");
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 sweep = must<HTMLButtonElement>("#movie-sweep");
const remove = must<HTMLButtonElement>("#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"));
+11
View File
@@ -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);