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:
+75
-18
@@ -9,6 +9,7 @@ import (
|
||||
"math"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -44,6 +45,9 @@ type listView struct {
|
||||
// reading set, not the active tab, so the badge does not change meaning as
|
||||
// the user moves between tabs.
|
||||
NewCount int
|
||||
// OOB marks a render of the chrome partials as an out-of-band swap rather
|
||||
// than the inline copy app.html lays out.
|
||||
OOB bool
|
||||
}
|
||||
|
||||
// Initial is the monogram the templates show in place of a cover when the
|
||||
@@ -184,12 +188,6 @@ func (h *webHandler) buildListView(tab string) (listView, error) {
|
||||
}
|
||||
reading := filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusReading })
|
||||
|
||||
// The strip reflects overall reading recency, not the active tab.
|
||||
recent := reading
|
||||
if len(recent) > recentCount {
|
||||
recent = recent[:recentCount]
|
||||
}
|
||||
|
||||
withNew := filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() })
|
||||
|
||||
var items []Bookmark
|
||||
@@ -206,6 +204,22 @@ func (h *webHandler) buildListView(tab string) (listView, error) {
|
||||
tab = "all"
|
||||
items = reading
|
||||
}
|
||||
// The strip is scoped to series with a chapter waiting, which is the one
|
||||
// question the list below it does not already answer: the list is ordered by
|
||||
// reading recency, so the head of it *is* the strip whenever the strip is
|
||||
// just "the most recent rows". Only on All — on Updated it would render the
|
||||
// same set twice, and on the other tabs it would contradict the bucket.
|
||||
//
|
||||
// It therefore disappears entirely on a library with nothing new. That is
|
||||
// the intended reading: an empty strip has nothing to say, and the ~240px it
|
||||
// costs on a phone belongs to the list.
|
||||
var recent []Bookmark
|
||||
if tab == "all" {
|
||||
recent = withNew
|
||||
if len(recent) > recentCount {
|
||||
recent = recent[:recentCount]
|
||||
}
|
||||
}
|
||||
return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
|
||||
}
|
||||
|
||||
@@ -217,6 +231,46 @@ func (h *webHandler) uiList(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "list", view)
|
||||
// The chrome is outside this response's swap target, so without this a tab
|
||||
// switch would leave the strip and badge from whichever tab the page was
|
||||
// loaded on — the same URL would render differently depending on how the
|
||||
// reader got there.
|
||||
h.writeChromeOOB(w, view)
|
||||
}
|
||||
|
||||
// currentTab is the tab the reader is looking at, read from htmx's own header,
|
||||
// so out-of-band chrome is rebuilt for that view rather than for a default.
|
||||
func currentTab(r *http.Request) string {
|
||||
u, err := url.Parse(r.Header.Get("HX-Current-URL"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Query().Get("tab")
|
||||
}
|
||||
|
||||
// writeChromeOOB appends the two regions that live outside #list — the recent
|
||||
// strip and the Updated badge — as out-of-band swaps, so a mutation cannot
|
||||
// leave them describing the library as it was before the tap.
|
||||
func (h *webHandler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
||||
view.OOB = true
|
||||
for _, name := range []string{"recent", "newcount"} {
|
||||
if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil {
|
||||
// The card is already written; stale chrome beats a torn response.
|
||||
log.Printf("render %s oob: %v", name, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refreshChrome rebuilds the chrome for the reader's current tab after a
|
||||
// mutation and appends it to the response.
|
||||
func (h *webHandler) refreshChrome(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := h.buildListView(currentTab(r))
|
||||
if err != nil {
|
||||
log.Printf("ui chrome: %v", err)
|
||||
return
|
||||
}
|
||||
h.writeChromeOOB(w, view)
|
||||
}
|
||||
|
||||
func (h *webHandler) login(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -273,17 +327,16 @@ func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bo
|
||||
return b, true
|
||||
}
|
||||
|
||||
// saveAndRenderCard upserts and renders the row as stored. Upsert decides
|
||||
// whether updated_at moves, so the argument's timestamp is only a candidate and
|
||||
// the response must come from the return value.
|
||||
// saveAndRenderCard upserts and renders the row as stored, then refreshes the
|
||||
// chrome. Upsert decides whether updated_at moves, so the argument's timestamp
|
||||
// is only a candidate and the response must come from the return value.
|
||||
//
|
||||
// ponytail: the swapped card stays put even when its new status no longer
|
||||
// matches the active tab, add an hx-swap-oob list refresh if that reads as a
|
||||
// bug rather than as feedback. Archiving from the All tab leaves the card on
|
||||
// screen until the next list load. The alternative costs a full list round
|
||||
// trip on every toggle, and the card visibly showing its new state is the
|
||||
// feedback the user needs.
|
||||
func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, b Bookmark) {
|
||||
// matches the active tab. That much is deliberate — the card showing its new
|
||||
// state is the feedback for the tap. The strip and the badge are not: they
|
||||
// describe the whole library, so they are rebuilt out of band on every
|
||||
// mutation, at the cost of one extra list read per toggle.
|
||||
func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b Bookmark) {
|
||||
stored, err := h.store.Upsert(b)
|
||||
if err != nil {
|
||||
log.Printf("ui upsert %q: %v", b.Key, err)
|
||||
@@ -291,6 +344,7 @@ func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, b Bookmark) {
|
||||
return
|
||||
}
|
||||
h.render(w, http.StatusOK, "card", stored)
|
||||
h.refreshChrome(w, r)
|
||||
}
|
||||
|
||||
// uiFavorite flips the favourite flag. last_chapter_num is untouched, so
|
||||
@@ -302,7 +356,7 @@ func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
b.Favorite = !b.Favorite
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
h.saveAndRenderCard(w, b)
|
||||
h.saveAndRenderCard(w, r, b)
|
||||
}
|
||||
|
||||
// uiStatus moves a bookmark between lifecycle buckets. This is the only place
|
||||
@@ -328,7 +382,7 @@ func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
h.saveAndRenderCard(w, b)
|
||||
h.saveAndRenderCard(w, r, b)
|
||||
}
|
||||
|
||||
// uiChapter forces the read chapter to a value the user typed.
|
||||
@@ -364,7 +418,7 @@ func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
||||
b.LastChapterNum = num
|
||||
}
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
h.saveAndRenderCard(w, b)
|
||||
h.saveAndRenderCard(w, r, b)
|
||||
}
|
||||
|
||||
// uiDelete removes the row and answers with an empty body, which htmx swaps in
|
||||
@@ -382,4 +436,7 @@ func (h *webHandler) uiDelete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// The empty body is what removes the card; the chrome still has to be told
|
||||
// the library got smaller.
|
||||
h.refreshChrome(w, r)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user