Batch of accessibility and touch fixes from the audit

- Esc closes whichever panel a card has open and returns focus to the cell that
  owns it; opening a confirm row moves focus to its primary button, so the
  announcement is deterministic and tab order stays inside the confirm.
- The inline error scrolls itself into view and no longer self-destructs after
  5s: this reader gets interrupted mid-tap, and an expiring notice left a failed
  write with no trace at all. It clears on the next request from that card.
- Every tab clears 44px wide (All was 15.25); desktop action cells go 40 -> 44.
- login.html's error gets role="alert".
- The card monogram carries its own aria-hidden — Chromium exposes it through
  the cover link's, because that link is programmatically focusable.
- The busy bar ends its travel flush with the card edge, so .card.htmx-request
  no longer needs overflow: hidden and cannot clip a panel mid-request.

Verified at 390 and 1280 in both themes; detector over templates+static reports
only the two known Instrument Serif false positives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 19:14:59 +07:00
parent c560a61a9a
commit c9487e5b31
4 changed files with 48 additions and 11 deletions
+33 -5
View File
@@ -101,9 +101,26 @@ function toggleChapterForm(key) {
// cell share it.
function toggleConfirmRow(key, kind) {
var cls = { archive: ".box", finish: ".finish", remove: ".remove" }[kind];
togglePanel(key, "confirm-" + kind + "-" + key, ".actions " + cls);
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.
if (row && !row.hidden) row.querySelector("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.
@@ -121,12 +138,23 @@ function toggleConfirmRow(key, kind) {
slot.append(" ", a);
}
slot.hidden = false;
clearTimeout(slot._hideTimer);
slot._hideTimer = setTimeout(function () {
slot.hidden = true;
}, linkHref ? 30000 : 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.
+8 -4
View File
@@ -163,7 +163,9 @@ button { cursor: pointer; }
/* Every hideable thing here is a flex container, and display beats hidden. */
[hidden] { display: none !important; }
@keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(320%) } }
/* Ends flush with the right edge of the card (30% wide × 233% travel), so the
card itself never needs overflow: hidden to contain it. */
@keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(233%) } }
@keyframes sheetIn { from { opacity: 0; transform: translateY(-4px) } to { opacity: 1; transform: none } }
/* The law: ember means "new chapter", and nothing else on a list screen —
busy, error and destruction all stay off it, so a tired glance never
@@ -258,7 +260,10 @@ button { cursor: pointer; }
flex: none;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
/* "All" is two characters in a 17px serif — 15px of target without this. */
min-width: 44px;
padding: 8px 0 12px;
color: var(--mute);
font: 400 17px var(--font-display);
@@ -587,7 +592,6 @@ button { cursor: pointer; }
/* Busy: a grey hairline slides across the top of the sheet — deliberately not
ember, which on a list screen only ever means "new chapter". */
.card.htmx-request { overflow: hidden; }
.card.htmx-request .actions { pointer-events: none; opacity: .5; }
.card.htmx-request::before {
content: "";
@@ -718,8 +722,8 @@ button { cursor: pointer; }
.actions { flex: none; gap: 4px; border-top: none; }
.actions > * {
flex: none;
width: 40px;
height: 40px;
width: 44px;
height: 44px;
border: 1px solid var(--rule);
}
/* Cells are already gapped here, so the clusters separate by space rather
+4 -1
View File
@@ -7,7 +7,10 @@
<a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"
tabindex="-1" aria-hidden="true">
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
{{else}}<span class="monogram">{{.Initial}}</span>{{end}}
{{/* aria-hidden on the cover link is not enough — Chromium still exposes
the letter because the link is programmatically focusable — so the
monogram carries its own, same as the recent strip's. */}}
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
{{if and (eq .Status "reading") .HasNewChapter}}<span class="foot-rule"></span>
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
</a>
+3 -1
View File
@@ -21,7 +21,9 @@
<input id="password" name="password" type="password"
autocomplete="current-password" autofocus required>
</div>
<p class="error">{{.Error}}</p>
{{/* The page reloads on a failed sign-in, so the message is present from
the start; role=alert is what gets it announced anyway. */}}
<p class="error" role="alert">{{.Error}}</p>
<button type="submit">Sign in</button>
</form>
</main>