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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user