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:
+6
-1
@@ -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
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user