Confirmations pin to the viewport; open ballots mark their rows

Twice the feedback was the bug: the success banner lives at the top of
the page and the user acts at the bottom of a 136-row list — so a
working "pick edition" read as broken (it worked both times; the data
proves it). showToast() pins transient confirmations to the viewport
bottom (role=status, carries buttons, auto-dismisses), and pick-
edition/edit-saved use it. Better: state stops being transient at all —
any row whose edition ballot is open shows a persistent "ballot in
Review" link where its button was, so the answer to "where did it go?"
lives on the row forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-05 21:25:49 -04:00
co-authored by Claude Fable 5
parent edb72514f4
commit c94e8bc695
4 changed files with 46 additions and 11 deletions
+15
View File
@@ -473,6 +473,21 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: #
border: var(--line); border-radius: var(--radius); background: #fff;
}
/* -- toast: viewport-pinned transient confirmations --------------------- */
#toast {
position: fixed; bottom: 1.1rem; left: 50%; transform: translateX(-50%);
z-index: 20; max-width: min(92vw, 34rem);
background: var(--navy); color: #fff;
border: var(--line); border-radius: var(--radius-lg);
box-shadow: var(--shadow-raised);
padding: .7rem 1rem; font-size: .9rem; line-height: 1.45;
}
#toast a { color: var(--gold); }
#toast button {
font-size: .8rem; padding: .25rem .7rem; margin-left: .5rem;
box-shadow: none;
}
/* -- responsive -------------------------------------------------------- */
/* The rail collapses to a top bar: brand + hamburger; the nav drops
+18
View File
@@ -13,6 +13,24 @@ function showBanner(html) {
document.getElementById("banner").innerHTML = html;
}
/* Transient confirmation pinned to the viewport: the #banner region sits
* at the top of the page, which a user deep in a long list never sees.
* The toast may carry buttons — wire them before the timer clears it. */
function showToast(html, ms = 8000) {
let el = document.getElementById("toast");
if (!el) {
el = document.createElement("div");
el.id = "toast";
el.setAttribute("role", "status");
document.body.appendChild(el);
}
el.innerHTML = html;
el.hidden = false;
clearTimeout(showToast._timer);
showToast._timer = setTimeout(() => { el.hidden = true; }, ms);
return el;
}
function errorBanner(detail) {
// an HTTP status means the server answered — the problem is its data
const httpish = /^\d{3}$/.test(String(detail));
+11 -9
View File
@@ -89,6 +89,10 @@ function render() {
title="one line, several boxes? make each photo its own copy">
split into copies</button>`
: ""}
${c.version_status === "version_ambiguous"
? `<a class="linkbtn ballot" href="/review#editions"
title="this copy's edition ballot is open">ballot in Review</a>`
: ""}
${c.bgg_id && ["auto", "approved"].includes(c.status)
&& ["version_unknown", "version_error", ""].includes(c.version_status || "")
? `<button class="pickedition" data-title="${esc(c.title_raw)}"
@@ -139,9 +143,8 @@ document.getElementById("catbody").addEventListener("click", async e => {
row_ix: pe.dataset.rowix === "" ? null : Number(pe.dataset.rowix),
});
if (res) {
showBanner(`<div class="banner">edition ballot ready for
<b>${esc(pe.dataset.title)}</b> —
<a href="/review#editions">pick it in Review</a></div>`);
showToast(`edition ballot ready for <b>${esc(pe.dataset.title)}</b> —
<a href="/review#editions">pick it in Review</a>`);
GATE.reset(); refresh().catch(() => {});
}
return;
@@ -214,14 +217,13 @@ document.getElementById("catbody").addEventListener("submit", async e => {
EDITING = null; GATE.reset(); refresh().catch(() => {});
// an edit re-queues any matched row: without a resolve run the line
// sits "awaiting resolve" — say so, and offer the run right here
showBanner(`<div class="banner">saved — a previously matched title
re-matches on the next resolve run.
<button id="resolvenow">run resolve now</button></div>`);
const btn = document.getElementById("resolvenow");
const toast = showToast(`saved — a previously matched title re-matches
on the next resolve run. <button id="resolvenow">run resolve now</button>`);
const btn = toast.querySelector("#resolvenow");
if (btn) btn.addEventListener("click", async () => {
const r = await apiPost("/api/run/resolve");
if (r) showBanner(`<div class="banner">resolve running — progress on
the <a href="/">Pipeline</a> page; this list updates itself</div>`);
if (r) showToast(`resolve running — progress on the
<a href="/">Pipeline</a> page; this list updates itself`);
});
}
});