From a736b4afa5cb4f3127a5f3346c73ac79d99805dd Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Thu, 30 Jul 2026 14:28:31 +0700 Subject: [PATCH] Confirm-gate lifecycle actions, cluster the action strip (#11) 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}}
-
-