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:
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -26,6 +27,71 @@ func withAuth(token string, next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// gzipWriter compresses the body and drops Content-Length, which no longer
|
||||
// describes what goes on the wire. WriteHeader is where the decision is made:
|
||||
// only then is Content-Type known.
|
||||
type gzipWriter struct {
|
||||
http.ResponseWriter
|
||||
gz *gzip.Writer
|
||||
decided bool
|
||||
}
|
||||
|
||||
// compressible covers what this server actually serves in bulk: HTML, CSS, JS
|
||||
// and JSON. Fonts are woff2, which is already compressed — gzipping them costs
|
||||
// CPU to add bytes.
|
||||
func compressible(contentType string) bool {
|
||||
ct, _, _ := strings.Cut(contentType, ";")
|
||||
switch strings.TrimSpace(ct) {
|
||||
case "text/html", "text/css", "text/javascript", "application/javascript",
|
||||
"application/json", "text/plain":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (w *gzipWriter) WriteHeader(status int) {
|
||||
if !w.decided {
|
||||
w.decided = true
|
||||
if compressible(w.Header().Get("Content-Type")) {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
w.Header().Del("Content-Length")
|
||||
w.gz = gzip.NewWriter(w.ResponseWriter)
|
||||
}
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (w *gzipWriter) Write(b []byte) (int, error) {
|
||||
if !w.decided {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
if w.gz != nil {
|
||||
return w.gz.Write(b)
|
||||
}
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// withGzip compresses text responses for clients that ask. The templates,
|
||||
// stylesheet and htmx together are ~120 KB uncompressed and roughly a quarter
|
||||
// of that gzipped, which is the difference between a fast and a slow first load
|
||||
// on mobile data.
|
||||
func withGzip(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Add("Vary", "Accept-Encoding")
|
||||
gw := &gzipWriter{ResponseWriter: w}
|
||||
defer func() {
|
||||
if gw.gz != nil {
|
||||
gw.gz.Close()
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(gw, r)
|
||||
})
|
||||
}
|
||||
|
||||
// withCORS reflects the request Origin only when it is in allowed, answers
|
||||
// preflight OPTIONS with 204, and passes everything else through. It wraps the
|
||||
// auth middleware so preflight (which carries no Authorization header) is never
|
||||
|
||||
Reference in New Issue
Block a user