Fix the accessibility findings from the audit

Contrast: --mute-2 and --trash both failed AA in both themes — every
10px mono label (recent chapter, section headings, chapter hint) sat
between 2.5:1 and 3.7:1, and the trash icon, the one irreversible
control, was the least visible thing in the strip. Retuned per theme:
labels now 4.8-5.2:1, trash 4.3-4.5:1 against a 3:1 requirement.

Focus: the search input clears its own outline and the underline lives
on the parent, so :focus-within on .searchbar carries the ring.

The inline error slot gets role=status, so a failed write is announced
rather than looking like an ignored tap.

The tab strip drops role=tablist/tab: these are real links that change
the URL, and the roles promised a tabpanel contract that was never
implemented, leaving aria-selected unset on every one. aria-current
carries the state instead.

prefers-reduced-motion now names pseudo-elements — `*` does not match
them, so the busy bar and the error dot, both ::before, kept animating
for the users who asked them not to.

Log out was a 54x15 target; an ::after pad makes it 78x45 without
moving the label.

Also: the two dark-tuned hex borders in the desktop block become
theme-aware tokens, and the preload swaps mono for DM Sans, the larger
face and the one the body text needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:43:34 +07:00
parent 8876e5e364
commit f081780d8d
4 changed files with 54 additions and 21 deletions
+5 -2
View File
@@ -20,8 +20,11 @@
})();
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");
});
}
+29 -11
View File
@@ -64,7 +64,9 @@
--paper-hot: #f0d3cb; /* title of a series with a new chapter */
--paper-dim: #ddd5cb; /* resting title */
--mute: #8d857c; /* secondary text, idle icons */
--mute-2: #5a5450; /* labels, hints */
/* Every mute-2 use is 10px mono, so it has to clear 4.5:1 on --ink even
though it reads as the quietest step. */
--mute-2: #877f76; /* labels, hints */
--faint: #3a3733; /* meta separators */
--faint-2: #57504b; /* cover monogram */
@@ -73,7 +75,11 @@
--ember-ink: #150907; /* text on solid ember */
--ember-soft: #eda798; /* text on ember wash */
--brass: #b8912f; /* favourite — a second, cooler metal */
--trash: #6b5450; /* remove, resting */
--trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */
/* Desktop cell borders for the two coloured action states. */
--play-hot-line: #3a1d18;
--fav-line: #332b14;
--asura: #7d93a5;
--demonic: #a98a78;
@@ -106,7 +112,7 @@
--paper-hot: #a33018;
--paper-dim: #191715;
--mute: #6b645d;
--mute-2: #857d75;
--mute-2: #6c655e; /* clears 4.5:1 on --ash too, not just --ink */
--faint: #c9c2ba;
--faint-2: #a8a098;
@@ -115,7 +121,10 @@
--ember-ink: #fff;
--ember-soft: #8d2c17;
--brass: #8a681c;
--trash: #a98276;
--trash: #8c6558;
--play-hot-line: #f0cfc6;
--fav-line: #e3d3a4;
--asura: #4f6b80;
--demonic: #8a6a55;
@@ -180,6 +189,7 @@ button { cursor: pointer; }
.topbar form { margin: 0; }
.ghost {
position: relative;
padding: 0;
border: none;
border-bottom: 1px solid var(--field-line);
@@ -190,6 +200,8 @@ button { cursor: pointer; }
text-transform: uppercase;
}
.ghost:hover { color: var(--paper); border-bottom-color: var(--paper); }
/* The label is 15px tall by design; the thumb gets 44 without moving it. */
.ghost::after { content: ""; position: absolute; inset: -15px -12px; }
.chrome { display: flex; flex-direction: column; }
@@ -202,6 +214,9 @@ button { cursor: pointer; }
border-bottom: 1px solid var(--rule);
color: var(--mute-2);
}
/* The input drops its own outline, so the bar it sits in carries the focus
ring — same move the two other inputs make with their border. */
.searchbar:focus-within { border-bottom-color: var(--paper); color: var(--paper); }
.searchbar svg { width: 15px; height: 15px; flex: none; }
.search {
flex: 1;
@@ -226,7 +241,7 @@ button { cursor: pointer; }
border-bottom: 1px solid var(--rule);
}
.tabs::-webkit-scrollbar { display: none; }
.tabs [role="tab"] {
.tabs a {
flex: none;
display: flex;
align-items: center;
@@ -236,8 +251,8 @@ button { cursor: pointer; }
font: 400 17px var(--font-display);
white-space: nowrap;
}
.tabs [role="tab"]:hover { color: var(--paper-dim); }
.tabs [role="tab"].active {
.tabs a:hover { color: var(--paper-dim); }
.tabs a.active {
color: var(--paper);
border-bottom: 2px solid var(--paper);
margin-bottom: -1px;
@@ -661,7 +676,7 @@ button { cursor: pointer; }
border-bottom: 1px solid var(--rule);
}
.tabs { order: 1; flex: none; gap: 20px; padding: 0; border-bottom: none; }
.tabs [role="tab"] { padding: 10px 0 14px; font-size: 18px; }
.tabs a { padding: 10px 0 14px; font-size: 18px; }
.searchbar { order: 2; flex: 1; margin: 0; border-bottom: none; }
.recent h2, .recent-strip { padding-left: 32px; padding-right: 32px; }
.recent-card, .recent-cover { width: 100px; }
@@ -683,8 +698,8 @@ button { cursor: pointer; }
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; }
.is-new .actions .play { border-color: var(--play-hot-line); }
.actions .on { border-color: var(--fav-line); }
/* Panels line up with the body text, i.e. past the cover and its gap. */
.chapter-form, .confirm-row, .error-inline {
@@ -693,5 +708,8 @@ button { cursor: pointer; }
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
/* Pseudo-elements need naming explicitly — `*` does not match them, and the
busy bar and error dot are both ::before. Their static form still reads:
the bar stays drawn and .actions stays dimmed. */
*, *::before, *::after { animation: none !important; transition: none !important; }
}
+17 -7
View File
@@ -8,7 +8,9 @@
<title>mangaBookmark</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/static/fonts/ibm-plex-mono-500-latin.woff2" as="font" type="font/woff2" crossorigin>
{{/* Body text before meta lines: DM Sans is the biggest face and the one
most of the page is set in; the mono is small and arrives from CSS. */}}
<link rel="preload" href="/static/fonts/dm-sans-var-latin.woff2" as="font" type="font/woff2" crossorigin>
<script src="/static/htmx.min.js" defer></script>
<script src="/static/filter.js" defer></script>
</head>
@@ -31,21 +33,29 @@
autocomplete="off" aria-label="Search titles">
</div>
<nav class="tabs" role="tablist">
<a role="tab" href="/?tab=all" class="{{if eq .Tab "all"}}active{{end}}"
{{/* These are real links with real hrefs that change the URL, so they are
navigation, not an ARIA tablist — aria-current carries "which bucket am
I in" without owing a tabpanel contract we do not implement. */}}
<nav class="tabs" aria-label="Bookmark buckets">
<a href="/?tab=all" class="{{if eq .Tab "all"}}active{{end}}"
{{if eq .Tab "all"}}aria-current="page"{{end}}
hx-get="/ui/list?tab=all" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=all" hx-on::after-request="setActiveTab(this)">All</a>
<a role="tab" href="/?tab=new" class="tab-new {{if eq .Tab "new"}}active{{end}}"
<a href="/?tab=new" class="tab-new {{if eq .Tab "new"}}active{{end}}"
{{if eq .Tab "new"}}aria-current="page"{{end}}
hx-get="/ui/list?tab=new" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=new" hx-on::after-request="setActiveTab(this)">Updated{{if .NewCount}}
<span class="count">{{.NewCount}}</span>{{end}}</a>
<a role="tab" href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}"
<a href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}"
{{if eq .Tab "fav"}}aria-current="page"{{end}}
hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a>
<a role="tab" href="/?tab=archived" class="{{if eq .Tab "archived"}}active{{end}}"
<a href="/?tab=archived" class="{{if eq .Tab "archived"}}active{{end}}"
{{if eq .Tab "archived"}}aria-current="page"{{end}}
hx-get="/ui/list?tab=archived" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=archived" hx-on::after-request="setActiveTab(this)">Archived</a>
<a role="tab" href="/?tab=finished" class="{{if eq .Tab "finished"}}active{{end}}"
<a href="/?tab=finished" class="{{if eq .Tab "finished"}}active{{end}}"
{{if eq .Tab "finished"}}aria-current="page"{{end}}
hx-get="/ui/list?tab=finished" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=finished" hx-on::after-request="setActiveTab(this)">Finished</a>
</nav>
+3 -1
View File
@@ -131,6 +131,8 @@
<button type="button" onclick="toggleConfirmRow('{{.Key}}', 'remove')">Cancel</button>
</div>
</div>
<p class="error-inline" hidden></p>
{{/* role=status announces a failed write; without it the tap just looks
ignored to a screen reader. */}}
<p class="error-inline" role="status" hidden></p>
</article>
{{end}}