Work the design critique down: chapter format, colour law, search, strip, a11y #11
@@ -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
|
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
|
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`.
|
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
|
- **Latest-chapter poller:** a ticker goroutine in the same binary re-checks
|
||||||
each bookmarked series' newest published chapter from the backend's own
|
each bookmarked series' newest published chapter from the backend's own
|
||||||
network access, so `latest_chapter` stays fresh when the user is not
|
network access, so `latest_chapter` stays fresh when the user is not
|
||||||
|
|||||||
+48
-17
@@ -25,61 +25,92 @@ function setActiveTab(el) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// The chapter-edit form and the delete confirm row are the two per-card
|
// The chapter-edit form and the archive/finish/remove confirm rows are the
|
||||||
// disclosure panels; only one makes sense open at a time. The button that
|
// per-card disclosure panels; only one makes sense open at a time. The button
|
||||||
// owns an open panel carries .open, which is how the strip shows which cell
|
// that owns an open panel carries .open, which is how the strip shows which
|
||||||
// the panel belongs to.
|
// cell the panel belongs to.
|
||||||
function closeCardPanels(key) {
|
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);
|
var card = document.getElementById("card-" + key);
|
||||||
if (card) {
|
if (!card) return;
|
||||||
|
card.querySelectorAll(".chapter-form, .confirm-row").forEach(function (p) {
|
||||||
|
p.hidden = true;
|
||||||
|
});
|
||||||
card.querySelectorAll(".actions .open").forEach(function (b) {
|
card.querySelectorAll(".actions .open").forEach(function (b) {
|
||||||
b.classList.remove("open");
|
b.classList.remove("open");
|
||||||
|
b.setAttribute("aria-expanded", "false");
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePanel(key, panelId, buttonSelector) {
|
function togglePanel(key, panelId, buttonSelector) {
|
||||||
var panel = document.getElementById(panelId + key);
|
var panel = document.getElementById(panelId);
|
||||||
if (!panel) return null;
|
if (!panel) return null;
|
||||||
var opening = panel.hidden;
|
var opening = panel.hidden;
|
||||||
closeCardPanels(key);
|
closeCardPanels(key);
|
||||||
panel.hidden = !opening;
|
panel.hidden = !opening;
|
||||||
var card = document.getElementById("card-" + key);
|
var card = document.getElementById("card-" + key);
|
||||||
var button = card && card.querySelector(buttonSelector);
|
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;
|
return panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleChapterForm(key) {
|
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();
|
if (form && !form.hidden) form.querySelector("input").focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleConfirmRow(key) {
|
// kind is "archive" | "finish" | "remove" — the panel id and the owning action
|
||||||
togglePanel(key, "confirm-row-", ".actions .remove");
|
// 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
|
// 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
|
// 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.
|
// went wrong. Surface it inline instead of leaving the tap looking ignored.
|
||||||
(function () {
|
(function () {
|
||||||
function showError(elt, message) {
|
function showError(elt, message, linkHref, linkText) {
|
||||||
var card = elt.closest(".card");
|
var card = elt.closest(".card");
|
||||||
var slot = card && card.querySelector(".error-inline");
|
var slot = card && card.querySelector(".error-inline");
|
||||||
if (!slot) return;
|
if (!slot) return;
|
||||||
slot.textContent = message;
|
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;
|
slot.hidden = false;
|
||||||
clearTimeout(slot._hideTimer);
|
clearTimeout(slot._hideTimer);
|
||||||
slot._hideTimer = setTimeout(function () {
|
slot._hideTimer = setTimeout(function () {
|
||||||
slot.hidden = true;
|
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) {
|
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.");
|
showError(e.detail.elt, "Couldn't save — try again.");
|
||||||
});
|
});
|
||||||
document.body.addEventListener("htmx:sendError", function (e) {
|
document.body.addEventListener("htmx:sendError", function (e) {
|
||||||
|
|||||||
+55
-15
@@ -84,6 +84,10 @@
|
|||||||
--hatch-dim: repeating-linear-gradient(135deg, #1b1918 0 5px, #151313 5px 10px);
|
--hatch-dim: repeating-linear-gradient(135deg, #1b1918 0 5px, #151313 5px 10px);
|
||||||
|
|
||||||
--measure: 760px;
|
--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
|
/* 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 barSlide { from { transform: translateX(-100%) } to { transform: translateX(320%) } }
|
||||||
@keyframes sheetIn { from { opacity: 0; transform: translateY(-4px) } to { opacity: 1; transform: none } }
|
@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. */
|
/* One measured column, edges drawn rather than boxed. */
|
||||||
.sheet {
|
.sheet {
|
||||||
@@ -335,12 +341,12 @@ button { cursor: pointer; }
|
|||||||
}
|
}
|
||||||
.card.is-dim { background: var(--dim); }
|
.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 {
|
.cover {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 66px;
|
width: var(--cover-w);
|
||||||
height: 88px;
|
height: calc(var(--cover-w) * 4 / 3);
|
||||||
flex: none;
|
flex: none;
|
||||||
background: var(--hatch);
|
background: var(--hatch);
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -348,7 +354,7 @@ button { cursor: pointer; }
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.cover img { width: 100%; height: 100%; object-fit: cover; }
|
.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; }
|
.is-dim .cover { background: var(--hatch-dim); filter: grayscale(1); opacity: .85; }
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
@@ -428,9 +434,20 @@ button { cursor: pointer; }
|
|||||||
.actions .remove { color: var(--trash); }
|
.actions .remove { color: var(--trash); }
|
||||||
.actions .remove:hover { color: var(--ember); }
|
.actions .remove:hover { color: var(--ember); }
|
||||||
.actions .open { background: var(--hover); color: var(--paper); }
|
.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(--ember-wash); color: var(--ember); }
|
||||||
.is-dim .actions > * { color: var(--mute-2); }
|
.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 ---- */
|
/* ---- disclosure panels ---- */
|
||||||
.chapter-form, .confirm-row, .error-inline { animation: sheetIn .18s ease-out; }
|
.chapter-form, .confirm-row, .error-inline { animation: sheetIn .18s ease-out; }
|
||||||
|
|
||||||
@@ -498,6 +515,17 @@ button { cursor: pointer; }
|
|||||||
color: var(--ember-ink);
|
color: var(--ember-ink);
|
||||||
font-weight: 600;
|
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 {
|
.error-inline {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -505,10 +533,10 @@ button { cursor: pointer; }
|
|||||||
gap: 9px;
|
gap: 9px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 11px 13px;
|
padding: 11px 13px;
|
||||||
background: var(--ember-wash);
|
background: var(--ash);
|
||||||
border-left: 2px solid var(--ember);
|
border-left: 2px solid var(--mute);
|
||||||
font: 400 16px var(--font-display);
|
font: 400 16px var(--font-display);
|
||||||
color: var(--ember-soft);
|
color: var(--paper-dim);
|
||||||
}
|
}
|
||||||
.error-inline::before {
|
.error-inline::before {
|
||||||
content: "";
|
content: "";
|
||||||
@@ -516,11 +544,16 @@ button { cursor: pointer; }
|
|||||||
width: 5px;
|
width: 5px;
|
||||||
height: 5px;
|
height: 5px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--ember);
|
background: var(--mute);
|
||||||
animation: emberPulse 1.4s ease-in-out infinite;
|
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 { overflow: hidden; }
|
||||||
.card.htmx-request .actions { pointer-events: none; opacity: .5; }
|
.card.htmx-request .actions { pointer-events: none; opacity: .5; }
|
||||||
.card.htmx-request::before {
|
.card.htmx-request::before {
|
||||||
@@ -530,7 +563,7 @@ button { cursor: pointer; }
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 30%;
|
width: 30%;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--ember);
|
background: var(--mute);
|
||||||
animation: barSlide 1.15s linear infinite;
|
animation: barSlide 1.15s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,6 +649,7 @@ button { cursor: pointer; }
|
|||||||
|
|
||||||
/* ---- desktop: same measure, actions fold up beside the row ---- */
|
/* ---- desktop: same measure, actions fold up beside the row ---- */
|
||||||
@media (min-width: 720px) {
|
@media (min-width: 720px) {
|
||||||
|
:root { --cover-w: 74px; --row-gap: 20px; }
|
||||||
.topbar { padding: 26px 32px 18px; }
|
.topbar { padding: 26px 32px 18px; }
|
||||||
.brand { font-size: 30px; }
|
.brand { font-size: 30px; }
|
||||||
|
|
||||||
@@ -634,8 +668,7 @@ button { cursor: pointer; }
|
|||||||
.recent-cover { height: 133px; }
|
.recent-cover { height: 133px; }
|
||||||
|
|
||||||
.card { padding: 18px 32px; }
|
.card { padding: 18px 32px; }
|
||||||
.row { flex-wrap: nowrap; align-items: center; gap: 20px; }
|
.row { flex-wrap: nowrap; align-items: center; }
|
||||||
.cover { width: 74px; height: 100px; }
|
|
||||||
.cover .monogram { font-size: 26px; }
|
.cover .monogram { font-size: 26px; }
|
||||||
.title { font-size: 21px; }
|
.title { font-size: 21px; }
|
||||||
|
|
||||||
@@ -646,10 +679,17 @@ button { cursor: pointer; }
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
border: 1px solid var(--rule);
|
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; }
|
.is-new .actions .play { border-color: #3a1d18; }
|
||||||
.actions .on { border-color: #332b14; }
|
.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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
+51
-29
@@ -40,7 +40,7 @@
|
|||||||
title="Continue reading" aria-label="Continue reading">
|
title="Continue reading" aria-label="Continue reading">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg>
|
||||||
</a>
|
</a>
|
||||||
<button class="{{if .Favorite}}on{{end}}"
|
<button class="fav{{if .Favorite}} on{{end}}"
|
||||||
title="{{if .Favorite}}Remove from favourites{{else}}Add to favourites{{end}}"
|
title="{{if .Favorite}}Remove from favourites{{else}}Add to favourites{{end}}"
|
||||||
aria-label="Toggle favourite"
|
aria-label="Toggle favourite"
|
||||||
hx-post="/ui/bookmarks/{{.Key}}/favorite"
|
hx-post="/ui/bookmarks/{{.Key}}/favorite"
|
||||||
@@ -52,38 +52,32 @@
|
|||||||
onclick="toggleChapterForm('{{.Key}}')">
|
onclick="toggleChapterForm('{{.Key}}')">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{{if eq .Status "finished"}}
|
{{/* Restore is a reversal, so it fires straight away; every move *out* of
|
||||||
<button class="restore" title="Restore to reading" aria-label="Restore to reading"
|
the list (archive, finish, remove) goes through a confirm row. */}}
|
||||||
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"reading"}'
|
{{if eq .Status "reading"}}
|
||||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
<button class="lifecycle box" title="Archive" aria-label="Archive"
|
||||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
|
aria-expanded="false" aria-controls="confirm-archive-{{.Key}}"
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
|
onclick="toggleConfirmRow('{{.Key}}', 'archive')">
|
||||||
</button>
|
|
||||||
{{else}}
|
|
||||||
{{if eq .Status "archived"}}
|
|
||||||
<button class="restore" title="Restore to reading" aria-label="Restore to reading"
|
|
||||||
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"reading"}'
|
|
||||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
|
||||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
|
|
||||||
</button>
|
|
||||||
{{else}}
|
|
||||||
<button title="Archive" aria-label="Archive"
|
|
||||||
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"archived"}'
|
|
||||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
|
||||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{{end}}
|
{{else}}
|
||||||
<button title="Mark finished" aria-label="Mark finished"
|
<button class="lifecycle restore" title="Restore to reading" aria-label="Restore to reading"
|
||||||
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"finished"}'
|
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"reading"}'
|
||||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
|
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
{{if ne .Status "finished"}}
|
||||||
|
<button class="lifecycle finish" title="Mark finished" aria-label="Mark finished"
|
||||||
|
aria-expanded="false" aria-controls="confirm-finish-{{.Key}}"
|
||||||
|
onclick="toggleConfirmRow('{{.Key}}', 'finish')">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
<button class="remove" title="Remove" aria-label="Remove"
|
<button class="lifecycle remove" title="Remove" aria-label="Remove"
|
||||||
onclick="toggleConfirmRow('{{.Key}}')">
|
aria-expanded="false" aria-controls="confirm-remove-{{.Key}}"
|
||||||
|
onclick="toggleConfirmRow('{{.Key}}', 'remove')">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,19 +88,47 @@
|
|||||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="input, button">
|
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="input, button">
|
||||||
{{if .LatestChapter}}<p class="hint">Latest known: Ch {{.LatestChapter}}</p>{{end}}
|
{{if .LatestChapter}}<p class="hint">Latest known: Ch {{.LatestChapter}}</p>{{end}}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<input name="chapter" type="number" step="0.1" min="0"
|
{{/* max is a fat-finger guard, not a real ceiling — no series is near it. */}}
|
||||||
|
<input name="chapter" type="number" step="0.1" min="0" max="9999"
|
||||||
value="{{.LastChapterNum}}" aria-label="Chapter number" required>
|
value="{{.LastChapterNum}}" aria-label="Chapter number" required>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="confirm-row" id="confirm-row-{{.Key}}" hidden>
|
{{/* 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
|
||||||
|
opened a second question. */}}
|
||||||
|
{{if eq .Status "reading"}}
|
||||||
|
<div class="confirm-row calm" id="confirm-archive-{{.Key}}" role="group" aria-live="polite" hidden>
|
||||||
|
<span>Archive this?</span>
|
||||||
|
<div>
|
||||||
|
<button class="go"
|
||||||
|
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"archived"}'
|
||||||
|
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||||
|
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Archive</button>
|
||||||
|
<button type="button" onclick="toggleConfirmRow('{{.Key}}', 'archive')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if ne .Status "finished"}}
|
||||||
|
<div class="confirm-row calm" id="confirm-finish-{{.Key}}" role="group" aria-live="polite" hidden>
|
||||||
|
<span>Mark finished?</span>
|
||||||
|
<div>
|
||||||
|
<button class="go"
|
||||||
|
hx-post="/ui/bookmarks/{{.Key}}/status" hx-vals='{"status":"finished"}'
|
||||||
|
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||||
|
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Finish</button>
|
||||||
|
<button type="button" onclick="toggleConfirmRow('{{.Key}}', 'finish')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="confirm-row" id="confirm-remove-{{.Key}}" role="group" aria-live="polite" hidden>
|
||||||
<span>Remove this?</span>
|
<span>Remove this?</span>
|
||||||
<div>
|
<div>
|
||||||
<button class="danger-solid"
|
<button class="danger-solid"
|
||||||
hx-delete="/ui/bookmarks/{{.Key}}"
|
hx-delete="/ui/bookmarks/{{.Key}}"
|
||||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Remove</button>
|
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">Remove</button>
|
||||||
<button type="button" onclick="toggleConfirmRow('{{.Key}}')">Cancel</button>
|
<button type="button" onclick="toggleConfirmRow('{{.Key}}', 'remove')">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="error-inline" hidden></p>
|
<p class="error-inline" hidden></p>
|
||||||
|
|||||||
Reference in New Issue
Block a user