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:
+120
-14
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -450,8 +451,14 @@ func TestUIDeleteRemovesRow(t *testing.T) {
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("delete status = %d, want 200", rr.Code)
|
||||
}
|
||||
if rr.Body.Len() != 0 {
|
||||
t.Fatalf("delete body = %q, want empty so htmx swaps the card away", rr.Body.String())
|
||||
// The body carries only out-of-band chrome, so htmx has nothing to swap into
|
||||
// the card's place and the row disappears.
|
||||
body := rr.Body.String()
|
||||
if strings.Contains(body, `class="card`) {
|
||||
t.Fatalf("delete body = %q, want no card so htmx swaps it away", body)
|
||||
}
|
||||
if !strings.Contains(body, `id="new-count" hx-swap-oob="true"`) {
|
||||
t.Fatalf("delete body = %q, want the out-of-band badge", body)
|
||||
}
|
||||
if _, ok, _ := store.Get("asura:solo"); ok {
|
||||
t.Fatal("row still present after delete")
|
||||
@@ -578,25 +585,90 @@ func TestTabsShowOnlyTheirBucket(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentStripExcludesArchivedAndFinished(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, store)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/?tab=archived", nil)
|
||||
// stripOf returns everything above the list, which is where the recent section
|
||||
// renders.
|
||||
func stripOf(t *testing.T, srv http.Handler, cfg Config, tab string) string {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/?tab="+tab, nil)
|
||||
req.AddCookie(sessionCookie(t, cfg))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
body := rr.Body.String()
|
||||
if i := strings.Index(body, `id="list"`); i >= 0 {
|
||||
body = body[:i]
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
strip := rr.Body.String()
|
||||
if i := strings.Index(strip, `id="list"`); i >= 0 {
|
||||
strip = strip[:i] // everything above the list is the recent section
|
||||
// The strip carries the series with a chapter waiting — the one thing the
|
||||
// updated_at-ordered list below it does not already say — and only on All.
|
||||
func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seedStatusRows(t, store) // ReadingOne is at 10 with 11 out; the rest are not reading
|
||||
|
||||
// A reading series that is caught up has nothing waiting, so it stays out.
|
||||
caught := Bookmark{
|
||||
Key: "asura:caught", Site: "asura", SeriesID: "caught", Title: "CaughtUpOne",
|
||||
Status: statusReading, LastChapterNum: 40, LatestChapter: "40",
|
||||
LatestChapterNum: floatPtr(40), UpdatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
if strings.Contains(strip, "ArchivedOne") || strings.Contains(strip, "FinishedOne") {
|
||||
t.Fatal("recent strip included an archived or finished series")
|
||||
if _, err := store.Upsert(caught); err != nil {
|
||||
t.Fatalf("seed %s: %v", caught.Key, err)
|
||||
}
|
||||
|
||||
strip := stripOf(t, srv, cfg, "all")
|
||||
if !strings.Contains(strip, "ReadingOne") {
|
||||
t.Fatal("recent strip dropped the reading series")
|
||||
t.Fatal("strip dropped the series with an unread chapter")
|
||||
}
|
||||
for _, unwanted := range []string{"CaughtUpOne", "ArchivedOne", "FinishedOne"} {
|
||||
if strings.Contains(strip, unwanted) {
|
||||
t.Fatalf("strip included %s", unwanted)
|
||||
}
|
||||
}
|
||||
for _, tab := range []string{"new", "fav", "archived", "finished"} {
|
||||
if strings.Contains(stripOf(t, srv, cfg, tab), "ReadingOne") {
|
||||
t.Fatalf("tab %s rendered the strip", tab)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing new anywhere: the strip has nothing to say and does not render.
|
||||
reading, _, err := store.Get("asura:reading")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
reading.LatestChapterNum = floatPtr(reading.LastChapterNum)
|
||||
if _, err := store.Upsert(reading); err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
// The section still ships (an out-of-band swap needs the id to exist) but
|
||||
// carries no cards and is hidden.
|
||||
empty := stripOf(t, srv, cfg, "all")
|
||||
if strings.Contains(empty, "recent-card") {
|
||||
t.Fatal("strip rendered cards with no unread chapters anywhere")
|
||||
}
|
||||
if !strings.Contains(empty, `id="recent" hidden`) {
|
||||
t.Fatalf("strip not hidden with nothing new: %q", empty)
|
||||
}
|
||||
}
|
||||
|
||||
// The strip never grows past recentCount, however many series are waiting.
|
||||
func TestRecentStripCapped(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
for i := 0; i <= recentCount; i++ {
|
||||
b := Bookmark{
|
||||
Key: fmt.Sprintf("asura:new%d", i), Site: "asura",
|
||||
SeriesID: fmt.Sprintf("new%d", i), Title: fmt.Sprintf("Waiting%d", i),
|
||||
Status: statusReading, LastChapterNum: 1, LatestChapter: "2",
|
||||
LatestChapterNum: floatPtr(2), UpdatedAt: time.Now().UnixMilli() + int64(i),
|
||||
}
|
||||
if _, err := store.Upsert(b); err != nil {
|
||||
t.Fatalf("seed %s: %v", b.Key, err)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(stripOf(t, srv, cfg, "all"), "recent-card"); got != recentCount {
|
||||
t.Fatalf("strip rendered %d cards, want %d", got, recentCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,3 +802,37 @@ func TestAppRendersNewTabs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A mutation has to bring the chrome with it: the strip and the badge live
|
||||
// outside the swapped card, so nothing else would correct them.
|
||||
func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
|
||||
cfg := webConfig()
|
||||
srv, store := newWebTestServer(t, cfg)
|
||||
seed(t, store, Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling",
|
||||
Status: statusReading, LastChapterNum: 10, LatestChapter: "Chapter 11",
|
||||
LatestChapterNum: floatPtr(11), UpdatedAt: time.Now().UnixMilli(),
|
||||
})
|
||||
|
||||
before := stripOf(t, srv, cfg, "all")
|
||||
if !strings.Contains(before, "Solo Leveling") || !strings.Contains(before, `id="new-count"`) {
|
||||
t.Fatalf("expected the series in the strip to start with: %q", before)
|
||||
}
|
||||
|
||||
req := uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/status",
|
||||
url.Values{"status": {statusArchived}})
|
||||
req.Header.Set("HX-Current-URL", "http://localhost/?tab=all")
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status post = %d, want 200", rr.Code)
|
||||
}
|
||||
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `id="recent" hx-swap-oob="true" hidden`) {
|
||||
t.Fatalf("archiving did not empty the strip out of band: %q", body)
|
||||
}
|
||||
if !strings.Contains(body, `id="new-count" hx-swap-oob="true" hidden`) {
|
||||
t.Fatalf("archiving did not clear the Updated badge out of band: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user