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
+20 -29
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,46 +33,35 @@
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}}"
hx-push-url="/?tab=new" hx-on::after-request="setActiveTab(this)">Updated
{{template "newcount" .}}</a>
<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>
</div>
{{if .Recent}}
<section class="recent">
<h2>Continue reading</h2>
<div class="recent-strip">
{{range .Recent}}
<a class="recent-card {{if .HasNewChapter}}is-new{{end}}" href="{{.ContinueURL}}"
target="_blank" rel="noopener noreferrer">
<span class="recent-cover">
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
{{if .HasNewChapter}}<span class="foot-rule"></span>
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
</span>
<span class="recent-title">{{.Title}}</span>
<span class="recent-chapter">Ch {{.LastChapter}}{{if .HasNewChapter}} · New{{end}}</span>
</a>
{{end}}
</div>
</section>
{{end}}
{{template "recent" .}}
<main id="list" class="list">
{{template "list" .}}
+67 -36
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>
@@ -21,10 +24,10 @@
<p class="meta">
<span class="site-{{.Site}}">{{.Site}}</span>
<span class="sep">/</span>
<span class="chapter">Ch {{.LastChapter}}</span>
<span class="chapter">{{.DisplayChapter}}</span>
{{if and (eq .Status "reading") .HasNewChapter}}
<span class="sep">/</span>
<span class="new-chapter">Ch {{.LatestChapter}} out</span>
<span class="new-chapter">{{.DisplayLatest}} out</span>
{{end}}
{{if eq .Status "archived"}}
<span class="sep">/</span>
@@ -40,7 +43,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 +55,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>
@@ -92,23 +89,57 @@
hx-post="/ui/bookmarks/{{.Key}}/chapter"
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="input, button">
{{if .LatestChapter}}<p class="hint">Latest known: Ch {{.LatestChapter}}</p>{{end}}
{{/* The field holds your progress; "Latest known" is the published chapter.
Those are different numbers whenever this form is worth opening, so the
label names the field and the latest sits after it as context. */}}
<label class="hint" for="chapter-{{.Key}}">Chapter you're on</label>
<div class="field">
<input name="chapter" type="number" step="0.1" min="0"
value="{{.LastChapterNum}}" aria-label="Chapter number" required>
{{/* max is a fat-finger guard, not a real ceiling — no series is near it. */}}
<input id="chapter-{{.Key}}" name="chapter" type="number" step="0.1" min="0" max="9999"
value="{{.LastChapterNum}}" required>
<button type="submit">Save</button>
</div>
{{if .LatestChapter}}<p class="hint">Latest known: {{.DisplayLatest}}</p>{{end}}
</form>
<div class="confirm-row" id="confirm-row-{{.Key}}" hidden>
<span>Remove this?</span>
{{/* 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 “{{.Title}}”? Chapter progress is lost.</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>
{{/* 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}}
+30
View File
@@ -0,0 +1,30 @@
{{/* The two regions that live outside the swapped #list: the "Continue
reading" strip and the Updated badge. Both are rendered inline by app.html
and again, out of band, on every /ui/ response — a mutation must not leave
them describing the library as it was before the tap.
Both always render, hidden when they have nothing to say, so an out-of-band
swap always has an element with the right id to replace. */}}
{{define "recent"}}
<section class="recent" id="recent"{{if .OOB}} hx-swap-oob="true"{{end}}{{if not .Recent}} hidden{{end}}>
<h2>Continue reading</h2>
<div class="recent-strip">
{{range .Recent}}
<a class="recent-card {{if .HasNewChapter}}is-new{{end}}" href="{{.ContinueURL}}"
target="_blank" rel="noopener noreferrer">
<span class="recent-cover">
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
{{if .HasNewChapter}}<span class="foot-rule"></span>
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
</span>
<span class="recent-title">{{.Title}}</span>
<span class="recent-chapter">{{.DisplayChapter}}{{if .HasNewChapter}} · New{{end}}</span>
</a>
{{end}}
</div>
</section>
{{end}}
{{define "newcount"}}<span class="count" id="new-count"{{if .OOB}} hx-swap-oob="true"{{end}}{{if not .NewCount}} hidden{{end}}>{{.NewCount}}</span>{{end}}
+8 -1
View File
@@ -1,10 +1,17 @@
{{define "list"}}
{{if .Items}}
{{range .Items}}{{template "card" .}}{{end}}
{{/* The client filter only hides cards, so without this the list area goes
blank on a query that matches nothing. filter.js fills in the query and
unhides it; it lives inside #list so a tab swap re-creates it. */}}
<div class="empty" id="no-match" hidden>
<strong>No titles match “<span class="no-match-q"></span>”.</strong>
<button type="button" class="clear-search">Clear search</button>
</div>
{{else if eq .Tab "fav"}}
<div class="empty"><strong>No favourites yet.</strong><p>Star a series to pin it here.</p></div>
{{else if eq .Tab "new"}}
<div class="empty hot"><strong>Nothing new.</strong><p>Every series is caught up to its latest chapter.</p></div>
<div class="empty"><strong>Nothing new.</strong><p>Every series is caught up to its latest chapter.</p></div>
{{else if eq .Tab "archived"}}
<div class="empty"><strong>Nothing archived.</strong><p>Shelve a series to park it here — it keeps getting checked for new chapters.</p></div>
{{else if eq .Tab "finished"}}
+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>