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/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 diff --git a/backend/static/filter.js b/backend/static/filter.js index ee15ec5..40bbb47 100644 --- a/backend/static/filter.js +++ b/backend/static/filter.js @@ -4,82 +4,182 @@ 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 server decides what the strip holds — it ships empty for every tab + // but All, and out of band after every mutation. All this has to do is keep + // it down while a filter is active, since the strip is never filtered and + // would otherwise put non-matching covers above an empty list. + var recent = document.querySelector(".recent"); + if (recent) { + recent.hidden = needle !== "" || !recent.querySelector(".recent-card"); + } + + // 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); + document.addEventListener("mangabm:refilter", applyFilter); })(); function setActiveTab(el) { - el.parentElement.querySelectorAll("[role=tab]").forEach(function (t) { - t.classList.toggle("active", t === el); + el.parentElement.querySelectorAll("a").forEach(function (t) { + var on = t === el; + t.classList.toggle("active", on); + if (on) t.setAttribute("aria-current", "page"); + else t.removeAttribute("aria-current"); + }); + // The strip is outside the swapped region, so its visibility is re-decided + // here rather than by the server that just answered. + document.dispatchEvent(new Event("mangabm:refilter")); +} + +// 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 card = document.getElementById("card-" + key); + 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"); }); } -// 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. -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"); - }); - } -} - 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]; + var row = togglePanel(key, "confirm-" + kind + "-" + key, ".actions " + cls); + // Focus the answer rather than trusting aria-live on a container that merely + // unhides: it makes the announcement deterministic, keeps tab order inside + // the confirm instead of running on into the next card, and means the row + // cannot be opened and scrolled past unnoticed. + // The reversible rows open on their affirmative; remove opens on Cancel. + // Focusing the first button in DOM order would hand the irreversible action + // a pre-armed Enter, which is the opposite of what a confirm gate is for. + if (row && !row.hidden) { + row.querySelector(row.classList.contains("calm") ? "button" : "button + button").focus(); + } } +// Esc closes whichever panel this card has open and hands focus back to the +// cell that owns it — otherwise the only way out is finding that exact cell +// again. +document.addEventListener("keydown", function (e) { + if (e.key !== "Escape" || !e.target.closest) return; + var card = e.target.closest(".card"); + var owner = card && card.querySelector(".actions .open"); + if (!owner) return; + closeCardPanels(card.id.replace(/^card-/, "")); + owner.focus(); +}); + // 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); + // No self-destruct timer: this reader gets interrupted mid-tap, and a + // notice that expires after 5s leaves a failed write with no trace at all + // — the star is back off and nothing says why. The notice stays until the + // next request from this card clears it (or a successful one swaps the + // whole card away). + slot.scrollIntoView({ + block: "nearest", + behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", + }); + } + + document.body.addEventListener("htmx:beforeRequest", function (e) { + var card = e.detail.elt.closest(".card"); + var slot = card && card.querySelector(".error-inline"); + if (slot) slot.hidden = true; + }); + + // 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..ec5b736 100644 --- a/backend/static/style.css +++ b/backend/static/style.css @@ -64,7 +64,9 @@ --paper-hot: #f0d3cb; /* title of a series with a new chapter */ --paper-dim: #ddd5cb; /* resting title */ --mute: #8d857c; /* secondary text, idle icons */ - --mute-2: #5a5450; /* labels, hints */ + /* Every mute-2 use is 10px mono, so it has to clear 4.5:1 on --ink even + though it reads as the quietest step. */ + --mute-2: #877f76; /* labels, hints */ --faint: #3a3733; /* meta separators */ --faint-2: #57504b; /* cover monogram */ @@ -72,8 +74,18 @@ --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: #6b5450; /* remove, resting */ + --trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */ + + /* Desktop cell borders for the two coloured action states. */ + --play-hot-line: #3a1d18; + --fav-line: #332b14; --asura: #7d93a5; --demonic: #a98a78; @@ -84,6 +96,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 @@ -102,7 +118,7 @@ --paper-hot: #a33018; --paper-dim: #191715; --mute: #6b645d; - --mute-2: #857d75; + --mute-2: #6c655e; /* clears 4.5:1 on --ash too, not just --ink */ --faint: #c9c2ba; --faint-2: #a8a098; @@ -110,8 +126,15 @@ --ember-wash: #fbeee9; --ember-ink: #fff; --ember-soft: #8d2c17; + --danger: #97362a; + --danger-wash: #fbe9e5; + --danger-ink: #fff; + --danger-soft: #7c2c22; --brass: #8a681c; - --trash: #a98276; + --trash: #8c6558; + + --play-hot-line: #f0cfc6; + --fav-line: #e3d3a4; --asura: #4f6b80; --demonic: #8a6a55; @@ -140,9 +163,16 @@ button { cursor: pointer; } /* Every hideable thing here is a flex container, and display beats hidden. */ [hidden] { display: none !important; } -@keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(320%) } } +/* Ends flush with the right edge of the card (30% wide × 233% travel), so the + card itself never needs overflow: hidden to contain it. */ +@keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(233%) } } @keyframes sheetIn { from { opacity: 0; transform: translateY(-4px) } to { opacity: 1; transform: none } } -@keyframes emberPulse { 0%, 100% { opacity: .5 } 50% { opacity: 1 } } +/* 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. */ .sheet { @@ -174,6 +204,7 @@ button { cursor: pointer; } .topbar form { margin: 0; } .ghost { + position: relative; padding: 0; border: none; border-bottom: 1px solid var(--field-line); @@ -184,6 +215,8 @@ button { cursor: pointer; } text-transform: uppercase; } .ghost:hover { color: var(--paper); border-bottom-color: var(--paper); } +/* The label is 15px tall by design; the thumb gets 44 without moving it. */ +.ghost::after { content: ""; position: absolute; inset: -15px -12px; } .chrome { display: flex; flex-direction: column; } @@ -196,6 +229,9 @@ button { cursor: pointer; } border-bottom: 1px solid var(--rule); color: var(--mute-2); } +/* The input drops its own outline, so the bar it sits in carries the focus + ring — same move the two other inputs make with their border. */ +.searchbar:focus-within { border-bottom-color: var(--paper); color: var(--paper); } .searchbar svg { width: 15px; height: 15px; flex: none; } .search { flex: 1; @@ -220,18 +256,21 @@ button { cursor: pointer; } border-bottom: 1px solid var(--rule); } .tabs::-webkit-scrollbar { display: none; } -.tabs [role="tab"] { +.tabs a { flex: none; display: flex; align-items: center; + justify-content: center; gap: 7px; + /* "All" is two characters in a 17px serif — 15px of target without this. */ + min-width: 44px; padding: 8px 0 12px; color: var(--mute); font: 400 17px var(--font-display); white-space: nowrap; } -.tabs [role="tab"]:hover { color: var(--paper-dim); } -.tabs [role="tab"].active { +.tabs a:hover { color: var(--paper-dim); } +.tabs a.active { color: var(--paper); border-bottom: 2px solid var(--paper); margin-bottom: -1px; @@ -335,12 +374,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 +387,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 { @@ -426,11 +465,22 @@ 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); } -.actions .remove.open { background: var(--ember-wash); color: var(--ember); } +/* 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(--danger-wash); color: var(--danger); } .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; } @@ -461,7 +511,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 { @@ -477,14 +529,17 @@ button { cursor: pointer; } display: flex; align-items: center; justify-content: space-between; + flex-wrap: wrap; 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 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; @@ -494,10 +549,21 @@ 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 danger 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 +571,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,12 +582,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. */ -.card.htmx-request { overflow: hidden; } +/* Busy: a grey hairline slides across the top of the sheet — deliberately not + ember, which on a list screen only ever means "new chapter". */ .card.htmx-request .actions { pointer-events: none; opacity: .5; } .card.htmx-request::before { content: ""; @@ -530,7 +600,7 @@ button { cursor: pointer; } left: 0; width: 30%; height: 1px; - background: var(--ember); + background: var(--mute); animation: barSlide 1.15s linear infinite; } @@ -542,7 +612,17 @@ 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; + 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; @@ -594,12 +674,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; @@ -616,6 +696,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; } @@ -627,31 +708,40 @@ button { cursor: pointer; } border-bottom: 1px solid var(--rule); } .tabs { order: 1; flex: none; gap: 20px; padding: 0; border-bottom: none; } - .tabs [role="tab"] { padding: 10px 0 14px; font-size: 18px; } + .tabs a { padding: 10px 0 14px; font-size: 18px; } .searchbar { order: 2; flex: 1; margin: 0; border-bottom: none; } .recent h2, .recent-strip { padding-left: 32px; padding-right: 32px; } .recent-card, .recent-cover { width: 100px; } .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; } .actions { flex: none; gap: 4px; border-top: none; } .actions > * { flex: none; - width: 40px; - height: 40px; + width: 44px; + height: 44px; border: 1px solid var(--rule); } - .is-new .actions .play { border-color: #3a1d18; } - .actions .on { border-color: #332b14; } + /* 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: var(--play-hot-line); } + .actions .on { border-color: var(--fav-line); } - .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) { - * { animation: none !important; transition: none !important; } + /* Pseudo-elements need naming explicitly — `*` does not match them, and the + busy bar and error dot are both ::before. Their static form still reads: + the bar stays drawn and .actions stays dimmed. */ + *, *::before, *::after { animation: none !important; transition: none !important; } } 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 a5df111..538d541 100644 --- a/backend/templates/app.html +++ b/backend/templates/app.html @@ -8,7 +8,9 @@