Compare commits

...

5 Commits

Author SHA1 Message Date
Miguel Palhas 070fd3d7ba Merge blitz/feedback-2: fmt fix and attention queue 2026-08-25 10:58:07 +01:00
Miguel Palhas 0b4ded3fe9 feat(web): icon-only controls and readouts (#230)
Trash replaces the remove labels on title pages and season and
episode rows, a magnifier replaces the deck control, the season
on-disk readout carries a drive glyph before its 0/10, and the
TMDB, TVDB, IMDb and Rotten Tomatoes links carry drawn marks
shipped inline (wordmark badges plus a tomato), never remote
images. Every icon-only control keeps an aria-label naming the
action and the row or title it acts on, and the arm-then-confirm
delete speaks a visible amber "confirm" while armed, so the first
hit never destroys.

Shared CSS lives in one block in style.css headed
"icon-only controls (#230)" — .icon, .control-icon, .icon-mark —
for #231 to consume for the settings rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:58:07 +01:00
Miguel Palhas afc17ca34a Merge #238: queue only targets still waiting for a file
Closes #238
2026-08-25 10:50:56 +01:00
Miguel Palhas c962998a2b Merge #229: drop the back button, banner meets the rail
Closes #229
2026-08-25 10:42:05 +01:00
Miguel Palhas 815a072ef0 fix(web): drop back button from movie and series pages (#229)
The rail, browser back, and Esc already cover navigation — the back
button was a third way to do what two other things already do, and it
cost the page its first line.

- Remove #movie-back and #series-back buttons and their event listeners
- Focus lands on the title element (tabindex=-1) instead of the removed
  button on open
- TV releases back button is untouched
- Hero banner now meets the rail with zero top padding on movie/series
- Esc and parent-route behaviour unchanged
- Deep link fallback for TV releases uses #nav-library instead of the
  removed #series-back
2026-08-25 10:40:37 +01:00
3 changed files with 290 additions and 71 deletions
+20 -12
View File
@@ -218,10 +218,7 @@
-->
<main class="deck" id="movie" hidden aria-label="movie detail">
<header class="releases-head">
<button type="button" class="control" id="movie-back">back</button>
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
</header>
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
<section class="module movie-hero" id="movie-hero" aria-label="title metadata">
<div class="movie-body">
@@ -266,12 +263,19 @@
<button type="button" class="control" id="movie-sweep">search indexers</button>
<button
type="button"
class="control control-quiet"
class="control control-quiet control-icon"
id="movie-remove"
aria-expanded="false"
aria-controls="remove-panel"
aria-label="remove from the library"
>
remove
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.8 4.3h10.4" />
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
<path d="M6.6 7v4.2" />
<path d="M9.4 7v4.2" />
</svg>
</button>
</div>
<div class="remove-panel" id="remove-panel" hidden></div>
@@ -470,10 +474,7 @@
-->
<main class="deck releases" id="series" hidden aria-label="series detail">
<header class="releases-head">
<button type="button" class="control" id="series-back">back</button>
<p class="deck-status readout" id="series-status" role="status" hidden></p>
</header>
<p class="deck-status readout" id="series-status" role="status" hidden></p>
<section class="module movie-hero" id="series-hero" aria-label="title metadata">
<div class="movie-body">
@@ -506,12 +507,19 @@
<span class="movie-controls-space"></span>
<button
type="button"
class="control control-quiet"
class="control control-quiet control-icon"
id="series-remove"
aria-expanded="false"
aria-controls="series-remove-panel"
aria-label="remove from the library"
>
remove
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.8 4.3h10.4" />
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
<path d="M6.6 7v4.2" />
<path d="M9.4 7v4.2" />
</svg>
</button>
</div>
<div class="remove-panel" id="series-remove-panel" hidden></div>
+208 -59
View File
@@ -103,7 +103,7 @@ import {
type TvTarget,
waiveAndGrabTv,
} from "./series";
import { armedDelete, settingsMain } from "./settings";
import { settingsMain } from "./settings";
import "./style.css";
const POLL_MS = 15_000;
@@ -393,14 +393,14 @@ function main() {
must<HTMLElement>("#library"),
{ kind: "library" },
);
// no origin click to restore focus to on a deep link — the series
// view's back control is the closest stand-in
// no origin click to restore focus to on a deep link — the library
// rail button is the closest stand-in
tvDeck.open({
title,
sub,
seriesId,
target,
origin: must<HTMLButtonElement>("#series-back"),
origin: must<HTMLElement>("#nav-library"),
returnTo: must<HTMLElement>("#series"),
parentRoute: { kind: "series", seriesId },
});
@@ -1263,6 +1263,118 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) {
});
}
/* ---- icon-only controls (#230) ----------------------------------------- */
const SVG_NS = "http://www.w3.org/2000/svg";
const ICON_PATHS = {
trash: [
"M2.8 4.3h10.4",
"M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2",
"M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5",
"M6.6 7v4.2",
"M9.4 7v4.2",
],
search: ["M7.2 2.6a4.4 4.4 0 1 1 0 8.8 4.4 4.4 0 0 1 0-8.8Z", "M10.4 10.4l3.2 3.2"],
disk: [
"M2.5 9.8l1.6-5c.2-.7.8-1.2 1.5-1.2h4.8c.7 0 1.3.5 1.5 1.2l1.6 5",
"M2.5 9.8h11v2.5c0 .7-.5 1.2-1.2 1.2H3.7c-.7 0-1.2-.5-1.2-1.2z",
"M10.9 11.6h.01",
],
} as const;
/** Stroke glyph on the shared 16-grid; the control's aria-label carries the meaning. */
function icon(name: keyof typeof ICON_PATHS): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 16 16");
svg.setAttribute("class", "icon");
svg.setAttribute("aria-hidden", "true");
for (const d of ICON_PATHS[name]) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", d);
svg.append(path);
}
return svg;
}
/** §9.6 provider marks, drawn and shipped inline — never a remote image. */
function providerMark(name: "tmdb" | "tvdb" | "imdb" | "rt"): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("class", "icon icon-mark");
svg.setAttribute("aria-hidden", "true");
if (name === "rt") {
svg.setAttribute("viewBox", "0 0 20 20");
const paths = [
"M10 6.6c4.1 0 6.8 2.1 6.8 5.2 0 3.3-3 5.7-6.8 5.7s-6.8-2.4-6.8-5.7c0-3.1 2.7-5.2 6.8-5.2Z",
"M10 6.6C8.6 7.1 7 6.8 5.9 5.8",
"M10 6.6c1.4.5 3 .2 4.1-.8",
"M10 6.6c-.3-1.2 0-2.3.9-3.2",
];
for (const d of paths) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", d);
svg.append(path);
}
return svg;
}
svg.setAttribute("viewBox", "0 0 44 20");
const box = document.createElementNS(SVG_NS, "rect");
box.setAttribute("x", "1");
box.setAttribute("y", "1.5");
box.setAttribute("width", "42");
box.setAttribute("height", "17");
box.setAttribute("rx", "4");
const letters = document.createElementNS(SVG_NS, "text");
letters.setAttribute("x", "22");
letters.setAttribute("y", "13.2");
letters.setAttribute("text-anchor", "middle");
letters.setAttribute("font-size", "9");
letters.textContent = { tmdb: "TMDB", tvdb: "TVDB", imdb: "IMDb" }[name];
svg.append(box, letters);
return svg;
}
/**
* The settings arm-then-confirm, icon-only: a trash glyph resting, the
* visible word "confirm" while armed, so an accidental hit never destroys.
*/
function armedDeleteIcon(label: string, execute: () => void): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "control control-quiet control-icon";
button.setAttribute("aria-label", label);
button.title = label;
button.replaceChildren(icon("trash"));
let armed = false;
let resetTimer: number | undefined;
const disarm = () => {
armed = false;
window.clearTimeout(resetTimer);
delete button.dataset.armed;
button.setAttribute("aria-label", label);
button.replaceChildren(icon("trash"));
};
button.addEventListener("click", () => {
if (armed) {
disarm();
button.disabled = true;
execute();
return;
}
armed = true;
button.dataset.armed = "true";
button.textContent = "confirm";
button.setAttribute("aria-label", `confirm — ${label}`);
resetTimer = window.setTimeout(disarm, 4000);
});
button.addEventListener("blur", () => {
if (armed) {
disarm();
}
});
return button;
}
/* ---- §9.6 painters shared by both detail pages ------------------------- */
/** The drawn star before a TMDB rating — no glyph standing in for an icon. */
@@ -1281,20 +1393,21 @@ function starIcon(): SVGSVGElement {
}
/** External links read as quiet controls; they leave the app entirely. */
function externalLink(label: string, href: string): HTMLAnchorElement {
function externalLink(mark: SVGSVGElement, label: string, href: string): HTMLAnchorElement {
const link = document.createElement("a");
link.className = "control control-quiet";
link.className = "control control-quiet control-icon";
link.href = href;
link.target = "_blank";
link.rel = "noreferrer";
link.textContent = label;
link.setAttribute("aria-label", label);
link.title = label;
link.append(mark);
return link;
}
function movieMain(views: HideableView[]): MovieView {
const view = must<HTMLElement>("#movie");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#movie-back");
const statusEl = must<HTMLElement>("#movie-status");
const hero = must<HTMLElement>("#movie-hero");
const poster = must<HTMLImageElement>("#movie-poster");
@@ -1414,6 +1527,8 @@ function movieMain(views: HideableView[]): MovieView {
}
titleEl.textContent = movie.title;
yearEl.textContent = movie.year === null ? "" : String(movie.year);
remove.setAttribute("aria-label", `remove ${movie.title} from the library`);
remove.title = `remove ${movie.title} from the library`;
chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === movie.root_id);
chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`));
@@ -1606,13 +1721,26 @@ function movieMain(views: HideableView[]): MovieView {
trailer.textContent = "trailer";
actionsEl.append(trailer);
}
actionsEl.append(externalLink("tmdb", tmdbMovieLink(detail.tmdb_id)));
const linkTitle = current?.title ?? "this movie";
actionsEl.append(
externalLink(
providerMark("tmdb"),
`open ${linkTitle} on TMDB`,
tmdbMovieLink(detail.tmdb_id),
),
);
if (detail.imdb_id !== null) {
actionsEl.append(externalLink("imdb", imdbLink(detail.imdb_id)));
actionsEl.append(
externalLink(providerMark("imdb"), `open ${linkTitle} on IMDb`, imdbLink(detail.imdb_id)),
);
}
if (current !== null) {
actionsEl.append(
externalLink("rotten tomatoes", rottenTomatoesSearch(current.title, current.year)),
externalLink(
providerMark("rt"),
`search Rotten Tomatoes for ${current.title}`,
rottenTomatoesSearch(current.title, current.year),
),
);
}
}
@@ -1906,7 +2034,8 @@ function movieMain(views: HideableView[]): MovieView {
filesSection.hidden = true;
diskRows.replaceChildren();
sweep.disabled = false;
back.focus();
titleEl.setAttribute("tabindex", "-1");
titleEl.focus();
await load();
}
@@ -1929,8 +2058,6 @@ function movieMain(views: HideableView[]): MovieView {
target?.focus();
}
back.addEventListener("click", close);
// capture + stopImmediatePropagation: one Escape steps back one layer —
// the library and search decks also listen for Escape on this window
window.addEventListener(
@@ -3125,7 +3252,6 @@ const SERIES_REFRESH_WAIT_MS = 30_000;
function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const view = must<HTMLElement>("#series");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#series-back");
const hero = must<HTMLElement>("#series-hero");
const poster = must<HTMLImageElement>("#series-poster");
const titleEl = must<HTMLElement>("#series-title");
@@ -3230,6 +3356,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
}
titleEl.textContent = current.title;
yearEl.textContent = current.year === null ? "" : String(current.year);
remove.setAttribute("aria-label", `remove ${current.title} from the library`);
remove.title = `remove ${current.title} from the library`;
chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === current.root_id);
chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`));
@@ -3332,26 +3460,41 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
trailer.textContent = "trailer";
actionsEl.append(trailer);
}
actionsEl.append(externalLink("tmdb", tmdbSeriesLink(detail.tmdb_id)));
const linkTitle = series?.title ?? "this series";
actionsEl.append(
externalLink(
providerMark("tmdb"),
`open ${linkTitle} on TMDB`,
tmdbSeriesLink(detail.tmdb_id),
),
);
// a series has no imdb_id in this app; TVDB is its second id (§9.6)
if (detail.tvdb_id !== null) {
actionsEl.append(externalLink("tvdb", tvdbLink(detail.tvdb_id)));
actionsEl.append(
externalLink(providerMark("tvdb"), `open ${linkTitle} on TVDB`, tvdbLink(detail.tvdb_id)),
);
}
if (series !== null) {
actionsEl.append(
externalLink("rotten tomatoes", rottenTomatoesSearch(series.title, series.year)),
externalLink(
providerMark("rt"),
`search Rotten Tomatoes for ${series.title}`,
rottenTomatoesSearch(series.title, series.year),
),
);
}
}
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
const counts = seasonCounts(season);
return countsChip(
`${counts.available}/${counts.wanted} on disk`,
const span = countsChip(
`${counts.available}/${counts.wanted}`,
counts.available,
counts.wanted,
`${counts.available} of ${counts.wanted} wanted episodes on disk`,
);
span.prepend(icon("disk"));
return span;
}
function seasonRow(season: ApiSeason): HTMLLIElement {
@@ -3418,8 +3561,14 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const deckBtn = document.createElement("button");
deckBtn.type = "button";
deckBtn.className = "control";
deckBtn.textContent = "deck";
deckBtn.className = "control control-icon";
const deckLabel =
season.number === 0
? "open the specials release deck"
: `open the season ${PAD_TWO(season.number)} release deck`;
deckBtn.setAttribute("aria-label", deckLabel);
deckBtn.title = deckLabel;
deckBtn.append(icon("search"));
deckBtn.addEventListener("click", () => {
const currentId = seriesId;
const currentTitle = series?.title ?? "";
@@ -3452,26 +3601,25 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
// #174: files go, episodes stop being wanted, the season stays listed.
// Only offered with files on disk — intent alone is the tracked toggle.
if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) {
const clear = armedDelete("remove files", () => {
const currentId = seriesId;
if (currentId === null) {
return;
}
void removeSeasonFiles(currentId, season.number).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
const clear = armedDeleteIcon(
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
() => {
const currentId = seriesId;
if (currentId === null) {
return;
}
// the control itself disappears with the files; the tracked
// toggle is the season's control that survives the repaint
focusKey = `track-${season.number}`;
void load();
});
});
clear.setAttribute(
"aria-label",
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
void removeSeasonFiles(currentId, season.number).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
return;
}
// the control itself disappears with the files; the tracked
// toggle is the season's control that survives the repaint
focusKey = `track-${season.number}`;
void load();
});
},
);
line.append(clear);
}
@@ -3539,21 +3687,20 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
if (aired && onDisk) {
// #174: the file goes and the episode stops being wanted; the row
// stays listed. Same arm-then-confirm as a settings row.
const clear = armedDelete("remove file", () => {
void removeEpisodeFiles(episode.id).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
return;
}
// once missing, the row's want control is what remains to focus
focusKey = `want-${episode.id}`;
void load();
});
});
clear.setAttribute(
"aria-label",
const clear = armedDeleteIcon(
`remove the ${episodeTag(seasonNumber, episode.number)} file from disk and stop wanting the episode — it stays listed`,
() => {
void removeEpisodeFiles(episode.id).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
return;
}
// once missing, the row's want control is what remains to focus
focusKey = `want-${episode.id}`;
void load();
});
},
);
actions.append(clear);
} else if (!aired) {
@@ -3584,8 +3731,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const deckBtn = document.createElement("button");
deckBtn.type = "button";
deckBtn.className = "control";
deckBtn.textContent = "deck";
deckBtn.className = "control control-icon";
const deckLabel = `open the ${episodeTag(seasonNumber, episode.number)} release deck`;
deckBtn.setAttribute("aria-label", deckLabel);
deckBtn.title = deckLabel;
deckBtn.append(icon("search"));
deckBtn.addEventListener("click", () => {
const currentId = seriesId;
const currentTitle = series?.title ?? "";
@@ -3793,7 +3943,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
view.hidden = false;
clearRemove();
clearRichDetail();
back.focus();
titleEl.setAttribute("tabindex", "-1");
titleEl.focus();
await load();
}
@@ -3814,8 +3965,6 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
origin?.focus();
}
back.addEventListener("click", close);
// capture, like every other layer: the tv deck's listener is registered
// first, so one Esc steps back one layer
window.addEventListener(
+62
View File
@@ -470,6 +470,62 @@ body {
outline-offset: 2px;
}
/* ---- icon-only controls (#230) ---------------------------------------- */
/* One block, shared: title pages, decks and readouts here, the settings
rows via #231. The glyph carries the action, the aria-label carries the
meaning, and the geometry stays on the --control-h rail with the chips. */
.icon {
display: block;
flex: none;
height: 0.875rem;
width: auto;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.control-icon {
min-width: var(--control-h);
padding: 0 var(--space-2);
}
/* provider wordmark badges (§9.6): the mark is drawn and shipped inline,
monochrome on currentColor — never a remote image */
.icon-mark {
height: 1.125rem;
}
.icon-mark rect {
stroke-width: 1.6;
}
/* the mark's letters live in viewBox units, so the size rides the SVG
scale as an attribute; only voice and weight come from the tokens */
.icon-mark text {
fill: currentColor;
stroke: none;
font-family: var(--font-readout);
font-weight: 700;
letter-spacing: 0.06em;
}
/* a readout chip's glyph sits beside its figure, sized to the readout */
.chip .icon {
height: 0.75rem;
stroke-width: 1.7;
}
/* rows widen every .control's padding; an icon control stays near-square */
.season-line .control-icon,
.ep-actions .control-icon,
.movie-controls .control-icon {
padding: 0 var(--space-2);
}
/* ---- unified search (§9.2) ------------------------------------------- */
.rail-search {
@@ -528,6 +584,12 @@ body {
flex: 1;
}
/* movie and series: the hero banner meets the rail — no top padding (#229) */
.deck#movie,
.deck#series {
padding-top: 0;
}
.deck-status {
margin: 0 0 var(--space-4);
color: var(--ink-muted);