Confirm-gate lifecycle actions, cluster the action strip

Fixes the 2026-07-30 impeccable critique of the web UI.

Archive and finish now open their own .confirm-row disclosure, the
pattern delete already used, so no single tap moves a series out of the
list. Restore stays instant — it is the reversal. The two reversible
confirms wear grey; remove keeps the ember wash.

The six action cells cluster by consequence: navigate (play) | organize
(favourite, chapter) | lifecycle (archive/restore, finish, remove). The
lifecycle cells sit on a recessed ground, separated by a hairline on a
phone and by a gap on desktop, so the 4th-slot icon swapping between
.box and .restore no longer breaks position memory.

htmx:responseError branches on status: 401 offers a login link, 400
surfaces the server's own reason, everything else keeps the retry copy.

Busy bar and inline error move to --mute, leaving --ember exclusive to
the new-chapter signal.

Also: aria-live on the confirm rows, max on the chapter input, cover
width and row gap as tokens instead of a hardcoded 94px indent, and
mobile covers 30% larger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:28:31 +07:00
parent ac3ee9b298
commit 8876e5e364
4 changed files with 172 additions and 64 deletions
+15
View File
@@ -38,6 +38,21 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache)
the web routes are not registered at all. UI mutations read-modify-write
through `Store.Get` + `Store.Upsert` so the `updated_at` rule stays in one
place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`.
**Design-tool caveat:** the templates link `/static/style.css` root-absolutely
(correct — they are served from `/`), but the impeccable detector resolves a
stylesheet href with `path.resolve(fileDir, href)`, which drops the directory
on a leading `/` and silently skips the file. A relative href does not help
either: the template's directory is not its served path. So
`detect.mjs backend/templates` reports a **false clean** — always pass
`backend/static` too. Its one finding there, `overused-font` on "Instrument
Serif", is a deliberate identity choice, not debt.
- **Every action that moves a series out of the list is confirm-gated.**
Archive, finish, and remove each open their own `.confirm-row` disclosure
(`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈
`archive|finish|remove`); restore fires instantly because it is the reversal.
Remove's row wears the ember wash, the two reversible ones wear `.calm` grey.
`--ember` stays reserved for the new-chapter signal: busy bar and inline
error use `--mute`.
- **Latest-chapter poller:** a ticker goroutine in the same binary re-checks
each bookmarked series' newest published chapter from the backend's own
network access, so `latest_chapter` stays fresh when the user is not
+51 -20
View File
@@ -25,61 +25,92 @@ function setActiveTab(el) {
});
}
// The chapter-edit form and the delete confirm row are the two per-card
// disclosure panels; only one makes sense open at a time. The button that
// owns an open panel carries .open, which is how the strip shows which cell
// the panel belongs to.
// The chapter-edit form and the archive/finish/remove confirm rows are the
// per-card disclosure panels; only one makes sense open at a time. The button
// that owns an open panel carries .open, which is how the strip shows which
// cell the panel belongs to.
function closeCardPanels(key) {
var form = document.getElementById("chapter-form-" + key);
var confirm = document.getElementById("confirm-row-" + key);
if (form) form.hidden = true;
if (confirm) confirm.hidden = true;
var card = document.getElementById("card-" + key);
if (card) {
card.querySelectorAll(".actions .open").forEach(function (b) {
b.classList.remove("open");
});
}
if (!card) return;
card.querySelectorAll(".chapter-form, .confirm-row").forEach(function (p) {
p.hidden = true;
});
card.querySelectorAll(".actions .open").forEach(function (b) {
b.classList.remove("open");
b.setAttribute("aria-expanded", "false");
});
}
function togglePanel(key, panelId, buttonSelector) {
var panel = document.getElementById(panelId + key);
var panel = document.getElementById(panelId);
if (!panel) return null;
var opening = panel.hidden;
closeCardPanels(key);
panel.hidden = !opening;
var card = document.getElementById("card-" + key);
var button = card && card.querySelector(buttonSelector);
if (button) button.classList.toggle("open", opening);
if (button) {
button.classList.toggle("open", opening);
button.setAttribute("aria-expanded", opening ? "true" : "false");
}
return panel;
}
function toggleChapterForm(key) {
var form = togglePanel(key, "chapter-form-", ".actions .pencil");
var form = togglePanel(key, "chapter-form-" + key, ".actions .pencil");
if (form && !form.hidden) form.querySelector("input").focus();
}
function toggleConfirmRow(key) {
togglePanel(key, "confirm-row-", ".actions .remove");
// kind is "archive" | "finish" | "remove" — the panel id and the owning action
// cell share it.
function toggleConfirmRow(key, kind) {
var cls = { archive: ".box", finish: ".finish", remove: ".remove" }[kind];
togglePanel(key, "confirm-" + kind + "-" + key, ".actions " + cls);
}
// A failed favourite/chapter/delete request leaves the card in place (htmx
// does not swap on a non-2xx response) but otherwise gives no sign anything
// went wrong. Surface it inline instead of leaving the tap looking ignored.
(function () {
function showError(elt, message) {
function showError(elt, message, linkHref, linkText) {
var card = elt.closest(".card");
var slot = card && card.querySelector(".error-inline");
if (!slot) return;
slot.textContent = message;
if (linkHref) {
var a = document.createElement("a");
a.href = linkHref;
a.textContent = linkText;
a.className = "error-link";
slot.append(" ", a);
}
slot.hidden = false;
clearTimeout(slot._hideTimer);
slot._hideTimer = setTimeout(function () {
slot.hidden = true;
}, 5000);
}, linkHref ? 30000 : 5000);
}
// The handlers answer a bad value with http.Error, i.e. a short plain-text
// line — worth showing verbatim. Anything long or HTML-ish is an error page,
// not a reason, so fall back to the generic copy.
function reasonFrom(xhr) {
var body = (xhr.responseText || "").trim();
if (!body || body.length > 120 || body.indexOf("<") === 0) return "";
return body.charAt(0).toUpperCase() + body.slice(1);
}
document.body.addEventListener("htmx:responseError", function (e) {
var xhr = e.detail.xhr;
if (xhr.status === 401) {
showError(e.detail.elt, "Session expired — nothing was saved.", "/login", "Log in again");
return;
}
if (xhr.status === 400) {
var reason = reasonFrom(xhr);
showError(e.detail.elt, reason ? reason + "." : "That value wasn't accepted — check it and try again.");
return;
}
showError(e.detail.elt, "Couldn't save — try again.");
});
document.body.addEventListener("htmx:sendError", function (e) {
+55 -15
View File
@@ -84,6 +84,10 @@
--hatch-dim: repeating-linear-gradient(135deg, #1b1918 0 5px, #151313 5px 10px);
--measure: 760px;
/* Cover width + row gap: the disclosure panels indent past the cover on
desktop, so both live here rather than as magic numbers. */
--cover-w: 86px;
--row-gap: 14px;
}
/* Light mode: same rules, cooler paper. Hues are re-tuned, not reused — the
@@ -142,7 +146,9 @@ button { cursor: pointer; }
@keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(320%) } }
@keyframes sheetIn { from { opacity: 0; transform: translateY(-4px) } to { opacity: 1; transform: none } }
@keyframes emberPulse { 0%, 100% { opacity: .5 } 50% { opacity: 1 } }
/* Transient system states pulse in grey: ember stays reserved for "new
chapter" so a tired glance never misreads a busy bar as unread heat. */
@keyframes mutePulse { 0%, 100% { opacity: .35 } 50% { opacity: 1 } }
/* One measured column, edges drawn rather than boxed. */
.sheet {
@@ -335,12 +341,12 @@ button { cursor: pointer; }
}
.card.is-dim { background: var(--dim); }
.row { display: flex; flex-wrap: wrap; gap: 14px; }
.row { display: flex; flex-wrap: wrap; gap: var(--row-gap); }
.cover {
position: relative;
width: 66px;
height: 88px;
width: var(--cover-w);
height: calc(var(--cover-w) * 4 / 3);
flex: none;
background: var(--hatch);
display: grid;
@@ -348,7 +354,7 @@ button { cursor: pointer; }
overflow: hidden;
}
.cover img { width: 100%; height: 100%; object-fit: cover; }
.cover .monogram { font-size: 24px; }
.cover .monogram { font-size: 30px; }
.is-dim .cover { background: var(--hatch-dim); filter: grayscale(1); opacity: .85; }
.body {
@@ -428,9 +434,20 @@ button { cursor: pointer; }
.actions .remove { color: var(--trash); }
.actions .remove:hover { color: var(--ember); }
.actions .open { background: var(--hover); color: var(--paper); }
/* Lifecycle cells already sit on --ash, which is what --hover resolves to, so
an open one needs the next step up to stay legible as the panel's owner. */
.actions .lifecycle.open { background: var(--rule); }
.actions .remove.open { background: var(--ember-wash); color: var(--ember); }
.is-dim .actions > * { color: var(--mute-2); }
/* Three clusters by consequence: navigate (play) | organize (favourite,
chapter) | lifecycle (archive/restore, finish, remove). The lifecycle cells
sit on a recessed ground so the thumb reads "this one moves the series"
before it reads which icon it landed on. */
.actions > .lifecycle { background: var(--ash); }
.actions > .play + *,
.actions > *:not(.lifecycle) + .lifecycle { box-shadow: inset 1px 0 0 var(--field-line); }
/* ---- disclosure panels ---- */
.chapter-form, .confirm-row, .error-inline { animation: sheetIn .18s ease-out; }
@@ -498,6 +515,17 @@ button { cursor: pointer; }
color: var(--ember-ink);
font-weight: 600;
}
/* Archive and finish are reversible, so their confirm asks in grey — only the
irreversible remove gets the ember wash. */
.confirm-row.calm { background: var(--ash); }
.confirm-row.calm span { color: var(--paper-dim); }
.confirm-row .go {
border: none;
background: var(--paper);
color: var(--ink);
font-weight: 600;
}
.confirm-row .go:hover { color: var(--ink); }
.error-inline {
display: flex;
@@ -505,10 +533,10 @@ button { cursor: pointer; }
gap: 9px;
margin: 0;
padding: 11px 13px;
background: var(--ember-wash);
border-left: 2px solid var(--ember);
background: var(--ash);
border-left: 2px solid var(--mute);
font: 400 16px var(--font-display);
color: var(--ember-soft);
color: var(--paper-dim);
}
.error-inline::before {
content: "";
@@ -516,11 +544,16 @@ button { cursor: pointer; }
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--ember);
animation: emberPulse 1.4s ease-in-out infinite;
background: var(--mute);
animation: mutePulse 1.4s ease-in-out infinite;
}
.error-inline .error-link {
border-bottom: 1px solid var(--field-line);
color: var(--paper);
}
/* Busy: the hairline at the top of the sheet burns across it. */
/* Busy: a grey hairline slides across the top of the sheet — deliberately not
ember, which only ever means "new chapter". */
.card.htmx-request { overflow: hidden; }
.card.htmx-request .actions { pointer-events: none; opacity: .5; }
.card.htmx-request::before {
@@ -530,7 +563,7 @@ button { cursor: pointer; }
left: 0;
width: 30%;
height: 1px;
background: var(--ember);
background: var(--mute);
animation: barSlide 1.15s linear infinite;
}
@@ -616,6 +649,7 @@ button { cursor: pointer; }
/* ---- desktop: same measure, actions fold up beside the row ---- */
@media (min-width: 720px) {
:root { --cover-w: 74px; --row-gap: 20px; }
.topbar { padding: 26px 32px 18px; }
.brand { font-size: 30px; }
@@ -634,8 +668,7 @@ button { cursor: pointer; }
.recent-cover { height: 133px; }
.card { padding: 18px 32px; }
.row { flex-wrap: nowrap; align-items: center; gap: 20px; }
.cover { width: 74px; height: 100px; }
.row { flex-wrap: nowrap; align-items: center; }
.cover .monogram { font-size: 26px; }
.title { font-size: 21px; }
@@ -646,10 +679,17 @@ button { cursor: pointer; }
height: 40px;
border: 1px solid var(--rule);
}
/* Cells are already gapped here, so the clusters separate by space rather
than by the hairline the phone layout uses. */
.actions > .play + *,
.actions > *:not(.lifecycle) + .lifecycle { margin-left: 10px; box-shadow: none; }
.is-new .actions .play { border-color: #3a1d18; }
.actions .on { border-color: #332b14; }
.chapter-form, .confirm-row, .error-inline { margin-left: 94px; }
/* Panels line up with the body text, i.e. past the cover and its gap. */
.chapter-form, .confirm-row, .error-inline {
margin-left: calc(var(--cover-w) + var(--row-gap));
}
}
@media (prefers-reduced-motion: reduce) {
+51 -29
View File
@@ -40,7 +40,7 @@
title="Continue reading" aria-label="Continue reading">
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg>
</a>
<button class="{{if .Favorite}}on{{end}}"
<button class="fav{{if .Favorite}} on{{end}}"
title="{{if .Favorite}}Remove from favourites{{else}}Add to favourites{{end}}"
aria-label="Toggle favourite"
hx-post="/ui/bookmarks/{{.Key}}/favorite"
@@ -52,38 +52,32 @@
onclick="toggleChapterForm('{{.Key}}')">
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg>
</button>
{{if eq .Status "finished"}}
<button class="restore" title="Restore to reading" aria-label="Restore to reading"
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"reading"}'
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
</button>
{{else}}
{{if eq .Status "archived"}}
<button class="restore" title="Restore to reading" aria-label="Restore to reading"
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"reading"}'
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
</button>
{{else}}
<button title="Archive" aria-label="Archive"
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"archived"}'
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
{{/* Restore is a reversal, so it fires straight away; every move *out* of
the list (archive, finish, remove) goes through a confirm row. */}}
{{if eq .Status "reading"}}
<button class="lifecycle box" title="Archive" aria-label="Archive"
aria-expanded="false" aria-controls="confirm-archive-{{.Key}}"
onclick="toggleConfirmRow('{{.Key}}', 'archive')">
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg>
</button>
{{end}}
<button title="Mark finished" aria-label="Mark finished"
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"finished"}'
{{else}}
<button class="lifecycle restore" title="Restore to reading" aria-label="Restore to reading"
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"reading"}'
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
</button>
{{end}}
{{if ne .Status "finished"}}
<button class="lifecycle finish" title="Mark finished" aria-label="Mark finished"
aria-expanded="false" aria-controls="confirm-finish-{{.Key}}"
onclick="toggleConfirmRow('{{.Key}}', 'finish')">
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg>
</button>
{{end}}
<button class="remove" title="Remove" aria-label="Remove"
onclick="toggleConfirmRow('{{.Key}}')">
<button class="lifecycle remove" title="Remove" aria-label="Remove"
aria-expanded="false" aria-controls="confirm-remove-{{.Key}}"
onclick="toggleConfirmRow('{{.Key}}', 'remove')">
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg>
</button>
</div>
@@ -94,19 +88,47 @@
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="input, button">
{{if .LatestChapter}}<p class="hint">Latest known: Ch {{.LatestChapter}}</p>{{end}}
<div class="field">
<input name="chapter" type="number" step="0.1" min="0"
{{/* max is a fat-finger guard, not a real ceiling — no series is near it. */}}
<input name="chapter" type="number" step="0.1" min="0" max="9999"
value="{{.LastChapterNum}}" aria-label="Chapter number" required>
<button type="submit">Save</button>
</div>
</form>
<div class="confirm-row" id="confirm-row-{{.Key}}" hidden>
{{/* One confirm row per way a series leaves the list. aria-live announces the
step to a screen reader, which otherwise gets no word that the tap
opened a second question. */}}
{{if eq .Status "reading"}}
<div class="confirm-row calm" id="confirm-archive-{{.Key}}" role="group" aria-live="polite" hidden>
<span>Archive this?</span>
<div>
<button class="go"
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"archived"}'
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Archive</button>
<button type="button" onclick="toggleConfirmRow('{{.Key}}', 'archive')">Cancel</button>
</div>
</div>
{{end}}
{{if ne .Status "finished"}}
<div class="confirm-row calm" id="confirm-finish-{{.Key}}" role="group" aria-live="polite" hidden>
<span>Mark finished?</span>
<div>
<button class="go"
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"finished"}'
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Finish</button>
<button type="button" onclick="toggleConfirmRow('{{.Key}}', 'finish')">Cancel</button>
</div>
</div>
{{end}}
<div class="confirm-row" id="confirm-remove-{{.Key}}" role="group" aria-live="polite" hidden>
<span>Remove this?</span>
<div>
<button class="danger-solid"
hx-delete="/ui/bookmarks/{{.Key}}"
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Remove</button>
<button type="button" onclick="toggleConfirmRow('{{.Key}}')">Cancel</button>
<button type="button" onclick="toggleConfirmRow('{{.Key}}', 'remove')">Cancel</button>
</div>
</div>
<p class="error-inline" hidden></p>