Work the design critique down: chapter format, colour law, search, strip, a11y (#11)

Two rounds of design-critique fixes on the web UI. Every visual change was verified at 390x844 and 1280x900 in both dark and light with screenshots; `go test ./...` is green throughout; no new dependencies.

## Earlier commits on this branch

The two oldest commits predate this session and were never opened as their own PR, so they are under review here too:

- Confirm-gate the lifecycle actions, cluster the action strip by consequence.
- Fix the accessibility findings from the audit: contrast, focus, reduced motion.

## The rest

**Chapter format.** The userscript and the poller both write `"Chapter N"`, and the templates prefixed `Ch ` again, so every real Asura row read `Ch Chapter 250` — while a manual edit stored a bare `250`, leaving two formats in one list. `DisplayChapter`/`DisplayLatest` on `Bookmark` now strip the lead-in and re-add exactly one `Ch `.

**Zero-result search.** The client filter only toggled `card.hidden`, so a query matching nothing left a blank list under a fully populated, unfiltered "Continue reading" strip. There is now a no-match state with a Clear-search button, and the strip goes down while a filter is active.

**The colour law.** `--ember` is documented as meaning "new chapter" and was spent on eight things, including setting "Nothing new." in the colour reserved for new chapters. Destruction moves to a new `--danger` token; text-input focus follows the searchbar idiom and turns `--paper`. Contrast, both themes: `--danger` on the page 4.82 / 6.65, the solid Remove button 4.94 / 7.30, the confirm question 9.00 / 7.98.

**The remove confirm.** Buttons 40px 8px apart became 46px 12px apart, and the question names the series and the loss instead of asking "Remove this?". It opens with **Cancel** focused, not Remove — the two reversible rows still open on their affirmative.

**The recent strip.** It was the head of the same `updated_at DESC` list rendered directly below it, on every tab, costing ~240px of the first phone screen. It is now scoped to series with a chapter waiting, and only on All. With nothing new anywhere it does not render — deliberate.

**Stale chrome.** The strip and the Updated badge describe the whole library but live outside the swapped `#list`, so archiving a series left it under "Continue reading" with the badge still counting it, and `/?tab=all` reached by htmx differed from the same URL reloaded. Both regions move into `chrome.html` and refresh out of band on every mutation and every tab switch.

**Accessibility and touch.** Esc closes any open panel and returns focus to the cell that owns it; opening a confirm moves focus into it; the inline error scrolls into view and no longer self-destructs after 5s; every tab and desktop action cell clears 44px; `role="alert"` on the login error; the card monogram is no longer announced; the busy bar is clipped by its own travel rather than by `overflow: hidden` on the card.

**Chapter form label.** The panel's only visible text named the published chapter while the field held your progress. The field gets a real label; "Latest known" moves below it.

**gzip.** Nothing was compressed. A stdlib middleware handles the four text types and leaves woff2 alone: style.css 21.8 -> 6.5 KB, htmx 50.9 -> 16.4, filter.js 7.6 -> 2.9.

## Not addressed

The `role="status"` error slot is still mutated while hidden and then revealed, which is the non-announcing pattern the confirm rows were fixed for. Delete is still a silent vanish. Both are flagged in the critique snapshot under `.impeccable/critique/`.

Design health went 24/36 (66.7%) to 29/40 (72.5%) between snapshots; the two P1s that survived were found and fixed after that run.

Reviewed-on: #11
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #11.
This commit is contained in:
2026-07-30 22:34:23 +07:00
committed by sulthan
parent ac3ee9b298
commit f3b55fd883
15 changed files with 787 additions and 173 deletions
+131 -31
View File
@@ -4,82 +4,182 @@
function applyFilter() {
var box = document.getElementById("search");
if (!box) return;
var needle = box.value.trim().toLowerCase();
document.querySelectorAll(".card").forEach(function (card) {
var query = box.value.trim();
var needle = query.toLowerCase();
var cards = document.querySelectorAll(".card");
var visible = 0;
cards.forEach(function (card) {
var title = (card.dataset.title || "").toLowerCase();
card.hidden = needle !== "" && title.indexOf(needle) === -1;
if (!card.hidden) visible++;
});
// The server decides what the strip holds — it ships empty for every tab
// but All, and out of band after every mutation. All this has to do is keep
// it down while a filter is active, since the strip is never filtered and
// would otherwise put non-matching covers above an empty list.
var recent = document.querySelector(".recent");
if (recent) {
recent.hidden = needle !== "" || !recent.querySelector(".recent-card");
}
// An empty bucket already explains itself server-side; this only speaks
// when the filter is what emptied the screen.
var none = document.getElementById("no-match");
if (none) {
none.hidden = !(needle !== "" && cards.length > 0 && visible === 0);
if (!none.hidden) none.querySelector(".no-match-q").textContent = query;
}
}
document.addEventListener("input", function (e) {
if (e.target && e.target.id === "search") applyFilter();
});
document.addEventListener("click", function (e) {
if (!e.target || !e.target.classList.contains("clear-search")) return;
var box = document.getElementById("search");
box.value = "";
applyFilter();
box.focus();
});
// htmx replaces the list on a tab switch, so re-apply to the new cards.
document.body.addEventListener("htmx:afterSwap", applyFilter);
document.addEventListener("mangabm:refilter", applyFilter);
})();
function setActiveTab(el) {
el.parentElement.querySelectorAll("[role=tab]").forEach(function (t) {
t.classList.toggle("active", t === el);
el.parentElement.querySelectorAll("a").forEach(function (t) {
var on = t === el;
t.classList.toggle("active", on);
if (on) t.setAttribute("aria-current", "page");
else t.removeAttribute("aria-current");
});
// The strip is outside the swapped region, so its visibility is re-decided
// here rather than by the server that just answered.
document.dispatchEvent(new Event("mangabm:refilter"));
}
// 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 card = document.getElementById("card-" + key);
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");
});
}
// 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.
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");
});
}
}
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];
var row = togglePanel(key, "confirm-" + kind + "-" + key, ".actions " + cls);
// Focus the answer rather than trusting aria-live on a container that merely
// unhides: it makes the announcement deterministic, keeps tab order inside
// the confirm instead of running on into the next card, and means the row
// cannot be opened and scrolled past unnoticed.
// The reversible rows open on their affirmative; remove opens on Cancel.
// Focusing the first button in DOM order would hand the irreversible action
// a pre-armed Enter, which is the opposite of what a confirm gate is for.
if (row && !row.hidden) {
row.querySelector(row.classList.contains("calm") ? "button" : "button + button").focus();
}
}
// Esc closes whichever panel this card has open and hands focus back to the
// cell that owns it — otherwise the only way out is finding that exact cell
// again.
document.addEventListener("keydown", function (e) {
if (e.key !== "Escape" || !e.target.closest) return;
var card = e.target.closest(".card");
var owner = card && card.querySelector(".actions .open");
if (!owner) return;
closeCardPanels(card.id.replace(/^card-/, ""));
owner.focus();
});
// 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);
// No self-destruct timer: this reader gets interrupted mid-tap, and a
// notice that expires after 5s leaves a failed write with no trace at all
// — the star is back off and nothing says why. The notice stays until the
// next request from this card clears it (or a successful one swaps the
// whole card away).
slot.scrollIntoView({
block: "nearest",
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
});
}
document.body.addEventListener("htmx:beforeRequest", function (e) {
var card = e.detail.elt.closest(".card");
var slot = card && card.querySelector(".error-inline");
if (slot) slot.hidden = true;
});
// 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) {