diff --git a/web/src/icons.ts b/web/src/icons.ts index 89cdfce..7b21dc4 100644 --- a/web/src/icons.ts +++ b/web/src/icons.ts @@ -52,6 +52,7 @@ export function armedDeleteIcon(label: string, execute: () => void): HTMLButtonE const button = document.createElement("button"); button.type = "button"; button.className = "control control-quiet control-icon"; + button.dataset.kind = "delete"; button.setAttribute("aria-label", label); button.title = label; button.replaceChildren(icon("trash")); diff --git a/web/src/main.ts b/web/src/main.ts index 502055c..afb51cf 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1285,6 +1285,14 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) { // icon() and armedDeleteIcon() live in icons.ts, shared with the settings // rows (#231); the §9.6 painters below stay here as their only consumer. +/** Invisible spacer that reserves a delete control's slot (#254). */ +function placeholderSlot(): HTMLSpanElement { + const slot = document.createElement("span"); + slot.className = "slot-icon"; + slot.setAttribute("aria-hidden", "true"); + return slot; +} + /** §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"); @@ -3605,6 +3613,15 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { disclose.focus(); }); + // the whole line expands the season — the chevron stays the accessible + // control and this is a second way in; clicks on real controls opt out + line.addEventListener("click", (event) => { + if (event.target instanceof Element && event.target.closest("button")) { + return; + } + disclose.click(); + }); + // season 0 never counts toward tracking or totals (§4.1, §4.2); say so const name = document.createElement("span"); name.className = "season-name"; @@ -3697,8 +3714,9 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { line.append(space, deckBtn); // #174: files go, episodes stop being wanted, the season stays listed. // Only offered with files on disk — intent alone is the tracked toggle. + let clear: HTMLButtonElement | null = null; if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) { - const clear = armedDeleteIcon( + 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; @@ -3707,7 +3725,9 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { } void removeSeasonFiles(currentId, season.number).then((outcome) => { if (outcome.kind === "error") { - clear.disabled = false; + if (clear) { + clear.disabled = false; + } setStatus(`remove failed — ${outcome.detail}`, "fault"); return; } @@ -3718,8 +3738,9 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { }); }, ); - line.append(clear); } + // rows without a delete still reserve its slot, so search keeps its column + line.append(space, deckBtn, clear ?? placeholderSlot()); item.append(line); const episodes = document.createElement("ul"); @@ -3804,7 +3825,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { const note = document.createElement("span"); note.className = "readout dim ep-unaired"; note.textContent = "unaired"; - actions.append(note); + actions.append(note, placeholderSlot()); } else { const want = document.createElement("button"); want.type = "button"; @@ -3851,6 +3872,9 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { }); }); actions.append(deckBtn); + // no delete on a wantable episode, but its slot is still reserved so + // search sits in the same column as on an on-disk row + actions.append(placeholderSlot()); } item.append(tag, name, air, chips, actions); diff --git a/web/src/style.css b/web/src/style.css index 2fd2393..27f27fb 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1152,6 +1152,18 @@ body { border-color: var(--signal-warn); } +/* a destructive control rests red, so delete never reads like the search + glyph beside it; arming hands over to the shared amber treatment below */ +.control[data-kind="delete"]:not(:disabled) { + color: var(--signal-fault); + border-color: oklch(from var(--signal-fault) l c h / 40%); +} + +.control[data-kind="delete"]:hover:not(:disabled), +.control[data-kind="delete"]:focus-visible:not(:disabled) { + border-color: var(--signal-fault); +} + /* the settings arm-then-confirm, shared with season and episode rows: an armed control turns caution amber until it disarms or fires */ .control[data-armed="true"] { @@ -1510,6 +1522,15 @@ body { flex-wrap: wrap; gap: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-1); + cursor: pointer; + transition: background 150ms var(--ease-out); +} + +/* a row under the pointer is visibly the row the click will land on — + the same lift a search result row gets */ +.season-line:hover, +.episode:hover { + background: var(--panel); } /* the disclosure is a drawn chevron — no glyph standing in for an icon */ @@ -1578,6 +1599,7 @@ body { gap: var(--space-1) var(--space-2); padding: var(--space-2) var(--space-1); border-bottom: 1px solid oklch(from var(--line) l c h / 45%); + transition: background 150ms var(--ease-out); } .ep-tag { @@ -1613,6 +1635,14 @@ body { font-size: var(--text-xs); } +/* an invisible spacer sized as an icon control: rows without a delete + reserve its slot so search sits in the same column on every row */ +.slot-icon { + flex: none; + width: var(--control-h); + height: var(--control-h); +} + /* #227: the season the operator's own report was about read `0/10` beside this chip's absence. It sits next to the counts because that number is what looked like nothing had ever been tried. */