diff --git a/web/index.html b/web/index.html index 4a56b13..67b6572 100644 --- a/web/index.html +++ b/web/index.html @@ -263,12 +263,19 @@ @@ -500,12 +507,19 @@ diff --git a/web/src/main.ts b/web/src/main.ts index 4dc735c..4def99e 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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; @@ -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,13 +1393,15 @@ 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; } @@ -1413,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}`)); @@ -1605,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), + ), ); } } @@ -3227,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}`)); @@ -3329,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 { @@ -3415,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 ?? ""; @@ -3449,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); } @@ -3536,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) { @@ -3581,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 ?? ""; diff --git a/web/src/style.css b/web/src/style.css index efe15b9..41bdf3e 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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 {