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
-3
@@ -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
@@ -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
@@ -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"));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user