From 8876e5e364aa8490f19425dc1f10d3d60e70ff53 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Thu, 30 Jul 2026 14:28:31 +0700 Subject: [PATCH 01/13] Confirm-gate lifecycle actions, cluster the action strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the 2026-07-30 impeccable critique of the web UI. Archive and finish now open their own .confirm-row disclosure, the pattern delete already used, so no single tap moves a series out of the list. Restore stays instant — it is the reversal. The two reversible confirms wear grey; remove keeps the ember wash. The six action cells cluster by consequence: navigate (play) | organize (favourite, chapter) | lifecycle (archive/restore, finish, remove). The lifecycle cells sit on a recessed ground, separated by a hairline on a phone and by a gap on desktop, so the 4th-slot icon swapping between .box and .restore no longer breaks position memory. htmx:responseError branches on status: 401 offers a login link, 400 surfaces the server's own reason, everything else keeps the retry copy. Busy bar and inline error move to --mute, leaving --ember exclusive to the new-chapter signal. Also: aria-live on the confirm rows, max on the chapter input, cover width and row gap as tokens instead of a hardcoded 94px indent, and mobile covers 30% larger. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 15 +++++++ backend/static/filter.js | 71 ++++++++++++++++++++++---------- backend/static/style.css | 70 +++++++++++++++++++++++++------- backend/templates/card.html | 80 +++++++++++++++++++++++-------------- 4 files changed, 172 insertions(+), 64 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a8f80dd..e719837 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,21 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache) the web routes are not registered at all. UI mutations read-modify-write through `Store.Get` + `Store.Upsert` so the `updated_at` rule stays in one place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`. + **Design-tool caveat:** the templates link `/static/style.css` root-absolutely + (correct — they are served from `/`), but the impeccable detector resolves a + stylesheet href with `path.resolve(fileDir, href)`, which drops the directory + on a leading `/` and silently skips the file. A relative href does not help + either: the template's directory is not its served path. So + `detect.mjs backend/templates` reports a **false clean** — always pass + `backend/static` too. Its one finding there, `overused-font` on "Instrument + Serif", is a deliberate identity choice, not debt. +- **Every action that moves a series out of the list is confirm-gated.** + Archive, finish, and remove each open their own `.confirm-row` disclosure + (`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈ + `archive|finish|remove`); restore fires instantly because it is the reversal. + Remove's row wears the ember wash, the two reversible ones wear `.calm` grey. + `--ember` stays reserved for the new-chapter signal: busy bar and inline + error use `--mute`. - **Latest-chapter poller:** a ticker goroutine in the same binary re-checks each bookmarked series' newest published chapter from the backend's own network access, so `latest_chapter` stays fresh when the user is not diff --git a/backend/static/filter.js b/backend/static/filter.js index ee15ec5..2902d02 100644 --- a/backend/static/filter.js +++ b/backend/static/filter.js @@ -25,61 +25,92 @@ function setActiveTab(el) { }); } -// The chapter-edit form and the delete confirm row are the two per-card -// disclosure panels; only one makes sense open at a time. The button that -// owns an open panel carries .open, which is how the strip shows which cell -// the panel belongs to. +// The chapter-edit form and the archive/finish/remove confirm rows are the +// per-card disclosure panels; only one makes sense open at a time. The button +// that owns an open panel carries .open, which is how the strip shows which +// cell the panel belongs to. function closeCardPanels(key) { - var form = document.getElementById("chapter-form-" + key); - var confirm = document.getElementById("confirm-row-" + key); - if (form) form.hidden = true; - if (confirm) confirm.hidden = true; var card = document.getElementById("card-" + key); - if (card) { - card.querySelectorAll(".actions .open").forEach(function (b) { - b.classList.remove("open"); - }); - } + if (!card) return; + card.querySelectorAll(".chapter-form, .confirm-row").forEach(function (p) { + p.hidden = true; + }); + card.querySelectorAll(".actions .open").forEach(function (b) { + b.classList.remove("open"); + b.setAttribute("aria-expanded", "false"); + }); } function togglePanel(key, panelId, buttonSelector) { - var panel = document.getElementById(panelId + key); + var panel = document.getElementById(panelId); if (!panel) return null; var opening = panel.hidden; closeCardPanels(key); panel.hidden = !opening; var card = document.getElementById("card-" + key); var button = card && card.querySelector(buttonSelector); - if (button) button.classList.toggle("open", opening); + if (button) { + button.classList.toggle("open", opening); + button.setAttribute("aria-expanded", opening ? "true" : "false"); + } return panel; } function toggleChapterForm(key) { - var form = togglePanel(key, "chapter-form-", ".actions .pencil"); + var form = togglePanel(key, "chapter-form-" + key, ".actions .pencil"); if (form && !form.hidden) form.querySelector("input").focus(); } -function toggleConfirmRow(key) { - togglePanel(key, "confirm-row-", ".actions .remove"); +// kind is "archive" | "finish" | "remove" — the panel id and the owning action +// cell share it. +function toggleConfirmRow(key, kind) { + var cls = { archive: ".box", finish: ".finish", remove: ".remove" }[kind]; + togglePanel(key, "confirm-" + kind + "-" + key, ".actions " + cls); } // A failed favourite/chapter/delete request leaves the card in place (htmx // does not swap on a non-2xx response) but otherwise gives no sign anything // went wrong. Surface it inline instead of leaving the tap looking ignored. (function () { - function showError(elt, message) { + function showError(elt, message, linkHref, linkText) { var card = elt.closest(".card"); var slot = card && card.querySelector(".error-inline"); if (!slot) return; slot.textContent = message; + if (linkHref) { + var a = document.createElement("a"); + a.href = linkHref; + a.textContent = linkText; + a.className = "error-link"; + slot.append(" ", a); + } slot.hidden = false; clearTimeout(slot._hideTimer); slot._hideTimer = setTimeout(function () { slot.hidden = true; - }, 5000); + }, linkHref ? 30000 : 5000); + } + + // The handlers answer a bad value with http.Error, i.e. a short plain-text + // line — worth showing verbatim. Anything long or HTML-ish is an error page, + // not a reason, so fall back to the generic copy. + function reasonFrom(xhr) { + var body = (xhr.responseText || "").trim(); + if (!body || body.length > 120 || body.indexOf("<") === 0) return ""; + return body.charAt(0).toUpperCase() + body.slice(1); } document.body.addEventListener("htmx:responseError", function (e) { + var xhr = e.detail.xhr; + if (xhr.status === 401) { + showError(e.detail.elt, "Session expired — nothing was saved.", "/login", "Log in again"); + return; + } + if (xhr.status === 400) { + var reason = reasonFrom(xhr); + showError(e.detail.elt, reason ? reason + "." : "That value wasn't accepted — check it and try again."); + return; + } showError(e.detail.elt, "Couldn't save — try again."); }); document.body.addEventListener("htmx:sendError", function (e) { diff --git a/backend/static/style.css b/backend/static/style.css index 76692a0..d5477fa 100644 --- a/backend/static/style.css +++ b/backend/static/style.css @@ -84,6 +84,10 @@ --hatch-dim: repeating-linear-gradient(135deg, #1b1918 0 5px, #151313 5px 10px); --measure: 760px; + /* Cover width + row gap: the disclosure panels indent past the cover on + desktop, so both live here rather than as magic numbers. */ + --cover-w: 86px; + --row-gap: 14px; } /* Light mode: same rules, cooler paper. Hues are re-tuned, not reused — the @@ -142,7 +146,9 @@ button { cursor: pointer; } @keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(320%) } } @keyframes sheetIn { from { opacity: 0; transform: translateY(-4px) } to { opacity: 1; transform: none } } -@keyframes emberPulse { 0%, 100% { opacity: .5 } 50% { opacity: 1 } } +/* Transient system states pulse in grey: ember stays reserved for "new + chapter" so a tired glance never misreads a busy bar as unread heat. */ +@keyframes mutePulse { 0%, 100% { opacity: .35 } 50% { opacity: 1 } } /* One measured column, edges drawn rather than boxed. */ .sheet { @@ -335,12 +341,12 @@ button { cursor: pointer; } } .card.is-dim { background: var(--dim); } -.row { display: flex; flex-wrap: wrap; gap: 14px; } +.row { display: flex; flex-wrap: wrap; gap: var(--row-gap); } .cover { position: relative; - width: 66px; - height: 88px; + width: var(--cover-w); + height: calc(var(--cover-w) * 4 / 3); flex: none; background: var(--hatch); display: grid; @@ -348,7 +354,7 @@ button { cursor: pointer; } overflow: hidden; } .cover img { width: 100%; height: 100%; object-fit: cover; } -.cover .monogram { font-size: 24px; } +.cover .monogram { font-size: 30px; } .is-dim .cover { background: var(--hatch-dim); filter: grayscale(1); opacity: .85; } .body { @@ -428,9 +434,20 @@ button { cursor: pointer; } .actions .remove { color: var(--trash); } .actions .remove:hover { color: var(--ember); } .actions .open { background: var(--hover); color: var(--paper); } +/* Lifecycle cells already sit on --ash, which is what --hover resolves to, so + an open one needs the next step up to stay legible as the panel's owner. */ +.actions .lifecycle.open { background: var(--rule); } .actions .remove.open { background: var(--ember-wash); color: var(--ember); } .is-dim .actions > * { color: var(--mute-2); } +/* Three clusters by consequence: navigate (play) | organize (favourite, + chapter) | lifecycle (archive/restore, finish, remove). The lifecycle cells + sit on a recessed ground so the thumb reads "this one moves the series" + before it reads which icon it landed on. */ +.actions > .lifecycle { background: var(--ash); } +.actions > .play + *, +.actions > *:not(.lifecycle) + .lifecycle { box-shadow: inset 1px 0 0 var(--field-line); } + /* ---- disclosure panels ---- */ .chapter-form, .confirm-row, .error-inline { animation: sheetIn .18s ease-out; } @@ -498,6 +515,17 @@ button { cursor: pointer; } color: var(--ember-ink); font-weight: 600; } +/* Archive and finish are reversible, so their confirm asks in grey — only the + irreversible remove gets the ember wash. */ +.confirm-row.calm { background: var(--ash); } +.confirm-row.calm span { color: var(--paper-dim); } +.confirm-row .go { + border: none; + background: var(--paper); + color: var(--ink); + font-weight: 600; +} +.confirm-row .go:hover { color: var(--ink); } .error-inline { display: flex; @@ -505,10 +533,10 @@ button { cursor: pointer; } gap: 9px; margin: 0; padding: 11px 13px; - background: var(--ember-wash); - border-left: 2px solid var(--ember); + background: var(--ash); + border-left: 2px solid var(--mute); font: 400 16px var(--font-display); - color: var(--ember-soft); + color: var(--paper-dim); } .error-inline::before { content: ""; @@ -516,11 +544,16 @@ button { cursor: pointer; } width: 5px; height: 5px; border-radius: 50%; - background: var(--ember); - animation: emberPulse 1.4s ease-in-out infinite; + background: var(--mute); + animation: mutePulse 1.4s ease-in-out infinite; +} +.error-inline .error-link { + border-bottom: 1px solid var(--field-line); + color: var(--paper); } -/* Busy: the hairline at the top of the sheet burns across it. */ +/* Busy: a grey hairline slides across the top of the sheet — deliberately not + ember, which only ever means "new chapter". */ .card.htmx-request { overflow: hidden; } .card.htmx-request .actions { pointer-events: none; opacity: .5; } .card.htmx-request::before { @@ -530,7 +563,7 @@ button { cursor: pointer; } left: 0; width: 30%; height: 1px; - background: var(--ember); + background: var(--mute); animation: barSlide 1.15s linear infinite; } @@ -616,6 +649,7 @@ button { cursor: pointer; } /* ---- desktop: same measure, actions fold up beside the row ---- */ @media (min-width: 720px) { + :root { --cover-w: 74px; --row-gap: 20px; } .topbar { padding: 26px 32px 18px; } .brand { font-size: 30px; } @@ -634,8 +668,7 @@ button { cursor: pointer; } .recent-cover { height: 133px; } .card { padding: 18px 32px; } - .row { flex-wrap: nowrap; align-items: center; gap: 20px; } - .cover { width: 74px; height: 100px; } + .row { flex-wrap: nowrap; align-items: center; } .cover .monogram { font-size: 26px; } .title { font-size: 21px; } @@ -646,10 +679,17 @@ button { cursor: pointer; } height: 40px; border: 1px solid var(--rule); } + /* Cells are already gapped here, so the clusters separate by space rather + 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; } - .chapter-form, .confirm-row, .error-inline { margin-left: 94px; } + /* Panels line up with the body text, i.e. past the cover and its gap. */ + .chapter-form, .confirm-row, .error-inline { + margin-left: calc(var(--cover-w) + var(--row-gap)); + } } @media (prefers-reduced-motion: reduce) { diff --git a/backend/templates/card.html b/backend/templates/card.html index 35d0e69..6f02b20 100644 --- a/backend/templates/card.html +++ b/backend/templates/card.html @@ -40,7 +40,7 @@ title="Continue reading" aria-label="Continue reading"> - - {{if eq .Status "finished"}} - - {{else}} - {{if eq .Status "archived"}} - - {{else}} - - {{end}} - + {{end}} + {{if ne .Status "finished"}} + {{end}} - @@ -94,19 +88,47 @@ hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="input, button"> {{if .LatestChapter}}

Latest known: Ch {{.LatestChapter}}

{{end}}
-
- - diff --git a/backend/templates/card.html b/backend/templates/card.html index 6f02b20..5ab8dee 100644 --- a/backend/templates/card.html +++ b/backend/templates/card.html @@ -131,6 +131,8 @@ - + {{/* role=status announces a failed write; without it the tap just looks + ignored to a screen reader. */}} + {{end}} -- 2.52.0 From 13789184ff1b9e2fd4f4c99d5241f1ff281c0346 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Thu, 30 Jul 2026 18:53:17 +0700 Subject: [PATCH 03/13] Render one chapter format, not "Ch Chapter 250" The userscript writes last_chapter/latest_chapter as "Chapter N" and the poller writes the same label, so the templates' own "Ch " prefix doubled it on every real row. A manual edit through the web UI stores a bare "250", so the list carried two formats at once and stopped being scannable. Move the formatting out of the templates into DisplayChapter/DisplayLatest on Bookmark, which strip a leading "Chapter"/"Ch." lead-in and re-add exactly one "Ch ", falling back to the numeric column when the label is empty. Co-Authored-By: Claude Opus 5 --- backend/store.go | 36 +++++++++++++++++++++++++++++++++++ backend/store_test.go | 38 +++++++++++++++++++++++++++++++++++++ backend/templates/app.html | 2 +- backend/templates/card.html | 6 +++--- 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/backend/store.go b/backend/store.go index 1684446..a911ef5 100644 --- a/backend/store.go +++ b/backend/store.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "regexp" + "strconv" "strings" _ "modernc.org/sqlite" @@ -41,6 +42,41 @@ func (b Bookmark) HasNewChapter() bool { return b.LatestChapterNum != nil && *b.LatestChapterNum > b.LastChapterNum } +// chapterLeadIn matches the prefix the userscript and the poller both write +// ("Chapter 250"), so the UI can add exactly one "Ch " of its own instead of +// doubling it. A manual edit through the web UI stores a bare "250", which is +// the same string minus the lead-in. +var chapterLeadIn = regexp.MustCompile(`(?i)^\s*(?:chapter|ch\.?)\s*`) + +func displayChapter(raw string, num float64) string { + rest := strings.TrimSpace(chapterLeadIn.ReplaceAllString(raw, "")) + if rest == "" { + rest = strconv.FormatFloat(num, 'f', -1, 64) + } + // "Ch " only makes sense in front of a number; anything else is a label the + // site gave us, so pass it through as written. + if rest[0] < '0' || rest[0] > '9' { + return rest + } + return "Ch " + rest +} + +// DisplayChapter is the read-progress line: one canonical "Ch N" whatever +// format the write came in as. +func (b Bookmark) DisplayChapter() string { + return displayChapter(b.LastChapter, b.LastChapterNum) +} + +// DisplayLatest is the same for the newest published chapter, which arrives +// with the same "Chapter N" lead-in from both the userscript and the poller. +func (b Bookmark) DisplayLatest() string { + var num float64 + if b.LatestChapterNum != nil { + num = *b.LatestChapterNum + } + return displayChapter(b.LatestChapter, num) +} + // ContinueURL is where the Continue button points: the chapter last read, or // the series page when no chapter URL was ever captured. func (b Bookmark) ContinueURL() string { diff --git a/backend/store_test.go b/backend/store_test.go index 3c1e231..03d4f1b 100644 --- a/backend/store_test.go +++ b/backend/store_test.go @@ -1034,3 +1034,41 @@ func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) { t.Fatalf("merge kept wrong row: %+v", merged) } } + +func TestDisplayChapter(t *testing.T) { + cases := []struct { + name string + raw string + num float64 + want string + }{ + {"userscript label", "Chapter 250", 250, "Ch 250"}, + {"abbreviated with dot", "Ch. 12", 12, "Ch 12"}, + {"lowercase abbreviation", "ch 3", 3, "Ch 3"}, + {"bare number from a manual edit", "45.5", 45.5, "Ch 45.5"}, + {"empty falls back to the number", "", 7, "Ch 7"}, + {"empty and zero", "", 0, "Ch 0"}, + {"label with no number passes through", "Prologue", 0, "Prologue"}, + {"title-like label keeps its words", "Chapter of the Sun", 0, "of the Sun"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := displayChapter(tc.raw, tc.num); got != tc.want { + t.Errorf("displayChapter(%q, %v) = %q, want %q", tc.raw, tc.num, got, tc.want) + } + }) + } + + b := Bookmark{LastChapter: "Chapter 9", LastChapterNum: 9} + if got := b.DisplayChapter(); got != "Ch 9" { + t.Errorf("DisplayChapter() = %q, want %q", got, "Ch 9") + } + if got := b.DisplayLatest(); got != "Ch 0" { + t.Errorf("DisplayLatest() with nothing captured = %q, want %q", got, "Ch 0") + } + num := 11.0 + b.LatestChapter, b.LatestChapterNum = "Chapter 11", &num + if got := b.DisplayLatest(); got != "Ch 11" { + t.Errorf("DisplayLatest() = %q, want %q", got, "Ch 11") + } +} diff --git a/backend/templates/app.html b/backend/templates/app.html index e4e9b80..f133fc0 100644 --- a/backend/templates/app.html +++ b/backend/templates/app.html @@ -75,7 +75,7 @@ {{else if .Favorite}}{{end}} {{.Title}} - Ch {{.LastChapter}}{{if .HasNewChapter}} · New{{end}} + {{.DisplayChapter}}{{if .HasNewChapter}} · New{{end}} {{end}} diff --git a/backend/templates/card.html b/backend/templates/card.html index 5ab8dee..613ff62 100644 --- a/backend/templates/card.html +++ b/backend/templates/card.html @@ -21,10 +21,10 @@

{{.Site}} / - Ch {{.LastChapter}} + {{.DisplayChapter}} {{if and (eq .Status "reading") .HasNewChapter}} / - Ch {{.LatestChapter}} out + {{.DisplayLatest}} out {{end}} {{if eq .Status "archived"}} / @@ -86,7 +86,7 @@ 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}}

Latest known: Ch {{.LatestChapter}}

{{end}} + {{if .LatestChapter}}

Latest known: {{.DisplayLatest}}

{{end}}
{{/* max is a fat-finger guard, not a real ceiling — no series is near it. */}} Date: Thu, 30 Jul 2026 19:01:03 +0700 Subject: [PATCH 04/13] Give the search a zero-result state, hide the strip while filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client filter only toggles card.hidden, so a query matching nothing left the list area blank while the unfiltered "Continue reading" strip stayed populated above it — the screen contradicted itself and read as a crash. list.html now carries a hidden no-match block; applyFilter counts visible cards, fills the query in with textContent, and hides .recent for the duration of a filter. The Clear-search button is handled by a delegated click listener so it survives an htmx tab swap. Co-Authored-By: Claude Opus 5 --- backend/static/filter.js | 30 ++++++++++++++++++++++++++++-- backend/static/style.css | 11 +++++++++++ backend/templates/list.html | 7 +++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/backend/static/filter.js b/backend/static/filter.js index 0bf9d8a..64d9d49 100644 --- a/backend/static/filter.js +++ b/backend/static/filter.js @@ -4,17 +4,43 @@ function applyFilter() { var box = document.getElementById("search"); if (!box) return; - var needle = box.value.trim().toLowerCase(); - document.querySelectorAll(".card").forEach(function (card) { + var query = box.value.trim(); + var needle = query.toLowerCase(); + var cards = document.querySelectorAll(".card"); + var visible = 0; + cards.forEach(function (card) { var title = (card.dataset.title || "").toLowerCase(); card.hidden = needle !== "" && title.indexOf(needle) === -1; + if (!card.hidden) visible++; }); + + // The strip is never filtered, so leaving it up during a search puts + // non-matching covers above an empty list and the screen contradicts + // itself. + var recent = document.querySelector(".recent"); + if (recent) recent.hidden = needle !== ""; + + // An empty bucket already explains itself server-side; this only speaks + // when the filter is what emptied the screen. + var none = document.getElementById("no-match"); + if (none) { + none.hidden = !(needle !== "" && cards.length > 0 && visible === 0); + if (!none.hidden) none.querySelector(".no-match-q").textContent = query; + } } document.addEventListener("input", function (e) { if (e.target && e.target.id === "search") applyFilter(); }); + document.addEventListener("click", function (e) { + if (!e.target || !e.target.classList.contains("clear-search")) return; + var box = document.getElementById("search"); + box.value = ""; + applyFilter(); + box.focus(); + }); + // htmx replaces the list on a tab switch, so re-apply to the new cards. document.body.addEventListener("htmx:afterSwap", applyFilter); })(); diff --git a/backend/static/style.css b/backend/static/style.css index 9d2ebaa..ebf18dd 100644 --- a/backend/static/style.css +++ b/backend/static/style.css @@ -591,6 +591,17 @@ button { cursor: pointer; } } .empty strong { font: 400 20px var(--font-display); color: var(--paper); } .empty.hot strong { color: var(--ember); } +.empty .clear-search { + align-self: flex-start; + margin-top: 4px; + height: 44px; + padding: 0 16px; + border: 1px solid var(--field-line); + background: transparent; + color: var(--paper-dim); + font: 400 16px var(--font-display); +} +.empty .clear-search:hover { color: var(--paper); border-color: var(--paper); } .empty p { margin: 0; max-width: 44ch; diff --git a/backend/templates/list.html b/backend/templates/list.html index e6edf97..38ca406 100644 --- a/backend/templates/list.html +++ b/backend/templates/list.html @@ -1,6 +1,13 @@ {{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. */}} + {{else if eq .Tab "fav"}}
No favourites yet.

Star a series to pin it here.

{{else if eq .Tab "new"}} -- 2.52.0 From bef1469413f7e0b1d64c8a0e63c073fff9f892cf Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Thu, 30 Jul 2026 19:05:35 +0700 Subject: [PATCH 05/13] Give destruction its own colour, take ember back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stylesheet asserted twice that --ember only ever means "new chapter" while spending it on eight things, including "Nothing new." — the heat signal firing on its own negation. Add a --danger token (a duller oxblood, tuned per theme) and move remove hover, the open remove cell, the confirm wash and the login error onto it. Text-input focus follows the .searchbar idiom and turns --paper, so a valid number field no longer paints itself red. Drop .empty.hot. Ember now survives on the is-new group, the Updated tab and the brand, which the law comment now says out loud. Contrast, both themes: --danger on --ink 4.82 dark / 6.65 light, --danger-ink on the solid button 4.94 / 7.30, --danger-soft on the wash 9.00 / 7.98. Co-Authored-By: Claude Opus 5 --- backend/static/style.css | 42 ++++++++++++++++++++++++------------- backend/templates/list.html | 2 +- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/backend/static/style.css b/backend/static/style.css index ebf18dd..e9595cc 100644 --- a/backend/static/style.css +++ b/backend/static/style.css @@ -74,6 +74,12 @@ --ember-wash: #1a1211; /* ember-tinted surface */ --ember-ink: #150907; /* text on solid ember */ --ember-soft: #eda798; /* text on ember wash */ + /* Destruction is hot but not ember: a duller oxblood, so a remove confirm is + never mistaken across the room for an unread chapter. */ + --danger: #cf5c4d; + --danger-wash: #211311; + --danger-ink: #150808; + --danger-soft: #e2aaa1; --brass: #b8912f; /* favourite — a second, cooler metal */ --trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */ @@ -120,6 +126,10 @@ --ember-wash: #fbeee9; --ember-ink: #fff; --ember-soft: #8d2c17; + --danger: #97362a; + --danger-wash: #fbe9e5; + --danger-ink: #fff; + --danger-soft: #7c2c22; --brass: #8a681c; --trash: #8c6558; @@ -155,8 +165,11 @@ button { cursor: pointer; } @keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(320%) } } @keyframes sheetIn { from { opacity: 0; transform: translateY(-4px) } to { opacity: 1; transform: none } } -/* Transient system states pulse in grey: ember stays reserved for "new - chapter" so a tired glance never misreads a busy bar as unread heat. */ +/* The law: ember means "new chapter", and nothing else on a list screen — + busy, error and destruction all stay off it, so a tired glance never + misreads a system state as unread heat. The one exception is the brand + itself (the wordmark, and the login screen, which shows no series at all). + Destruction has its own token, --danger. */ @keyframes mutePulse { 0%, 100% { opacity: .35 } 50% { opacity: 1 } } /* One measured column, edges drawn rather than boxed. */ @@ -447,12 +460,12 @@ button { cursor: pointer; } .actions .on { color: var(--brass); } .actions .restore { color: var(--paper); } .actions .remove { color: var(--trash); } -.actions .remove:hover { color: var(--ember); } +.actions .remove:hover { color: var(--danger); } .actions .open { background: var(--hover); color: var(--paper); } /* Lifecycle cells already sit on --ash, which is what --hover resolves to, so an open one needs the next step up to stay legible as the panel's owner. */ .actions .lifecycle.open { background: var(--rule); } -.actions .remove.open { background: var(--ember-wash); color: var(--ember); } +.actions .remove.open { background: var(--danger-wash); color: var(--danger); } .is-dim .actions > * { color: var(--mute-2); } /* Three clusters by consequence: navigate (play) | organize (favourite, @@ -493,7 +506,9 @@ button { cursor: pointer; } font: 500 17px var(--font-mono); outline: none; } -.chapter-form input:focus { border-color: var(--ember); } +/* Focus follows the .searchbar idiom — paper, not heat: a red border on a + valid number field reads as "invalid". */ +.chapter-form input:focus { border-color: var(--paper); } .chapter-form input::-webkit-outer-spin-button, .chapter-form input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .chapter-form button { @@ -511,9 +526,9 @@ button { cursor: pointer; } justify-content: space-between; gap: 12px; padding: 14px; - background: var(--ember-wash); + background: var(--danger-wash); } -.confirm-row span { font: 400 17px var(--font-display); color: var(--ember-soft); } +.confirm-row span { font: 400 17px var(--font-display); color: var(--danger-soft); } .confirm-row div { display: flex; gap: 8px; } .confirm-row button { height: 40px; @@ -526,12 +541,12 @@ button { cursor: pointer; } .confirm-row button:hover { color: var(--paper); } .confirm-row .danger-solid { border: none; - background: var(--ember); - color: var(--ember-ink); + background: var(--danger); + color: var(--danger-ink); font-weight: 600; } /* Archive and finish are reversible, so their confirm asks in grey — only the - irreversible remove gets the ember wash. */ + irreversible remove gets the danger wash. */ .confirm-row.calm { background: var(--ash); } .confirm-row.calm span { color: var(--paper-dim); } .confirm-row .go { @@ -568,7 +583,7 @@ button { cursor: pointer; } } /* Busy: a grey hairline slides across the top of the sheet — deliberately not - ember, which only ever means "new chapter". */ + ember, which on a list screen only ever means "new chapter". */ .card.htmx-request { overflow: hidden; } .card.htmx-request .actions { pointer-events: none; opacity: .5; } .card.htmx-request::before { @@ -590,7 +605,6 @@ button { cursor: pointer; } padding: 40px 20px 48px; } .empty strong { font: 400 20px var(--font-display); color: var(--paper); } -.empty.hot strong { color: var(--ember); } .empty .clear-search { align-self: flex-start; margin-top: 4px; @@ -653,12 +667,12 @@ button { cursor: pointer; } letter-spacing: .16em; outline: none; } -.login-card input:focus { border-bottom-color: var(--ember); } +.login-card input:focus { border-bottom-color: var(--paper); } .login-card .error { margin: 0; min-height: 20px; font: 400 13px/1.4 var(--font-body); - color: var(--ember); + color: var(--danger); } .login-card button { height: 54px; diff --git a/backend/templates/list.html b/backend/templates/list.html index 38ca406..a0efda3 100644 --- a/backend/templates/list.html +++ b/backend/templates/list.html @@ -11,7 +11,7 @@ {{else if eq .Tab "fav"}}
No favourites yet.

Star a series to pin it here.

{{else if eq .Tab "new"}} -
Nothing new.

Every series is caught up to its latest chapter.

+
Nothing new.

Every series is caught up to its latest chapter.

{{else if eq .Tab "archived"}}
Nothing archived.

Shelve a series to park it here — it keeps getting checked for new chapters.

{{else if eq .Tab "finished"}} -- 2.52.0 From 07b075f04aaebf4a9481e86a300ff4feb3a560f9 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Thu, 30 Jul 2026 19:07:05 +0700 Subject: [PATCH 06/13] Make the remove confirm name the stake and take a thumb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one irreversible path was defended with 40px buttons 8px apart and a question — "Remove this?" — that never said what was leaving or what it cost. Confirm buttons go to 46px with a 12px gap (all three rows), and the remove question names the series and the loss. The row wraps, so a long title takes the width it needs instead of overflowing; verified at 320 and 390 in both themes with a 90-character title. Button order is unchanged: the group is right-aligned with Cancel outermost, so the destructive button already sits further from a right thumb's arc than Cancel does, and all three rows keep the same shape. Co-Authored-By: Claude Opus 5 --- backend/static/style.css | 9 ++++++--- backend/templates/card.html | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/static/style.css b/backend/static/style.css index e9595cc..e9028d9 100644 --- a/backend/static/style.css +++ b/backend/static/style.css @@ -524,14 +524,17 @@ button { cursor: pointer; } display: flex; align-items: center; justify-content: space-between; + flex-wrap: wrap; gap: 12px; padding: 14px; background: var(--danger-wash); } -.confirm-row span { font: 400 17px var(--font-display); color: var(--danger-soft); } -.confirm-row div { display: flex; gap: 8px; } +/* The remove question names the series, so it has to be able to take the row + to itself and push the buttons onto their own line. */ +.confirm-row span { flex: 1 1 16ch; font: 400 17px/1.3 var(--font-display); color: var(--danger-soft); } +.confirm-row div { display: flex; flex: none; gap: 12px; margin-left: auto; } .confirm-row button { - height: 40px; + height: 46px; padding: 0 14px; border: 1px solid var(--field-line); background: transparent; diff --git a/backend/templates/card.html b/backend/templates/card.html index 613ff62..f67434e 100644 --- a/backend/templates/card.html +++ b/backend/templates/card.html @@ -122,7 +122,7 @@
{{end}} - {{if .Recent}} -
-

Continue reading

-
-
- {{end}} + {{template "recent" .}}
{{template "list" .}} diff --git a/backend/templates/chrome.html b/backend/templates/chrome.html new file mode 100644 index 0000000..83f54db --- /dev/null +++ b/backend/templates/chrome.html @@ -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"}} +
+

Continue reading

+ +
+{{end}} + +{{define "newcount"}}{{.NewCount}}{{end}} diff --git a/backend/web.go b/backend/web.go index 7ec0d3d..b3368db 100644 --- a/backend/web.go +++ b/backend/web.go @@ -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 @@ -227,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) { @@ -283,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) @@ -301,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 @@ -312,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 @@ -338,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. @@ -374,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 @@ -392,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) } diff --git a/backend/web_test.go b/backend/web_test.go index 90a8648..a37bd0f 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -451,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") @@ -635,8 +641,14 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) { if _, err := store.Upsert(reading); err != nil { t.Fatalf("Upsert: %v", err) } - if strings.Contains(stripOf(t, srv, cfg, "all"), `class="recent"`) { - t.Fatal("strip rendered with no unread chapters anywhere") + // 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) } } @@ -790,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) + } +} -- 2.52.0 From 9158709dc9f08fda48ba1bf4dffc33225f299c65 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Thu, 30 Jul 2026 22:23:14 +0700 Subject: [PATCH 12/13] Gzip the text responses Nothing was compressed: the list page shipped ~123 KB of HTML, CSS and JS in the clear on every cold load, which is the wrong bill for a phone on mobile data. A stdlib middleware compresses the four content types this server actually serves in bulk and leaves woff2 alone, since it is already compressed. Measured on the live server: style.css 21.8 KB -> 6.5, htmx 50.9 -> 16.4, filter.js 7.6 -> 2.9. Co-Authored-By: Claude Opus 5 --- backend/main.go | 2 +- backend/main_test.go | 45 +++++++++++++++++++++++++++++ backend/middleware.go | 66 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/backend/main.go b/backend/main.go index 323fa94..aa8f70d 100644 --- a/backend/main.go +++ b/backend/main.go @@ -179,7 +179,7 @@ func newRouter(store *Store, cfg Config) http.Handler { web.register(mux) } - return withCORS(cfg.AllowedOrigins, guardEmptyUserscriptToken(mux)) + return withCORS(cfg.AllowedOrigins, withGzip(guardEmptyUserscriptToken(mux))) } // guardEmptyUserscriptToken heads off ServeMux's own path-cleaning redirect: diff --git a/backend/main_test.go b/backend/main_test.go index dd87c8e..1d89df6 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -1,8 +1,10 @@ package main import ( + "compress/gzip" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "strings" @@ -196,3 +198,46 @@ func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) { t.Fatalf("stored last_chapter_num = %v, want 12", got.LastChapterNum) } } + +func TestGzipCompressesTextNotFonts(t *testing.T) { + srv, _ := newWebTestServer(t, webConfig()) + + cases := []struct { + path string + want bool + }{ + {"/static/style.css", true}, + {"/static/filter.js", true}, + {"/static/htmx.min.js", true}, + {"/static/fonts/dm-sans-var-latin.woff2", false}, + } + for _, tc := range cases { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + req.Header.Set("Accept-Encoding", "gzip") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("GET %s = %d, want 200", tc.path, rr.Code) + } + got := rr.Header().Get("Content-Encoding") == "gzip" + if got != tc.want { + t.Errorf("GET %s Content-Encoding gzip = %v, want %v", tc.path, got, tc.want) + } + if got { + zr, err := gzip.NewReader(rr.Body) + if err != nil { + t.Fatalf("GET %s: body is not gzip: %v", tc.path, err) + } + if _, err := io.ReadAll(zr); err != nil { + t.Fatalf("GET %s: gzip body did not decode: %v", tc.path, err) + } + } + } + + // A client that does not ask still gets plain bytes. + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/static/style.css", nil)) + if enc := rr.Header().Get("Content-Encoding"); enc != "" { + t.Errorf("Content-Encoding without Accept-Encoding = %q, want empty", enc) + } +} diff --git a/backend/middleware.go b/backend/middleware.go index dad76d9..220e4bf 100644 --- a/backend/middleware.go +++ b/backend/middleware.go @@ -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 -- 2.52.0 From 8fcc4f7e490f7be68cd04f8a6a2326c51ed561d2 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Thu, 30 Jul 2026 22:25:04 +0700 Subject: [PATCH 13/13] Label the chapter field for the number it holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edit panel's only visible text was "Latest known: Ch 30" sitting directly above a field pre-filled with your own progress — so on the one occasion the form is worth opening, when those two numbers differ, the visible label named the wrong one. Correcting a chapter number is this UI's stated primary job. The field gets a real label ("Chapter you're on", tied by for/id, replacing the screen-reader-only aria-label) and "Latest known" moves below it as context. Co-Authored-By: Claude Opus 5 --- backend/templates/card.html | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/templates/card.html b/backend/templates/card.html index 5ef6a3d..4b79804 100644 --- a/backend/templates/card.html +++ b/backend/templates/card.html @@ -89,13 +89,17 @@ 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}}

Latest known: {{.DisplayLatest}}

{{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. */}} +
{{/* max is a fat-finger guard, not a real ceiling — no series is near it. */}} - +
+ {{if .LatestChapter}}

Latest known: {{.DisplayLatest}}

{{end}} {{/* 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 -- 2.52.0