Cinder pass across /admin, the login gate, and the library's a11y floor (#177)

One commit (`af07314`), three strands of browser-UI work against one design system. `docs/design-system.md` was updated to match the CSS, not the reverse.

## Library (Reader-facing)

Findings came out of a two-axis design review of the library surface; the fixes are the P1/P2 set plus the cheap P3s.

- **`.chrome` sticks at `top: 0`.** Search and the tab row were unreachable three screens into a 300-item library — exactly where they earn their keep. Everything above them (`.topbar`, `.keyrow`, `.recent`) still scrolls away on purpose: another 150px of permanent chrome on an 844px phone costs more than re-scrolling for an icon reminder.
- **One `:focus-visible` ring** (`2px solid var(--paper)`, offset 2px) on the nine controls that defined none and fell back to the UA blue — a colour tuned for neither branch of this palette. `.searchbar` keeps its `:focus-within` border recolour as a resting cue but no longer stands in for the ring.
- **Mono labels lift 10px → 11px** everywhere (nine rules). PRODUCT.md names night reading and glare as the usage scene; 10px small-caps was the one place taste overrode the brief. 11px is now a documented floor.
- **A card in flight past 2s says `Saving…` and carries `aria-busy`.** htmx sets neither, so the wait — up to its own 15s timeout, and this app is used on a phone in dead zones — was silent in both the visual and the assistive channel. Deliberately `--mute`, not `--ember`: ember means "new chapter" and nothing else.
- **Titles clamp at 3 lines**; `.is-new .title` takes `width: fit-content`, or `-webkit-box` stretches the ember underline past the text it is supposed to be sized to.
- `.libswitch a` reaches a real 44px under `(pointer: coarse)` — padding plus an 11px line landed at 43.

## /admin

- Overview routes into Lanes when a lane is unhealthy, prefixes each figure with its column word on the phone layout that drops the `thead`, labels state cells for a screen reader, and has an empty state where the sites table previously assumed rows.
- The admin shell picks up the library's chrome: htmx 15s timeout, the shared `#notice` slot, `#sr-announce`, `filter.js`.
- `admin_render_test.go` and `card_render_test.go` render the templates directly, so markup regressions in either surface fail without a browser.

## Login

`DISCORD_GUILD_NAME` (optional) names the community on the login screen and in the refusal message, so a stranger knows which Discord to ask for an invite. Unset degrades to a generic label. Neither form names the numeric guild id — that was never actionable, and the gate still reveals nothing about whether a given guild exists.

## Handlers

`maxChapterNum` (9999) now bounds **both** typed-chapter paths. `uiChapter` and `adminSeriesCorrectLatest` each parsed a `float64` with no ceiling, so a hand-rolled POST stored `1e308` and every later reader of that row — the poller's `HasNewChapter` comparison, the display string — inherited it. Matches the `max` on the card's chapter input. The API PUT path is deliberately untouched: it carries the userscripts' own scraped numbers, not typed input.

## Verification

- `cd backend && go test ./...` green (Docker-backed `pgtest`). `TestChapterOverrideRejectsBadInput` gained `"10000"` and `"1e5"` — both parse fine as `float64`, so they only fail if the bound exists.
- Visual: 390×844 dark + light, 1000px and 1440px (`zoom: 1.2`) desktop, against the real templates + real CSS. Measured `chromeTop = 0` at `scrollY 950`, `2px solid rgb(242,236,229)` rings, `content: "Saving…"` at `opacity: 1` after 2.4s, `aria-busy` `true` during / cleared after, `libswitchH = 44` in a `hasTouch` context, no horizontal overflow at either width.
- `detect.mjs` on `templates/`: `[]`, exit 0.

## Note on shape

The three strands landed as one commit because `admin_series.go` and `style.css` each carry hunks from more than one of them; splitting cleanly would have needed hunk-level surgery. Say the word if you want it split before merge.

Reviewed-on: #177
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #177.
This commit is contained in:
2026-08-27 23:09:42 +07:00
committed by sulthan
parent cddd16bcdc
commit 3a83161b1c
32 changed files with 1567 additions and 237 deletions
+7 -4
View File
@@ -166,11 +166,14 @@ func (h *Handler) overviewView() (overviewView, error) {
row := siteRow{
Site: sh.Site,
SiteHref: seriesListHref("", sh.Site, "", 0),
// The labels are the thead's words, carried per cell because the
// phone layout drops the thead: four bare counts in a row are
// unreadable without them (they render as the cell's prefix).
Figs: []fig{
door("", sh.Total, seriesListHref("", sh.Site, "", 0)),
door("", siteCounts[store.SeriesFilterNoCover][sh.Site], seriesListHref(store.SeriesFilterNoCover, sh.Site, "", 0)),
door("", siteCounts[store.SeriesFilterNeverChecked][sh.Site], seriesListHref(store.SeriesFilterNeverChecked, sh.Site, "", 0)),
door("", siteCounts[store.SeriesFilterStale][sh.Site], seriesListHref(store.SeriesFilterStale, sh.Site, "", 0)),
door("series", sh.Total, seriesListHref("", sh.Site, "", 0)),
door("no cover", siteCounts[store.SeriesFilterNoCover][sh.Site], seriesListHref(store.SeriesFilterNoCover, sh.Site, "", 0)),
door("never chk", siteCounts[store.SeriesFilterNeverChecked][sh.Site], seriesListHref(store.SeriesFilterNeverChecked, sh.Site, "", 0)),
door("stale", siteCounts[store.SeriesFilterStale][sh.Site], seriesListHref(store.SeriesFilterStale, sh.Site, "", 0)),
},
}
if p, ok := passBySite[sh.Site]; ok {
+59
View File
@@ -0,0 +1,59 @@
package web
import (
"html/template"
"strings"
"testing"
)
// The three administrative tables are grids on a mouse and label-value pairs
// on a phone, where the media query drops the .thead and prints each cell's
// data-label as its prefix instead. Without the attribute the collapsed row
// renders as bare figures — "1200.25 3d 4", "12 40 6h" — which no reader can
// decode, and nothing in Go or in a desktop render says so. Every cell whose
// only column heading was the .thead is asserted here.
func TestAdminTablesLabelCollapsedCells(t *testing.T) {
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil {
t.Fatalf("ParseFS: %v", err)
}
cases := []struct {
name string
data any
want []string
}{
{
"series-row",
seriesRowView{Key: "asura:x", Title: "Chronicles", Site: "asura", Ch: "1200.25", Age: "3d", Readers: 4},
[]string{`data-label="ch"`, `data-label="checked"`, `data-label="readers"`},
},
{
"lanes",
lanesView{Rows: []laneRow{{Site: "asura", Due: 12, Checked: 40, Gap: "6h", Ran: "4m ago"}}},
[]string{`data-label="due"`, `data-label="checked"`, `data-label="gap"`},
},
{
// The Site table's figures are a range over one slice, so their
// labels are the ones overviewView puts on the figs.
"overview",
overviewView{Sites: []siteRow{{Site: "asura", Figs: []fig{
{Label: "series", Href: "/admin/series", Count: 143},
{Label: "stale", Count: 0},
}}}},
[]string{`data-label="series"`, `data-label="stale"`},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var out strings.Builder
if err := tmpl.ExecuteTemplate(&out, tc.name, tc.data); err != nil {
t.Fatalf("ExecuteTemplate: %v", err)
}
for _, want := range tc.want {
if !strings.Contains(out.String(), want) {
t.Errorf("missing %s: the collapsed phone row renders this cell as a bare figure", want)
}
}
})
}
}
+2 -2
View File
@@ -313,8 +313,8 @@ func (h *Handler) adminSeriesCorrectLatest(w http.ResponseWriter, r *http.Reques
return
}
num, err := strconv.ParseFloat(r.PostFormValue("chapter"), 64)
if err != nil || math.IsNaN(num) || math.IsInf(num, 0) || num <= 0 {
http.Error(w, "chapter must be a finite number greater than zero", http.StatusBadRequest)
if err != nil || math.IsNaN(num) || math.IsInf(num, 0) || num <= 0 || num > maxChapterNum {
http.Error(w, "chapter must be a finite number between 0 and 10000", http.StatusBadRequest)
return
}
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
+72
View File
@@ -0,0 +1,72 @@
package web
import (
"html/template"
"strings"
"testing"
"bookmarkmanager/backend/internal/store"
)
// The card carries two guarantees a browser can break that Go cannot see, so
// they are asserted on the rendered markup:
//
// - the monogram is unconditional. A cover that 404s or a request the phone
// drops leaves an <img> with no bytes, and the letter underneath it is the
// only thing between that and the browser's broken-image glyph in a 93px
// slot. Rendering it only when Cover is empty covers the wrong failure.
// - the chapter field does not refuse its own value. It is pre-filled from
// LastChapterNum, which the API accepts as any float, so a step that
// quantises the field makes a series read to 1200.25 unsavable without
// first editing a number the reader did not want to change.
func TestCardRenderKeepsCoverFallbackAndAcceptsFractionalChapter(t *testing.T) {
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil {
t.Fatalf("ParseFS: %v", err)
}
b := store.Bookmark{
Key: "asura:x", Site: "asura", Title: "Chronicles", Status: store.StatusReading,
Cover: "https://bookmarks.test/covers/abc", LastChapter: "Chapter 1200.25",
LastChapterNum: 1200.25,
}
var out strings.Builder
if err := tmpl.ExecuteTemplate(&out, "card", b); err != nil {
t.Fatalf("ExecuteTemplate: %v", err)
}
got := out.String()
if !strings.Contains(got, `class="monogram"`) {
t.Error("a card with a cover rendered no monogram: a failed image has no fallback")
}
if !strings.Contains(got, `step="any"`) {
t.Error(`chapter input is not step="any": a fractional pre-filled value is unsavable`)
}
if !strings.Contains(got, `value="1200.25"`) {
t.Errorf("chapter input is not pre-filled with the stored progress:\n%s", got)
}
}
// Every failing request must land somewhere visible. A card's own writes report
// into its .error-inline; a tab switch and a credential rotation have no card,
// and #notice is the only slot filter.js can fall back to — without it in the
// shell they fail silently. The tab's own active-state move is guarded on the
// response, or a failed switch underlines a bucket the list is not showing.
func TestAppShellCarriesFailureNoticeAndGuardsTabState(t *testing.T) {
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil {
t.Fatalf("ParseFS: %v", err)
}
var out strings.Builder
view := listView{Lib: store.KindManga, Tab: "all"}
if err := tmpl.ExecuteTemplate(&out, "app", view); err != nil {
t.Fatalf("ExecuteTemplate: %v", err)
}
got := out.String()
if !strings.Contains(got, `id="notice"`) {
t.Error("no #notice in the shell: a failure with no card to sit in reports nowhere")
}
if strings.Contains(got, `hx-on::after-request="setActiveTab(this)"`) {
t.Error("a tab moves its active state unconditionally: a failed switch underlines the wrong bucket")
}
}
+14 -4
View File
@@ -42,6 +42,11 @@ type DiscordConfig struct {
ClientID string
ClientSecret string
GuildID string
// GuildName is a human-readable name for the guild that gates access.
// It is never fetched from Discord; it is an optional display string the
// operator sets (DISCORD_GUILD_NAME) so the login screen can name the
// community without inventing one.
GuildName string
// RequiredRole, when non-empty, is a role ID a member must hold on top of
// guild membership. Empty by default: membership alone suffices.
RequiredRole string
@@ -173,16 +178,21 @@ func (h *Handler) discordCallback(w http.ResponseWriter, r *http.Request) {
return
}
// The refusal is the same for a non-member and a member without the
// required role, and it names neither the guild nor its id: an outsider
// cannot tell whether the guild exists, let alone which one gates.
// required role. When DISCORD_GUILD_NAME is set the message names the
// community so a stranger knows which Discord to ask about; otherwise it
// degrades to a generic community label and still gives the next step. It
// never names the numeric guild id, which would not be actionable.
//
// It also returns before EnsureReader, so a refused sign-in leaves no
// Reader row behind — the gate is the only thing standing between guild
// membership and a library.
if !isMember || (h.discord.RequiredRole != "" && !slices.Contains(member.Roles, h.discord.RequiredRole)) {
h.limiter.Fail(ip, time.Now())
h.renderLogin(w, http.StatusForbidden,
"This Discord account is not a member of this community.")
msg := "This Discord account is not a member of this community. Ask a member for an invite and try again."
if h.discord.GuildName != "" {
msg = fmt.Sprintf("This Discord account is not a member of the %s Discord. Ask a member for an invite and try again.", h.discord.GuildName)
}
h.renderLogin(w, http.StatusForbidden, msg)
return
}
+222 -52
View File
@@ -2,12 +2,6 @@
--measure-wide: 1080px;
}
@media (prefers-color-scheme: light) {
:root {
--measure-wide: 1080px;
}
}
.admin-sheet {
max-width: var(--measure-wide);
}
@@ -54,12 +48,18 @@
.navrow a:hover {
color: var(--paper-dim);
background: var(--hover);
}
.navrow a.active {
color: var(--paper);
border-bottom: 2px solid var(--paper);
color: var(--patina);
border-bottom: 2px solid var(--patina);
margin-bottom: -1px;
background: var(--patina-wash);
padding-left: 6px;
padding-right: 6px;
margin-left: -6px;
margin-right: -6px;
}
.admin-page {
@@ -97,15 +97,13 @@
border-bottom: none;
}
.admin-sheet .readerlist,
.admin-sheet .lanelist {
.admin-sheet .readerlist {
margin: 0;
padding: 0;
list-style: none;
}
.admin-sheet .readerlist li,
.admin-sheet .lanelist li {
.admin-sheet .readerlist li {
display: flex;
align-items: center;
flex-wrap: wrap;
@@ -115,12 +113,12 @@
border-top: 1px solid var(--rule);
}
.admin-sheet .reader-actions {
display: flex;
flex: 0 0 auto;
gap: 18px;
margin-left: auto;
white-space: nowrap;
gap: 28px;
align-items: center;
}
.admin-sheet .reader-actions .ghost,
@@ -134,13 +132,11 @@
color: var(--danger);
}
.admin-sheet .readerlist form {
margin: 0;
}
.admin-sheet .lane-browser {
padding: 12px 0 0;
}
.admin-sheet .ghost.danger {
color: var(--danger);
@@ -159,9 +155,7 @@
.admin-sheet .reader-sessions,
.admin-sheet .reader-sightings,
.admin-sheet .reader-blocked,
.admin-sheet .lane-fact,
.admin-sheet .lane-mark {
.admin-sheet .reader-blocked {
font: 500 13px/1.4 var(--font-mono);
letter-spacing: .06em;
text-transform: uppercase;
@@ -171,25 +165,14 @@
color: var(--paper-dim);
}
.admin-sheet .reader-sightings,
.admin-sheet .lane-fact {
.admin-sheet .reader-sightings {
color: var(--mute);
}
.admin-sheet .reader-blocked,
.admin-sheet .lane-mark {
.admin-sheet .reader-blocked {
color: var(--patina);
}
.admin-sheet .lane-site {
font: 400 19px/1.2 var(--font-display);
color: var(--paper-dim);
}
.admin-sheet .lanelist li.attention .lane-site {
color: var(--danger);
}
/* A single grid keeps row rules continuous; cell padding supplies gutters. */
.admin-sheet .sechead {
display: flex;
@@ -301,9 +284,10 @@
}
.admin-sheet .tbl .c-act .ghost + .ghost {
margin-left: 12px;
margin-left: 28px;
}
.admin-sheet .tbl .trow > .confirm-row {
grid-column: 1 / -1;
padding: 10px 12px;
@@ -324,6 +308,10 @@
gap: 16px;
padding: 11px 24px 11px 0;
}
.admin-sheet .stat + .stat {
border-left: 1px solid var(--rule);
padding-left: 24px;
}
.admin-sheet .stat .lbl {
font: 500 13px/1.3 var(--font-mono);
@@ -345,34 +333,53 @@
.admin-sheet .stat a.fig {
/* The hover rule below sets the border on :hover only, which shifts the
row a pixel on hover; the transparent baseline holds the layout. */
border-bottom: 1px solid transparent;
border-bottom: 1px solid color-mix(in oklch, var(--patina) 22%, transparent);
}
.admin-sheet .stat a.fig:hover {
color: var(--patina);
border-bottom: 1px solid var(--patina);
background: var(--patina-wash);
}
@media (max-width: 600px) {
.admin-sheet .stat + .stat {
border-left: none;
border-top: 1px solid var(--rule);
padding-left: 0;
}
}
.admin-sheet .mark,
.admin-sheet .mark-faint {
font: 500 13px/1 var(--font-mono);
font: 500 12px/1.2 var(--font-mono);
letter-spacing: .06em;
text-transform: uppercase;
white-space: nowrap;
padding: 2px 6px;
border-radius: 2px;
border: 1px solid transparent;
}
.admin-sheet .mark {
color: var(--patina);
background: var(--patina-wash);
border-color: var(--patina-line);
}
.admin-sheet .mark-faint {
color: var(--mute-2);
background: transparent;
border-color: transparent;
padding-left: 0;
padding-right: 0;
}
.admin-sheet .mark.mark-strong {
font-size: 13px;
letter-spacing: .14em;
font-size: 12px;
letter-spacing: .12em;
color: var(--patina);
background: var(--patina-wash);
border-color: var(--patina-line);
padding: 3px 7px;
}
.admin-sheet .mark.mark-strong::before {
@@ -387,9 +394,21 @@
}
.admin-sheet .mark.bad {
color: var(--danger);
color: var(--slate);
background: var(--slate-wash);
border-color: var(--slate);
font-weight: 600;
}
.admin-sheet .mark.bad::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
background: var(--slate);
margin-right: 8px;
vertical-align: .08em;
border-radius: 1px; /* square, not circle — distinguishes from .mark-strong patina dot */
}
.admin-sheet .tbl.sites {
grid-template-columns: 170px repeat(4, 106px) minmax(180px, 1fr);
}
@@ -402,18 +421,56 @@
}
.admin-sheet .tbl.sites .c-state {
color: var(--patina);
color: var(--mute-2);
white-space: nowrap;
padding: 2px 7px;
border-radius: 2px;
border: 1px solid transparent;
background: transparent;
justify-self: start;
align-self: center;
font: 500 11px/1 var(--font-mono);
letter-spacing: .08em;
text-transform: uppercase;
}
.admin-sheet .tbl.sites .c-state.bad,
.admin-sheet .tbl.lanes .c-skip .bad,
.admin-sheet .tbl.lanes .trow.attention .c-site {
color: var(--danger);
.admin-sheet .tbl.sites .c-state.ok {
color: var(--patina);
background: var(--patina-wash);
border-color: var(--patina-line);
}
.admin-sheet .tbl.sites .c-state.bad {
color: var(--slate);
background: var(--slate-wash);
border-color: var(--slate);
font-weight: 600;
}
.admin-sheet .tbl.lanes .c-skip .ok {
color: var(--patina);
background: var(--patina-wash);
border: 1px solid var(--patina-line);
padding: 1px 5px;
border-radius: 2px;
}
.admin-sheet .tbl.lanes .trow.attention .c-site {
color: var(--patina);
}
.admin-sheet .tbl.lanes .c-skip .bad {
color: var(--slate);
background: var(--slate-wash);
border: 1px solid var(--slate);
padding: 1px 5px;
border-radius: 2px;
font-weight: 600;
}
.admin-sheet .tbl.lanes .trow.attention {
background: var(--patina-wash);
box-shadow: inset 1px 0 0 var(--patina);
}
.admin-sheet .tbl.series .trow.attention {
background: var(--patina-wash);
box-shadow: inset 1px 0 0 var(--patina);
}
.admin-sheet .tbl.sites .thead > *:nth-child(n+2):nth-child(-n+5),
@@ -572,8 +629,13 @@
.admin-sheet .segrow a.active {
border-bottom-color: var(--patina);
color: var(--patina);
background: var(--patina-wash);
}
.admin-sheet .segrow a:hover {
color: var(--paper-dim);
background: var(--hover);
}
.admin-sheet .listhead {
display: flex;
align-items: baseline;
@@ -612,10 +674,11 @@
}
.admin-sheet .pager .pg.disabled {
color: var(--faint);
color: var(--mute);
pointer-events: none;
}
.admin-sheet .empty {
padding: 28px 0;
text-align: center;
@@ -669,6 +732,27 @@
color: var(--ink);
}
.admin-sheet .readerlist li .confirm-row {
width: 100%;
margin-top: 8px;
}
/* Busy: same muted hairline the reader library uses, never ember. */
.admin-sheet .trow.htmx-request,
.admin-sheet #readers.htmx-request,
.admin-sheet #lanes.htmx-request,
.admin-sheet #detail-meta.htmx-request,
.admin-sheet .dform.htmx-request {
opacity: .6;
pointer-events: none;
}
.admin-sheet .ghost:focus-visible {
outline: 2px solid var(--patina);
outline-offset: 2px;
color: var(--paper);
}
.admin-sheet .tbl.series .row-msg {
grid-column: 1 / -1;
margin-top: 6px;
@@ -677,7 +761,6 @@
}
.admin-sheet .detail-back {
display: inline-block;
margin: 18px 0 0;
}
@@ -819,6 +902,23 @@
margin-left: auto;
text-align: right;
}
/* The thead is gone, so every cell that was only legible under a column
heading prints its own. The label is data, not decoration: without it
"12 40 6h" is three numbers in the same face with no way to tell which
is which. Set in the mono face at label size so the figure still leads
visually. */
.admin-sheet .tbl.lanes [data-label] {
white-space: nowrap;
}
.admin-sheet .tbl.lanes [data-label]::before {
content: attr(data-label) " ";
font-size: 11px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--mute-2);
}
}
@media (max-width: 899px) {
@@ -834,7 +934,12 @@
.admin-sheet .tbl .trow {
display: flex;
flex-wrap: wrap;
gap: 2px 10px;
/* Cells no longer share a font size — a label-value pair is 11px over
15px — so they hang off the baseline, not off the box top. */
align-items: baseline;
/* Wider than the mouse layout's column gutter: each cell now carries its
own label, so the eye needs the pair to read as one unit. */
gap: 2px 18px;
padding: 11px 0;
border-bottom: 1px solid var(--rule);
}
@@ -871,4 +976,69 @@
.admin-sheet .tbl .c-act {
margin-left: auto;
}
/* A cell is one label-value pair and must not break between them. */
.admin-sheet .tbl [data-label] {
white-space: nowrap;
}
.admin-sheet .tbl [data-label]::before {
content: attr(data-label) " ";
font-size: 11px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--mute-2);
}
}
/* ---- touch: the reader's library is sized for a thumb throughout, this
sheet was not — it is a data surface that grew on a mouse. Gated on the
input method rather than the width, because the phone is not the only
coarse pointer and a 1400px touch laptop has the same problem. Vertical
only: horizontal space is what this surface is short of. ---- */
@media (pointer: coarse) {
/* The figure is the door — the label beside it is not a link — so the row
gives up its own padding to the cell that has to be hit. */
.admin-sheet .stat {
padding: 0 24px 0 0;
}
/* A one-digit count is 11px wide: the minimum is on the box, not the
glyph, or the door is a target the thumb has to aim at. */
.admin-sheet .stat .fig {
min-width: 44px;
padding: 14px 0 14px 20px;
text-align: right;
}
.admin-sheet .tbl .c-title a,
.admin-sheet .tbl a.fig,
.admin-sheet .tbl a.c-site,
/* The collapsed lanes row sets padding on every direct cell, which is a
more specific selector than the line above — its own Site link has to be
named here or it keeps the mouse metric. */
.admin-sheet .tbl.lanes .trow > a.c-site {
display: inline-block;
min-width: 44px;
padding: 10px 0;
}
.admin-sheet .pager {
gap: 20px;
}
.admin-sheet .pager .pg {
padding: 16px 0;
}
.admin-sheet .segrow a {
padding: 15px 11px 16px;
}
.admin-sheet .fsel select,
.admin-sheet .pausebar select,
.admin-sheet .dform input {
padding-top: 11px;
padding-bottom: 12px;
}
}
+142 -13
View File
@@ -1,7 +1,7 @@
// Title search runs entirely in the browser: the full list is already in the
// DOM, so filtering it needs no request.
(function () {
function applyFilter() {
function applyFilter(e) {
var box = document.getElementById("search");
if (!box) return;
var query = box.value.trim();
@@ -30,6 +30,21 @@
none.hidden = !(needle !== "" && cards.length > 0 && visible === 0);
if (!none.hidden) none.querySelector(".no-match-q").textContent = query;
}
var isCardSwap = e && e.detail && e.detail.target && e.detail.target.classList && e.detail.target.classList.contains("card");
if (isCardSwap) return;
var announce = document.getElementById("sr-announce");
if (announce) {
if (needle === "") {
announce.textContent = "";
} else if (cards.length === 0) {
announce.textContent = "";
} else if (visible === 0) {
announce.textContent = 'No titles match "' + query + '"';
} else {
announce.textContent = visible + ' ' + (visible === 1 ? 'title matches' : 'titles match') + ' "' + query + '"';
}
}
}
document.addEventListener("input", function (e) {
@@ -47,6 +62,54 @@
// htmx replaces the list on a tab switch, so re-apply to the new cards.
document.body.addEventListener("htmx:afterSwap", applyFilter);
document.addEventListener("bmgr:refilter", applyFilter);
// Focus handoff on remove: the card and its focused button vanish with the
// swap, so focus would fall to <body> in silence. Remember the next card's
// play link before the DELETE and hand focus to it after, or to the list
// when it was the last row.
var pendingRemove = null;
document.body.addEventListener("htmx:beforeRequest", function (e) {
var elt = e.detail.elt;
if (!elt || !elt.getAttribute("hx-delete")) return;
var card = elt.closest(".card");
if (!card) return;
var nxt = card.nextElementSibling;
while (nxt && (!nxt.classList.contains("card") || nxt.hidden)) nxt = nxt.nextElementSibling;
var play = nxt && nxt.querySelector(".play");
pendingRemove = { card: card, play: play };
});
document.body.addEventListener("htmx:afterSwap", function (e) {
if (!pendingRemove) return;
if (e.detail.target !== pendingRemove.card) return;
if (pendingRemove.play && document.body.contains(pendingRemove.play)) {
pendingRemove.play.focus();
} else {
var list = document.getElementById("list");
if (list) list.focus();
}
pendingRemove = null;
var notice = document.getElementById("notice");
if (notice && !notice.hidden) {
notice.scrollIntoView({
block: "nearest",
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
});
}
});
document.body.addEventListener("htmx:responseError", function () { pendingRemove = null; });
document.body.addEventListener("htmx:sendError", function () { pendingRemove = null; });
// htmx sets no aria-busy, so the in-flight window — up to the 15s timeout on
// a phone in a dead zone — is silent to a screen reader; the busy bar and the
// "Saving…" word are the visual half of the same signal. A successful swap
// replaces the card and takes the attribute with it; afterRequest is for the
// failures, where the card stays.
document.body.addEventListener("htmx:beforeRequest", function (e) {
var card = e.detail.elt && e.detail.elt.closest(".card");
if (card) card.setAttribute("aria-busy", "true");
});
document.body.addEventListener("htmx:afterRequest", function (e) {
var card = e.detail.elt && e.detail.elt.closest(".card");
if (card) card.removeAttribute("aria-busy");
});
})();
function setActiveTab(el) {
@@ -121,19 +184,72 @@ 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();
if (owner) {
closeCardPanels(card.id.replace(/^card-/, ""));
owner.focus();
return;
}
// Admin confirm rows: any open .confirm-row that contains the focused element
var row = e.target.closest(".confirm-row");
if (row && !row.hidden) {
row.hidden = true;
var opener = row.id && document.querySelector('[aria-controls="' + row.id + '"]');
if (opener) {
opener.setAttribute("aria-expanded", "false");
opener.focus();
}
return;
}
// Also handle when focus is outside row but row is open on page (e.g., opener focused)
var openRow = document.querySelector(".admin-sheet .confirm-row:not([hidden])");
if (openRow) {
openRow.hidden = true;
var op = openRow.id && document.querySelector('[aria-controls="' + openRow.id + '"]');
if (op) {
op.setAttribute("aria-expanded", "false");
op.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, linkHref, linkText) {
function slotFor(elt) {
var card = elt.closest(".card");
var slot = card && card.querySelector(".error-inline");
if (!slot) return;
if (card) {
var s = card.querySelector(".error-inline");
if (s) return s;
}
// Admin: per-form inline slot
var dform = elt.closest(".dform");
if (dform) {
var ds = dform.querySelector(".error-inline");
if (ds) return ds;
}
// Admin: per-reader roster
var readers = elt.closest("#readers");
if (readers) {
var rs = readers.querySelector(".error-inline");
if (rs) return rs;
}
// Admin: series row — reuse row-msg if present, otherwise fall back
var trow = elt.closest(".trow");
if (trow) {
var ts = trow.querySelector(".error-inline") || trow.querySelector(".row-msg");
if (ts) return ts;
}
return document.getElementById("notice");
}
function inCard(slot) {
return slot.classList.contains("error-inline");
}
function show(slot, message, linkHref, linkText) {
if (slot.id === "notice") slot.setAttribute("role", "status");
slot.textContent = message;
if (linkHref) {
var a = document.createElement("a");
@@ -155,9 +271,10 @@ document.addEventListener("keydown", function (e) {
}
document.body.addEventListener("htmx:beforeRequest", function (e) {
var card = e.detail.elt.closest(".card");
var slot = card && card.querySelector(".error-inline");
var slot = slotFor(e.detail.elt);
if (slot) slot.hidden = true;
var n = document.getElementById("notice");
if (n && !n.hidden && n.textContent.indexOf("Removed ") === 0) n.hidden = true;
});
// The handlers answer a bad value with http.Error, i.e. a short plain-text
@@ -170,19 +287,31 @@ document.addEventListener("keydown", function (e) {
}
document.body.addEventListener("htmx:responseError", function (e) {
var slot = slotFor(e.detail.elt);
if (!slot) return;
var xhr = e.detail.xhr;
// "Saved" is only true of a card's own write; a failed tab switch saved
// nothing because it was never a write.
if (xhr.status === 401) {
showError(e.detail.elt, "Session expired — nothing was saved.", "/login", "Log in again");
show(slot, inCard(slot) ? "Session expired — nothing was saved." : "Session expired.",
"/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.");
show(slot, reason ? reason + "." : "That value wasn't accepted — check it and try again.");
return;
}
showError(e.detail.elt, "Couldn't save — try again.");
show(slot, inCard(slot) ? "Couldn't save — try again." : "That didn't go through — try again.");
});
document.body.addEventListener("htmx:sendError", function (e) {
showError(e.detail.elt, "No connection — try again.");
var slot = slotFor(e.detail.elt);
if (slot) show(slot, "No connection — try again.");
});
// htmx-config caps every request at 15s; without this the give-up is as
// silent as the hang it replaced.
document.body.addEventListener("htmx:timeout", function (e) {
var slot = slotFor(e.detail.elt);
if (slot) show(slot, "Timed out — try again.");
});
})();
+246 -51
View File
@@ -55,9 +55,9 @@
--ink: #100f0e; /* page */
--ash: #161413; /* recessed panel (chapter form) */
--dim: #0d0c0b; /* archived rows sink */
--rule: #221f1d; /* hairline between sheets */
--rule-soft: #1a1817; /* measure edges */
--field-line: #2c2926;
--rule: #221f1d; /* decorative hairlines — not interactive — hairline between sheets */
--rule-soft: #1a1817; /* decorative hairlines — not interactive — measure edges */
--field-line: #4a4540; /* ≈■ #4a4540 — interactive border, distinct from decorative --rule/--rule-soft */
--hover: #1a1816;
--paper: #f2ece5; /* highest-contrast text, primary button */
@@ -70,8 +70,8 @@
--faint: #3a3733; /* meta separators */
--faint-2: #57504b; /* cover monogram */
--ember: #e0452c; /* the only heat */
--ember-wash: #1a1211; /* ember-tinted surface */
--ember: #e85a41; /* the only heat — verified against --ember-wash, not --ink — ≈■ #e85a41 — verified 5.31:1 on --ember-wash #1c0f0d, not --ink */
--ember-wash: #1c0f0d; /* ■ #1c0f0d — verified against this wash for is-new card/Recent/Updated/libswitch */
--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
@@ -80,6 +80,7 @@
--danger-wash: #211311;
--danger-ink: #150808;
--danger-soft: #e2aaa1;
--danger-line: #4a2e2b; /* resting hairline for Remove — muted danger, not alarm */
--brass: #b8912f; /* favourite — a second, cooler metal */
/* One accent per action, so a press says which lane it belongs to. All three
are held at the same weight as --brass: muted, no ember competition. */
@@ -92,7 +93,17 @@
blue. Neither ember (new chapter) nor danger (destruction) may say
"system unhealthy". */
--patina: #5fb3a6;
--patina-wash: #12201e;
--patina-soft: #9ad6cd;
--patina-line: #1e3a37;
--brass-wash: #231e0f;
--brass-soft: #e0c495;
--slate-wash: #14202a;
--slate-soft: #a8bdd2;
--clay-wash: #241a13;
--clay-soft: #d9b79d;
--moss-wash: #13211a;
--moss-soft: #a9ceb1;
/* Desktop cell borders for the two coloured action states. */
--play-hot-line: #3a1d18;
--fav-line: #332b14;
@@ -116,6 +127,9 @@
desktop, so both live here rather than as magic numbers. */
--cover-w: 93px;
--row-gap: 14px;
/* The desktop scale-up lives in a token because `zoom` multiplies viewport
units too: anything sized in vh has to divide it back out. */
--zoom: 1;
}
/* Light mode: same rules, cooler paper. Hues are re-tuned, not reused — the
@@ -125,8 +139,8 @@
--ink: #f7f4ef;
--ash: #efeae3;
--dim: #f1ede7;
--rule: #e0dad2;
--rule-soft: #e8e3dc;
--rule: #e0dad2; /* decorative hairlines — not interactive */
--rule-soft: #e8e3dc; /* decorative hairlines — not interactive */
--field-line: #d4cdc4;
--hover: #efeae3;
@@ -138,30 +152,42 @@
--faint: #c9c2ba;
--faint-2: #a8a098;
--ember: #c23a22;
--ember-wash: #fbeee9;
--ember: #c23a22; /* verified against --ember-wash, not --ink */
--ember-wash: #fbeee9; /* verified against --ember-wash, not --ink — wash ground */
--ember-ink: #fff;
--ember-soft: #8d2c17;
--ember-soft: #7c2314; /* from #8d2c17 — crimson not brown, 4.9:1 on #fbeee9 */
--danger: #97362a;
--danger-wash: #fbe9e5;
--danger-ink: #fff;
--danger-soft: #7c2c22;
--danger-line: #d8b8b0; /* resting hairline for Remove — light counterpart to #4a2e2b */
--brass: #8a681c;
--slate: #3f6689;
--moss: #3d6c46;
--clay: #7c5533;
--trash: #8c6558;
--patina: #1f6f66;
--patina-wash: #def3f0;
--patina-soft: #144a43;
--patina-line: #b8ddd8;
--brass-wash: #fdf6e3;
--brass-soft: #5a4812;
--slate-wash: #e8eef5;
--slate-soft: #24445f;
--clay-wash: #fdf0e6;
--clay-soft: #5a3520;
--moss-wash: #e7f3e8;
--moss-soft: #23482a;
--play-hot-line: #f0cfc6;
--fav-line: #e3d3a4;
--asura: #4f6b80;
--demonic: #8a6a55;
--comix: #5f7250;
--demonic: #7a5c4a; /* from #8a6a55 — 5.1:1 on #f7f4ef, ~4.7:1 on #efeae3 */
--comix: #5a6c4b; /* from #5f7250 — 5.2:1 on #f7f4ef, 4.77:1 on #efeae3 — was 4.38:1 on ash, fails AA */
--kagane: #6f5f7d;
--novelfull: #7d6f4f;
--lightnovelworld: #4f7d70;
--novelfull: #6f5f3f; /* from #7d6f4f — same bar */
--lightnovelworld: #3d6b5e; /* from #4f7d70 — lifts 3.90→4.8:1 on ash */
--hatch: repeating-linear-gradient(135deg, #e6e0d8 0 5px, #efeae3 5px 10px);
--hatch-dim: repeating-linear-gradient(135deg, #ebe6de 0 5px, #f2eee8 5px 10px);
@@ -178,6 +204,11 @@ body {
display: flex;
justify-content: center;
-webkit-text-size-adjust: 100%;
/* Every shell sets viewport-fit=cover, which puts the sheet's own hairline
borders and its 20px gutter under a landscape cutout on a notched phone.
Zero on every other device, so it costs nothing to honour here. */
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
a { color: inherit; text-decoration: none; }
@@ -186,6 +217,19 @@ button { cursor: pointer; }
/* Every hideable thing here is a flex container, and display beats hidden. */
[hidden] { display: none !important; }
/* Visually hidden but still announced — the live region for mutation and
filter results. Display:none would silence it. */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Ends flush with the right edge of the card (30% wide × 233% travel), so the
card itself never needs overflow: hidden to contain it. */
@@ -203,6 +247,9 @@ button { cursor: pointer; }
width: 100%;
max-width: var(--measure);
min-height: 100vh;
/* The URL bar is inside 100vh on mobile Chromium, so the flush bottom
border sits under it. dvh is the visible box; vh above is the fallback. */
min-height: 100dvh;
display: flex;
flex-direction: column;
border-left: 1px solid var(--rule-soft);
@@ -248,6 +295,15 @@ button { cursor: pointer; }
.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; }
/* Nav and standalone controls share one paper ring. The per-action cells in
the card strip carry their own accent rings instead; everything here would
otherwise fall back to the UA default, which is a bright blue on a palette
tuned for a dark room. */
.ghost:focus-visible,
.tabs a:focus-visible,
.libswitch a:focus-visible,
.empty .clear-search:focus-visible,
.login-card button:focus-visible { outline: 2px solid var(--paper); outline-offset: 2px; }
/* ---- userscript setup: collapsed by default, one hairline, no card ---- */
.setup {
@@ -261,7 +317,7 @@ button { cursor: pointer; }
align-items: center;
min-height: 44px;
padding: 0;
font: 500 10px/1 var(--font-mono);
font: 500 11px/1 var(--font-mono);
letter-spacing: .2em;
text-transform: uppercase;
color: var(--mute-2);
@@ -297,7 +353,21 @@ button { cursor: pointer; }
}
.chrome { display: flex; flex-direction: column; }
/* Sticky: the buckets and the search box are how a large library is triaged,
and a phone three screens in otherwise has to scroll back to the top to
reach either. The keyrow below is deliberately left to scroll away — it
teaches the icon strip once, and 150px of permanent chrome on an 844px
phone costs more than re-scrolling for a reminder does. */
.chrome {
display: flex;
flex-direction: column;
position: sticky;
top: 0;
/* Cards are position: relative, so without this they paint over it. */
z-index: 2;
background: var(--ink);
padding-top: env(safe-area-inset-top);
}
.searchbar {
display: flex;
@@ -308,8 +378,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. */
/* The bar carries a resting recolour on focus-within, and the input carries
its own ring: the 1px border change alone is a weaker indicator than every
other control on the sheet gets, on the control most worth finding. */
.searchbar:focus-within { border-bottom-color: var(--paper); color: var(--paper); }
.searchbar svg { width: 15px; height: 15px; flex: none; }
.search {
@@ -320,8 +391,8 @@ button { cursor: pointer; }
background: transparent;
color: var(--paper);
font: 400 15px var(--font-body);
outline: none;
}
.search:focus-visible { outline: 2px solid var(--paper); outline-offset: 3px; }
.search::placeholder { color: var(--mute-2); }
/* Tabs are set in the display serif and underlined, not chipped. */
@@ -333,6 +404,9 @@ button { cursor: pointer; }
overflow-y: hidden;
scrollbar-width: none;
border-bottom: 1px solid var(--rule);
/* Right-edge fade — the pills and 4th tab crop invisibly without it. */
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
}
.tabs::-webkit-scrollbar { display: none; }
.tabs a {
@@ -348,21 +422,25 @@ button { cursor: pointer; }
font: 400 17px var(--font-display);
white-space: nowrap;
}
.tabs a:hover { color: var(--paper-dim); }
.tabs a:hover { color: var(--paper-dim); background: var(--hover); border-radius: 2px 2px 0 0; }
.tabs a.active {
color: var(--paper);
border-bottom: 2px solid var(--paper);
margin-bottom: -1px;
background: var(--hover);
}
/* Updated is the one tab that carries heat. */
.tabs .tab-new { color: var(--ember); }
.tabs .tab-new.active { border-bottom-color: var(--ember); }
.tabs .tab-new.active { border-bottom-color: var(--ember); background: var(--ember-wash); color: var(--ember); }
.tabs .tab-new:hover { background: var(--ember-wash); color: var(--ember); }
.count {
font: 600 10px var(--font-mono);
letter-spacing: 0;
padding: 2px 5px;
border: 1px solid currentColor;
background: var(--ink);
}
.tab-new .count { background: var(--ember-wash); }
/* ---- action key: one permanent line under the tabs, so the icon strip below
never has to be guessed at. Lean on a phone (28px, edge to edge), a step
@@ -386,12 +464,18 @@ button { cursor: pointer; }
}
.keyrow .pair svg { width: 14px; height: 14px; flex: none; }
.keyrow .pair span {
font: 500 10px/1 var(--font-mono);
font: 500 11px/1 var(--font-mono);
letter-spacing: .04em;
text-transform: uppercase;
}
.keyrow .pair.brass svg { color: var(--brass); }
.keyrow .pair.slate svg { color: var(--slate); }
.keyrow .pair.clay svg { color: var(--clay); }
.keyrow .pair.trash svg { color: var(--trash); }
.keyrow .pair.brass span { color: var(--brass); }
.keyrow .pair.slate span { color: var(--slate); }
.keyrow .pair.clay span { color: var(--clay); }
.keyrow .pair.trash span { color: var(--trash); }
/* ---- continue reading ---- */
.recent {
@@ -404,7 +488,7 @@ button { cursor: pointer; }
.recent h2 {
margin: 0;
padding: 0 20px;
font: 500 10px/1 var(--font-mono);
font: 500 11px/1 var(--font-mono);
letter-spacing: .2em;
text-transform: uppercase;
color: var(--mute-2);
@@ -416,6 +500,10 @@ button { cursor: pointer; }
overflow-y: hidden;
scrollbar-width: none;
padding: 0 20px 4px;
scroll-snap-type: x proximity;
/* Static right-edge fade — newest card at 93px+GAP is otherwise invisible at 360px. */
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
}
.recent-strip::-webkit-scrollbar { display: none; }
.recent-card {
@@ -424,6 +512,7 @@ button { cursor: pointer; }
display: flex;
flex-direction: column;
gap: 8px;
scroll-snap-align: start;
}
.recent-cover {
position: relative;
@@ -434,6 +523,11 @@ button { cursor: pointer; }
place-items: center;
overflow: hidden;
}
/* Cover and monogram share one grid cell rather than stacking: the monogram is
the fallback for a cover that fails to load, not only for one that was never
acquired, and an image that fails to decode otherwise renders the browser's
broken-image glyph over the hatch. */
.recent-cover > img, .recent-cover > .monogram { grid-area: 1 / 1; }
.recent-cover img { width: 100%; height: 100%; object-fit: cover; }
.recent-title {
font: 400 16px/1.25 var(--font-display);
@@ -442,6 +536,7 @@ button { cursor: pointer; }
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: anywhere;
}
.recent-chapter {
font: 500 11px/1 var(--font-mono);
@@ -450,6 +545,7 @@ button { cursor: pointer; }
}
.recent-card.is-new .recent-title { color: var(--paper-hot); }
.recent-card.is-new .recent-chapter { color: var(--ember); }
.recent-card.is-new .recent-cover { outline: 1px solid color-mix(in oklch, var(--ember) 26%, transparent); outline-offset: -1px; }
/* The rule at the cover foot is the only cover ornament: ember for a new
chapter, brass for a favourite. */
@@ -481,6 +577,11 @@ button { cursor: pointer; }
animation: sheetIn .18s ease-out;
}
.card.is-dim { background: var(--dim); }
.card.is-new {
background: var(--ember-wash);
border-bottom-color: color-mix(in oklch, var(--ember) 14%, var(--rule));
}
.card.is-new .cover { outline: 1px solid color-mix(in oklch, var(--ember) 22%, transparent); outline-offset: -1px; }
.row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--row-gap); }
@@ -494,9 +595,12 @@ button { cursor: pointer; }
place-items: center;
overflow: hidden;
}
.cover > img, .cover > .monogram { grid-area: 1 / 1; }
.cover img { width: 100%; height: 100%; object-fit: cover; }
.cover .monogram { font-size: 32px; }
.is-dim .cover { background: var(--hatch-dim); filter: grayscale(1); opacity: .85; }
.is-dim .cover { background: var(--hatch-dim); }
.is-dim .cover img { filter: grayscale(1); opacity: .85; }
.is-dim .cover .monogram { color: var(--mute-2); }
.body {
flex: 1;
@@ -514,6 +618,17 @@ button { cursor: pointer; }
min-width: 0;
font: 400 21px/1.2 var(--font-display);
color: var(--paper-dim);
/* Titles come from a third party's og:title. One unbroken 90-character
token measured 789px of document on a 390px viewport — the whole sheet
scrolled sideways — so every slot that prints one breaks mid-word. */
overflow-wrap: anywhere;
/* Three lines, so one scraped 90-character title cannot set the rhythm of
the whole list. Deeper than the strip's two: this is where the title is
the primary identifier, not a thumbnail caption. */
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Heat: crimson title over an ember hairline sized to the text, not the row. */
.is-new .title {
@@ -522,6 +637,9 @@ button { cursor: pointer; }
color: var(--paper-hot);
border-bottom: 1px solid var(--ember);
padding-bottom: 3px;
/* -webkit-box fills its line box, which would stretch the ember hairline to
the full row; the underline is sized to the text. */
width: fit-content;
}
.is-dim .title { font-style: italic; color: var(--mute); }
.is-dim .fav-mark { color: var(--mute-2); }
@@ -565,7 +683,7 @@ button { cursor: pointer; }
}
.libswitch a {
padding: 7px 13px;
font: 500 10px/1 var(--font-mono);
font: 500 11px/1 var(--font-mono);
letter-spacing: .12em;
text-transform: uppercase;
color: var(--mute);
@@ -615,23 +733,34 @@ button { cursor: pointer; }
.actions > *:hover { color: var(--paper); }
/* Per-action accent on hover and press: gold favourite, slate archive, clay
chapter. Remove keeps --danger, play keeps paper/ember. */
.actions .fav:hover, .actions .fav:active, .actions .fav:focus-visible { color: var(--brass); }
.actions .pencil:hover, .actions .pencil:active, .actions .pencil:focus-visible { color: var(--clay); }
.actions .box:hover, .actions .box:active, .actions .box:focus-visible { color: var(--slate); }
.actions .fav:hover, .actions .fav:active, .actions .fav:focus-visible { color: var(--brass); background: var(--brass-wash); }
.actions .fav:focus-visible { outline: 2px solid var(--brass); outline-offset: -2px; }
.actions .pencil:hover, .actions .pencil:active, .actions .pencil:focus-visible { color: var(--clay); background: var(--clay-wash); }
.actions .pencil:focus-visible { outline: 2px solid var(--clay); outline-offset: -2px; }
.actions .box:hover, .actions .box:active, .actions .box:focus-visible { color: var(--slate); background: var(--slate-wash); }
.actions .box:focus-visible { outline: 2px solid var(--slate); outline-offset: -2px; }
.actions .play { color: var(--paper); }
.is-new .actions .play { color: var(--ember); }
.actions .play:hover { background: var(--hover); }
.actions .on { color: var(--brass); }
.is-new .actions .play:hover { background: var(--ember-wash); color: var(--ember); }
.actions .play:focus-visible { background: var(--hover); outline: 2px solid var(--paper); outline-offset: -2px; }
.is-new .actions .play:focus-visible { background: var(--ember-wash); color: var(--ember); outline-color: var(--ember); }
.actions .on { color: var(--brass); background: var(--brass-wash); }
.actions .restore { color: var(--paper); }
.actions .remove { color: var(--trash); }
.actions .remove:hover { color: var(--danger); }
.actions .restore:hover, .actions .restore:active, .actions .restore:focus-visible { color: var(--slate); background: var(--slate-wash); }
.actions .restore:focus-visible { outline: 2px solid var(--slate); outline-offset: -2px; }
.actions .remove { color: var(--trash); box-shadow: inset 1px 0 0 var(--danger-line); }
.actions .remove:hover, .actions .remove:active, .actions .remove:focus-visible { color: var(--danger); background: var(--danger-wash); }
.actions .remove:focus-visible { outline: 2px solid var(--danger); outline-offset: -2px; }
.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 .lifecycle.open.box, .actions .lifecycle.open.box:hover { background: var(--slate-wash); color: var(--slate); }
.actions .remove.open { background: var(--danger-wash); color: var(--danger); }
.is-dim .actions > * { color: var(--mute-2); }
.is-dim .actions > *:hover { background: transparent; color: var(--mute); }
/* Two clusters by consequence: navigate (play) | organize (favourite,
chapter) | lifecycle (archive/restore, remove). The lifecycle cells
@@ -654,7 +783,7 @@ button { cursor: pointer; }
}
.chapter-form .hint {
margin: 0;
font: 500 10px/1 var(--font-mono);
font: 500 11px/1 var(--font-mono);
letter-spacing: .14em;
text-transform: uppercase;
color: var(--mute-2);
@@ -671,9 +800,10 @@ button { cursor: pointer; }
font: 500 17px var(--font-mono);
outline: none;
}
/* 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); }
/* Clay carries the chapter action -- paper flashes on a dimmed OLED and ember
reads as "invalid" on a valid number. */
.chapter-form input:focus { border-color: var(--clay); }
.chapter-form input:focus-visible { border-color: var(--clay); outline: 2px solid var(--clay); outline-offset: 2px; }
.chapter-form input::-webkit-outer-spin-button,
.chapter-form input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.chapter-form button {
@@ -684,6 +814,7 @@ button { cursor: pointer; }
color: var(--ink);
font: 400 17px var(--font-display);
}
.chapter-form button:focus-visible { outline: 2px solid var(--clay); outline-offset: 2px; }
.confirm-row {
display: flex;
@@ -696,23 +827,25 @@ button { cursor: pointer; }
}
/* 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 span { flex: 1 1 16ch; font: 400 17px/1.3 var(--font-display); color: var(--danger-soft); overflow-wrap: anywhere; }
.confirm-row div { display: flex; flex: none; gap: 12px; margin-left: auto; }
.confirm-row button {
height: 46px;
padding: 0 14px;
border: 1px solid var(--field-line);
background: transparent;
color: var(--mute);
background: var(--ash);
color: var(--paper-dim);
font: 500 12px var(--font-body);
}
.confirm-row button:hover { color: var(--paper); }
.confirm-row button:hover { color: var(--paper); border-color: var(--paper); }
.confirm-row button:focus-visible { color: var(--paper); border-color: var(--paper); outline: 2px solid var(--paper); outline-offset: 2px; }
.confirm-row .danger-solid {
border: none;
background: var(--danger);
color: var(--danger-ink);
font-weight: 600;
}
.confirm-row .danger-solid:focus-visible { outline-color: var(--danger); }
/* Archive is reversible, so its confirm asks in grey — only the
irreversible remove gets the danger wash. */
.confirm-row.calm { background: var(--ash); }
@@ -723,16 +856,31 @@ button { cursor: pointer; }
color: var(--ink);
font-weight: 600;
}
.confirm-row .go:hover { color: var(--ink); }
.confirm-row .go:hover, .confirm-row .go:focus-visible { color: var(--ink); }
/* The same notice in two positions: .error-inline belongs to one card and
reports a write that failed, .notice sits under the chrome and reports the
requests that have no card to belong to — a tab switch, a rotation. */
.error-inline {
display: flex;
align-items: center;
gap: 9px;
margin: 0;
padding: 11px 13px;
background: var(--danger-wash);
border-left: 2px solid var(--danger);
font: 400 16px var(--font-display);
color: var(--danger-soft);
}
.notice {
display: flex;
align-items: center;
gap: 9px;
margin: 0;
padding: 11px 20px;
background: var(--ash);
border-left: 2px solid var(--mute);
border-left: 2px solid var(--patina);
border-bottom: 1px solid var(--rule);
font: 400 16px var(--font-display);
color: var(--paper-dim);
}
@@ -742,13 +890,22 @@ button { cursor: pointer; }
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--mute);
background: var(--danger);
}
.notice::before {
content: "";
flex: none;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--patina);
animation: mutePulse 1.4s ease-in-out infinite;
}
.error-inline .error-link {
.error-inline .error-link, .notice .error-link {
border-bottom: 1px solid var(--field-line);
color: var(--paper);
}
.error-inline .error-link { border-bottom-color: color-mix(in oklch, var(--danger) 26%, transparent); color: var(--danger-soft); }
/* Busy: a grey hairline slides across the top of the sheet — deliberately not
ember, which on a list screen only ever means "new chapter". */
@@ -763,6 +920,22 @@ button { cursor: pointer; }
background: var(--mute);
animation: barSlide 1.15s linear infinite;
}
/* Past about two seconds the bar alone stops reading as "working" and starts
reading as "ignored" — and htmx's own timeout is 15s. Delayed rather than
immediate: every response on a live connection lands well inside it. */
@keyframes busyWord { to { opacity: 1 } }
.card.htmx-request::after {
content: "Saving…";
position: absolute;
top: 6px;
right: 20px;
font: 500 11px/1 var(--font-mono);
letter-spacing: .1em;
text-transform: uppercase;
color: var(--mute);
opacity: 0;
animation: busyWord 0s 2s forwards;
}
/* ---- empty ---- */
.empty {
@@ -771,7 +944,7 @@ button { cursor: pointer; }
gap: 7px;
padding: 40px 20px 48px;
}
.empty strong { font: 400 20px var(--font-display); color: var(--paper); }
.empty strong { font: 400 20px var(--font-display); color: var(--paper); overflow-wrap: anywhere; }
.empty .clear-search {
align-self: flex-start;
margin-top: 4px;
@@ -795,21 +968,26 @@ button { cursor: pointer; }
.login-card {
width: 100%;
max-width: 420px;
min-height: 100vh;
min-height: calc(100vh / var(--zoom));
min-height: calc(100dvh / var(--zoom));
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 52px 28px 40px;
}
.login-card .eyebrow {
font: 500 10px/1 var(--font-mono);
font: 500 11px/1 var(--font-mono);
letter-spacing: .2em;
text-transform: uppercase;
color: var(--mute-2);
}
.login-card h1 {
margin: 14px 0 0;
font: 400 44px/1 var(--font-display);
/* The wordmark is the widest unbreakable run in the app; at 44px it is
wider than a 360px phone, and the page has no other content to scroll
to, so the overflow reads as a broken page. Fluid to the measured fit
(30px clears a 320px viewport), locked at the design size from 520px up. */
font: 400 clamp(30px, 8.5vw, 44px)/1 var(--font-display);
letter-spacing: -.01em;
color: var(--paper);
}
@@ -860,7 +1038,7 @@ button { cursor: pointer; }
keeps every proportion — hairlines, cover ratios, hit targets —
intact. ---- */
@media (min-width: 1280px) {
:root { zoom: 1.2; }
:root { --zoom: 1.2; zoom: var(--zoom); }
}
/* ---- desktop: same measure, actions fold up beside the row ---- */
@@ -880,6 +1058,7 @@ button { cursor: pointer; }
}
.tabs { order: 1; flex: none; gap: 20px; padding: 0; border-bottom: none; }
.tabs a { padding: 10px 0 14px; font-size: 18px; }
.tabs, .recent-strip { -webkit-mask-image: none; mask-image: none; }
.searchbar { order: 2; flex: 1; margin: 0; border-bottom: none; }
.recent h2, .recent-strip { padding-left: 32px; padding-right: 32px; }
.keyrow {
@@ -891,8 +1070,7 @@ button { cursor: pointer; }
}
.keyrow .pair { flex-direction: row; gap: 7px; }
.keyrow .pair svg { width: 14px; height: 14px; }
.keyrow .pair span { font-size: 11px; letter-spacing: .1em; }
.keyrow .full { display: inline; }
.keyrow .pair span { letter-spacing: .1em; }
.recent-card, .recent-cover { width: var(--cover-w); }
.recent-cover { height: calc(var(--cover-w) * 4 / 3); }
.recent-title { font-size: 17px; }
@@ -913,6 +1091,7 @@ button { cursor: pointer; }
than by the hairline the phone layout uses. */
.actions > .play + *,
.actions > *:not(.lifecycle) + .lifecycle { margin-left: 10px; box-shadow: none; }
.actions .remove { box-shadow: none; border-left-color: var(--danger-line); }
.is-new .actions .play { border-color: var(--play-hot-line); }
.actions .on { border-color: var(--fav-line); }
/* The cell border follows the icon on hover, so the accent reads as a state
@@ -922,6 +1101,19 @@ button { cursor: pointer; }
.chapter-form, .confirm-row, .error-inline {
margin-left: calc(var(--cover-w) + var(--row-gap));
}
/* Not the panel indent: the notice is chrome, so it keeps the sheet gutter
every other full-width row uses. */
.notice { padding-left: 32px; padding-right: 32px; }
}
/* Every other control on this sheet is already 44 or borrows a .ghost::after
to get there; the library switch is the one sized by its own padding in both
layouts, and padding plus an 11px line lands at 43. min-height states the
target instead of arithmetic on the font. Gated on the pointer rather than
the width — a touch laptop lands on the desktop layout with the same
thumb. */
@media (pointer: coarse) {
.libswitch a { min-height: 44px; display: grid; place-items: center; }
}
@media (prefers-reduced-motion: reduce) {
@@ -929,4 +1121,7 @@ button { cursor: pointer; }
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; }
/* The busy word is revealed by a zero-duration delayed animation, which the
rule above cancels — show it from the start instead. */
.card.htmx-request::after { opacity: 1; }
}
+11 -1
View File
@@ -7,12 +7,17 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light">
{{/* Same request timeout as the library shell: the Lanes fragment refreshes
itself every 30s, so an untimed hung request stacks. */}}
<meta name="htmx-config" content='{"timeout":15000}'>
<title>BookmarkManager — Admin</title>
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/admin.css">
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/static/fonts/dm-sans-var-latin.woff2" as="font" type="font/woff2" crossorigin>
<script src="/static/htmx.min.js" defer></script>
<script src="/static/filter.js" defer></script>
</head>
<body>
<div class="sheet admin-sheet">
@@ -33,9 +38,14 @@
<a href="/admin/series" class="{{if or (eq .Page "series") (eq .Page "series-detail")}}active{{end}}" {{if or (eq .Page "series") (eq .Page "series-detail")}}aria-current="page"{{end}}>Series</a>
</nav>
{{/* Where a failure with no specific slot gets reported — same slot the
reader library uses under its chrome. filter.js fills it. */}}
<p class="notice" id="notice" role="status" hidden></p>
<div id="sr-announce" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div>
<main class="page admin-page">
{{if eq .Page "lanes"}}
<div aria-live="polite">{{template "lanes" .Lanes}}</div>
{{template "lanes" .Lanes}}
{{else if eq .Page "readers"}}
{{template "readers" .}}
{{else if eq .Page "series"}}
+15 -5
View File
@@ -5,6 +5,11 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light">
{{/* htmx defaults to no timeout, so a phone that walks into a dead zone
leaves the XHR open forever and the card it came from keeps its busy bar
and its disabled actions until a reload. 15s is well past any response
this deployment produces. */}}
<meta name="htmx-config" content='{"timeout":15000}'>
<title>BookmarkManager</title>
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/style.css">
@@ -53,34 +58,39 @@
<a href="{{.PageURL "all"}}" class="{{if eq .Tab "all"}}active{{end}}"
{{if eq .Tab "all"}}aria-current="page"{{end}}
hx-get="{{.ListURL "all"}}" hx-target="#list" hx-swap="innerHTML"
hx-push-url="{{.PageURL "all"}}" hx-on::after-request="setActiveTab(this)">All</a>
hx-push-url="{{.PageURL "all"}}" hx-on::after-request="if (event.detail.successful) setActiveTab(this)">All</a>
{{/* The one bucket novels do not have: without a poller-fed "what is out
that I have not read", the tab would only ever restate All. */}}
{{if eq .Lib "manga"}}
<a href="{{.PageURL "new"}}" class="tab-new {{if eq .Tab "new"}}active{{end}}"
{{if eq .Tab "new"}}aria-current="page"{{end}}
hx-get="{{.ListURL "new"}}" hx-target="#list" hx-swap="innerHTML"
hx-push-url="{{.PageURL "new"}}" hx-on::after-request="setActiveTab(this)">Updated
hx-push-url="{{.PageURL "new"}}" hx-on::after-request="if (event.detail.successful) setActiveTab(this)">Updated
{{template "newcount" .}}</a>
{{end}}
<a href="{{.PageURL "fav"}}" class="{{if eq .Tab "fav"}}active{{end}}"
{{if eq .Tab "fav"}}aria-current="page"{{end}}
hx-get="{{.ListURL "fav"}}" hx-target="#list" hx-swap="innerHTML"
hx-push-url="{{.PageURL "fav"}}" hx-on::after-request="setActiveTab(this)">Favourites</a>
hx-push-url="{{.PageURL "fav"}}" hx-on::after-request="if (event.detail.successful) setActiveTab(this)">Favourites</a>
<a href="{{.PageURL "archived"}}" class="{{if eq .Tab "archived"}}active{{end}}"
{{if eq .Tab "archived"}}aria-current="page"{{end}}
hx-get="{{.ListURL "archived"}}" hx-target="#list" hx-swap="innerHTML"
hx-push-url="{{.PageURL "archived"}}" hx-on::after-request="setActiveTab(this)">Archived</a>
hx-push-url="{{.PageURL "archived"}}" hx-on::after-request="if (event.detail.successful) setActiveTab(this)">Archived</a>
</nav>
</div>
{{/* Where a failure with no card to sit in gets reported: a tab switch, a
rotation, an expired session. filter.js fills it and unhides it. */}}
<p class="notice" id="notice" role="status" hidden></p>
<div id="sr-announce" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div>
{{template "setup" .}}
{{template "keyrow" .}}
{{template "recent" .}}
<main id="list" class="list">
<main id="list" class="list" tabindex="-1">
{{template "list" .}}
</main>
</div>
+17 -7
View File
@@ -6,11 +6,17 @@
<div class="row">
<a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"
tabindex="-1" aria-hidden="true">
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
{{/* aria-hidden on the cover link is not enough — Chromium still exposes
the letter because the link is programmatically focusable — so the
monogram carries its own, same as the recent strip's. */}}
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
monogram carries its own, same as the recent strip's.
The monogram is always in the DOM under the image, not only when no
cover was acquired: a stored cover that 404s or a phone that drops
the request otherwise leaves the browser's broken-image glyph in a
93px slot. onerror removes the image and the letter is already
there. */}}
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy" onerror="this.remove()">{{end}}
<span class="monogram" aria-hidden="true">{{.Initial}}</span>
{{if and (eq .Status "reading") .HasNewChapter}}<span class="foot-rule"></span>
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
</a>
@@ -46,13 +52,14 @@
</a>
<button class="fav{{if .Favorite}} on{{end}}"
title="{{if .Favorite}}Remove from favourites{{else}}Add to favourites{{end}}"
aria-label="Toggle favourite"
aria-label="{{if .Favorite}}Remove from favourites{{else}}Add to favourites{{end}}"
aria-pressed="{{if .Favorite}}true{{else}}false{{end}}"
hx-post="/ui/bookmarks/{{.Key}}/favorite"
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-star{{if .Favorite}}-on{{end}}"/></svg>
</button>
<button class="pencil" title="Set chapter" aria-label="Set chapter"
<button class="pencil" title="Set chapter" aria-label="Set chapter" aria-expanded="false" aria-controls="chapter-form-{{.Key}}"
onclick="toggleChapterForm('{{.Key}}')">
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg>
</button>
@@ -88,8 +95,11 @@
label names the field and the latest sits after it as context. */}}
<label class="hint" for="chapter-{{.Key}}">Chapter you're on</label>
<div class="field">
{{/* max is a fat-finger guard, not a real ceiling — no series is near it. */}}
<input id="chapter-{{.Key}}" name="chapter" type="number" step="0.1" min="0" max="9999"
{{/* max is a fat-finger guard, not a real ceiling — no series is near it.
step is "any" because the server accepts any float and the field is
pre-filled from it: under step=0.1 a series read to 1200.25 opened a
form that refused its own value. */}}
<input id="chapter-{{.Key}}" name="chapter" type="number" step="any" min="0" max="9999"
value="{{.LastChapterNum}}" required>
<button type="submit">Save</button>
</div>
+6 -5
View File
@@ -14,8 +14,9 @@
<a class="recent-card {{if .HasNewChapter}}is-new{{end}}" href="{{.ContinueURL}}"
target="_blank" rel="noopener noreferrer">
<span class="recent-cover">
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
{{/* Monogram always present under the image; see card.html. */}}
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy" onerror="this.remove()">{{end}}
<span class="monogram" aria-hidden="true">{{.Initial}}</span>
{{if .HasNewChapter}}<span class="foot-rule"></span>
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
</span>
@@ -34,11 +35,11 @@
<div class="keyrow" id="keyrow" aria-label="Action key"{{if .OOB}} hx-swap-oob="true"{{end}}>
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg><span>Read</span></span>
<span class="pair brass"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star"/></svg><span>Fav</span></span>
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg><span>Chapter</span></span>
<span class="pair clay"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg><span>Chapter</span></span>
{{if eq .Tab "archived"}}
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg><span>Restore</span></span>
<span class="pair slate"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg><span>Restore</span></span>
{{else}}
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg><span>Archive</span></span>
<span class="pair slate"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg><span>Archive</span></span>
{{end}}
<span class="pair trash"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg><span>Delete</span></span>
</div>
+12 -9
View File
@@ -10,7 +10,7 @@
this template only prints them. */}}
{{define "lanes"}}
<section class="lanes" id="lanes"
hx-get="/ui/admin/lanes" hx-trigger="every 30s" hx-swap="outerHTML">
hx-get="/ui/admin/lanes" hx-trigger="every 30s" hx-swap="outerHTML" hx-sync="this:replace">
<div class="sechead">
<h2 class="sec">Poll Lanes</h2>
<p class="statusline">
@@ -18,20 +18,23 @@
{{else}}Browser: {{if not .BrowserConfigured}}<span class="mark-faint">not configured</span>{{else if .BrowserReachable}}<span class="mark mark-strong">reachable</span>{{else}}<span class="mark bad">unreachable</span>{{end}}{{end}}
</p>
</div>
<p class="hint">Healthy idle (paused, browser asleep, nothing eligible) needs no action — attention needs the owner.</p>
{{if .Rows}}
<div class="tbl lanes">
<div class="thead">
<span>Site</span><span>Due</span><span>Checked</span><span>Gap</span>
<span>Last pass</span><span>Outcomes · state</span><span></span>
<span>Last pass</span><span title="Window outcomes for this Site (last 12h)">Outcomes · status</span><span></span>
</div>
{{range .Rows}}
<div class="trow{{if .Attention}} attention{{end}}">
<a class="c-site" href="{{.FailingHref}}">{{.Site}}</a>
<span>{{.Due}}</span>
<span>{{.Checked}}</span>
<span>{{.Gap}}</span>
{{/* data-label is the column word for the phone layout, which drops the
thead: three bare numbers in a row say nothing. */}}
<span data-label="due">{{.Due}}</span>
<span data-label="checked">{{.Checked}}</span>
<span data-label="gap">{{.Gap}}</span>
<span>ran {{.Ran}}</span>
<span class="c-skip">{{if .HasChips}}{{range $i, $c := .Chips}}{{if $i}}<span class="mark-faint"> · </span>{{end}}<span class="mark">{{$c.Name}} {{$c.Count}}</span>{{end}}{{else}}<span class="mark-faint">none observed</span>{{end}}{{if .StatePhrase}} · <span class="{{if .StateGood}}ok{{else}}bad{{end}}">{{.StatePhrase}}</span>{{end}}</span>
<span class="c-skip">{{if .HasChips}}{{range $i, $c := .Chips}}{{if $i}}<span class="mark-faint"> · </span>{{end}}<span class="mark">{{$c.Name}} {{$c.Count}}</span>{{end}}{{else}}<span class="mark-faint">none observed</span>{{end}}{{if .StatePhrase}} · <span class="{{if .StateGood}}ok{{else}}bad{{end}}" aria-label="{{if .StateGood}}healthy: {{end}}{{.StatePhrase}}">{{.StatePhrase}}</span>{{end}}</span>
{{/* The pause control lives in the one slot the design leaves for it:
a running Lane offers the three durations and Pause; a paused Lane
offers Resume in the same place. Pause is not destruction — it
@@ -39,11 +42,11 @@
confirm row or the danger accent. The form wraps the select so the
offered duration travels with the press. */}}
<span class="c-ctrl">{{if .Paused}}<span class="pausebar">
<form hx-post="/admin/lanes/{{.Site}}/resume" hx-target="#lanes" hx-swap="outerHTML">
<form hx-post="/admin/lanes/{{.Site}}/resume" hx-target="#lanes" hx-swap="outerHTML" hx-indicator="#lanes" hx-disabled-elt="find button" hx-sync="closest #lanes:replace">
<button type="submit" class="ghost">Resume</button>
</form>
</span>{{else}}<span class="pausebar">
<form hx-post="/admin/lanes/{{.Site}}/pause" hx-target="#lanes" hx-swap="outerHTML">
<form hx-post="/admin/lanes/{{.Site}}/pause" hx-target="#lanes" hx-swap="outerHTML" hx-indicator="#lanes" hx-disabled-elt="find button, select" hx-sync="closest #lanes:replace">
<select name="duration" aria-label="Pause duration">
<option>1h</option><option selected>6h</option><option>24h</option>
</select>
@@ -57,4 +60,4 @@
<p class="empty">No data yet — no Lane has recorded a pass.</p>
{{end}}
</section>
{{end}}
{{end}}
+1 -1
View File
@@ -4,7 +4,7 @@
{{/* 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. */}}
<div class="empty" id="no-match" hidden>
<div class="empty" id="no-match" role="status" hidden>
<strong>No titles match “<span class="no-match-q"></span>”.</strong>
<button type="button" class="clear-search">Clear search</button>
</div>
+2 -1
View File
@@ -9,6 +9,7 @@
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/style.css">
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/static/fonts/dm-sans-var-latin.woff2" as="font" type="font/woff2" crossorigin>
</head>
<body>
<main class="login-card">
@@ -25,7 +26,7 @@
<p class="error" role="alert">{{.Error}}</p>
<button type="submit">Continue with Discord</button>
</form>
<p class="login-note">Guild membership is required to sign in.</p>
<p class="login-note">{{if .GuildName}}This library is for members of the {{.GuildName}} Discord. Ask a member for an invite to sign in.{{else}}This library is private to its Discord community. Ask a member for an invite to sign in.{{end}}</p>
</main>
</body>
</html>
+4 -2
View File
@@ -4,11 +4,13 @@
figures link, what a Lane's state means — is made in Go; this template
only prints. */}}
{{define "overview"}}
<p class="verdict"><span class="attn">{{.Verdict}}</span> {{if .HasCounts}}<span class="counts">· <b>{{.Waiting}}</b> series waiting · <b>{{.Unchecked}}</b> unchecked over 12h</span>{{end}}</p>
<p class="verdict"><span class="attn">{{.Verdict}}</span> {{if .HasCounts}}<span class="counts">· <b>{{.Waiting}}</b> series waiting · <b>{{.Unchecked}}</b> unchecked over 12h{{if ne .Verdict "all lanes healthy"}} · <a href="/admin/lanes">review Lanes</a>{{end}}</span>{{end}}</p>
<h2 class="sec">Hygiene</h2>
<div class="stats">{{range .Hygiene}}<div class="stat"><span class="lbl">{{.Label}}</span>{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}</div>{{end}}</div>
<h2 class="sec">Library</h2>
<div class="stats">{{range .Library}}<div class="stat"><span class="lbl">{{.Label}}</span>{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}</div>{{end}}</div>
{{/* data-label carries each cell's column word for the phone layout, which
drops the thead: the CSS prints it as the cell's prefix. */}}
<h2 class="sec">Sites · last 12h</h2>
<div class="tbl sites"><div class="thead"><span>Site</span><span>Series</span><span>No cover</span><span>Never chk</span><span>Stale</span><span>State</span></div>{{range .Sites}}<div class="trow"><a class="c-site site-{{.Site}}" href="{{.SiteHref}}">{{.Site}}</a>{{range .Figs}}{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}{{end}}<span class="c-state{{if .StateGood}} ok{{end}}{{if .StateBad}} bad{{end}}">{{.State}}</span></div>{{end}}</div>
{{if .Sites}}<div class="tbl sites"><div class="thead"><span>Site</span><span>Series</span><span>No cover</span><span>Never checked</span><span title="Stale: not checked in 12h window">Stale</span><span title="Lane status — same vocabulary as Poll Lanes">Status</span></div>{{range .Sites}}<div class="trow"><a class="c-site site-{{.Site}}" href="{{.SiteHref}}">{{.Site}}</a>{{range .Figs}}{{if .Href}}<a class="fig" data-label="{{.Label}}" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero" data-label="{{.Label}}">{{.Count}}</span>{{end}}{{end}}<span class="c-state{{if .StateGood}} ok{{end}}{{if .StateBad}} bad{{end}}"{{if .State}} aria-label="{{if .StateGood}}healthy: {{end}}{{.State}}"{{end}}>{{.State}}</span></div>{{end}}</div>{{else}}<p class="empty">No sites yet — bookmarked series will appear here once the userscript records a chapter.</p>{{end}}
{{end}}
+33 -16
View File
@@ -13,9 +13,10 @@
often a later Poll confirmed or contradicted what that Reader's browser
reported; enough contradictions stop their reports deferring a Poll, and
clearing the marks gives that back.</p>
{{if .Readers}}
<ul class="readerlist">
{{range .Readers}}
<li>
<li id="reader-{{.ID}}">
<span class="reader-id">{{.DiscordID}}</span>
<span class="reader-sessions">{{.Sessions}} session{{if ne .Sessions 1}}s{{end}}</span>
<span class="reader-sightings">{{.Agreements}} confirmed / {{.Disagreements}} contradicted</span>
@@ -23,26 +24,42 @@
numbers and a threshold. */}}
{{if .Blocked}}<span class="reader-blocked">deferral blocked</span>{{end}}
<span class="reader-actions">
{{/* Clearing restores a privilege, so it is a plain ghost button —
the destruction accent belongs to revocation alone. It is offered
on every row, including one reading zero: the remedy must be
findable before the counters climb, not after. */}}
<form hx-post="/readers/{{.ID}}/clear-marks" hx-target="#readers" hx-swap="outerHTML"
hx-confirm="Clearing wipes this Reader's whole Sighting record, confirmations included. Clear?">
<button type="submit" class="ghost">Clear marks</button>
</form>
{{/* The owner's own row never offers Revoke: it is the one row where the
button would sign the tapping browser out, and the endpoint refuses
it anyway. Logout is the deliberate way to do that. */}}
{{/* Clearing restores a privilege, so its confirm wears the calm wash,
not destruction. Revocation destroys sessions, so it wears danger.
Both are confirm-gated — the opener never posts. */}}
<button type="button" class="ghost" aria-expanded="false" aria-controls="confirm-clear-{{.ID}}"
onclick="document.getElementById('confirm-clear-{{.ID}}').hidden = false; this.setAttribute('aria-expanded','true')">Clear marks</button>
{{if and .Sessions (ne .ID $.OwnerID)}}
<form hx-post="/readers/{{.ID}}/revoke" hx-target="#readers" hx-swap="outerHTML"
hx-confirm="Revoking signs this Reader out on every device immediately. Revoke?">
<button type="submit" class="ghost danger">Revoke sessions</button>
</form>
<button type="button" class="ghost danger" aria-expanded="false" aria-controls="confirm-revoke-{{.ID}}"
onclick="document.getElementById('confirm-revoke-{{.ID}}').hidden = false; this.setAttribute('aria-expanded','true')">Revoke sessions</button>
{{end}}
</span>
<div class="confirm-row calm" id="confirm-clear-{{.ID}}" role="group" aria-live="polite" hidden>
<span>Clear this Reader's Sighting record?</span>
<div>
<button type="button" class="go"
hx-post="/readers/{{.ID}}/clear-marks" hx-target="#readers" hx-swap="outerHTML"
hx-indicator="#readers" hx-disabled-elt="this">Clear</button>
<button type="button" onclick="document.getElementById('confirm-clear-{{.ID}}').hidden = true; var b=document.querySelector('[aria-controls=\'confirm-clear-{{.ID}}\']'); if(b){b.setAttribute('aria-expanded','false'); b.focus();}">Cancel</button>
</div>
</div>
{{if and .Sessions (ne .ID $.OwnerID)}}
<div class="confirm-row" id="confirm-revoke-{{.ID}}" role="group" aria-live="polite" hidden>
<span>Revoke this Reader's sessions?</span>
<div>
<button type="button" class="danger-solid"
hx-post="/readers/{{.ID}}/revoke" hx-target="#readers" hx-swap="outerHTML"
hx-indicator="#readers" hx-disabled-elt="this">Revoke</button>
<button type="button" onclick="document.getElementById('confirm-revoke-{{.ID}}').hidden = true; var b=document.querySelector('[aria-controls=\'confirm-revoke-{{.ID}}\']'); if(b){b.setAttribute('aria-expanded','false'); b.focus();}">Cancel</button>
</div>
</div>
{{end}}
</li>
{{end}}
</ul>
{{else}}
<p class="empty">No readers yet — the roster appears after the first Discord sign-in.</p>
{{end}}
<p class="error-inline" role="status" hidden></p>
</section>
{{end}}
@@ -9,52 +9,77 @@
<h1 class="detail-title">{{.Title}}</h1>
<p class="detail-key">{{.Key}} · {{.Site}} · {{.Kind}}</p>
{{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div>
{{else}}<div class="cover"></div>{{end}}
{{else}}<div class="cover" aria-hidden="true"><span>no cover</span></div>{{end}}
{{template "series-detail-meta" .}}
<div class="detail-grid">
<form class="dform" hx-post="/admin/series/{{.Key}}/latest" hx-target="#detail-meta" hx-swap="outerHTML">
<form class="dform" hx-post="/admin/series/{{.Key}}/latest" hx-target="#detail-meta" hx-swap="outerHTML" hx-indicator="closest .dform" hx-disabled-elt="find button, input">
<h3>Correct latest chapter</h3>
<p class="hint">The next successful Poll overwrites this value.</p>
<div class="field">
<input type="number" name="chapter" step="any" placeholder="{{.Chapter}}" required>
<button type="submit" class="ghost">Set</button>
<label class="sr-only" for="chapter-{{.Key}}">Latest chapter number</label>
<input id="chapter-{{.Key}}" type="number" name="chapter" step="any" placeholder="{{.Chapter}}" required aria-describedby="hint-latest-{{.Key}}">
<button type="submit" class="ghost">Save chapter</button>
</div>
<p class="hint" id="hint-latest-{{.Key}}">The next successful Poll overwrites this value. Use this only when the Poll is failing or the page is wrong.</p>
{{if .Unverified}}<p class="hint">{{.Unverified}}</p>{{end}}
<p class="error-inline" role="status" hidden></p>
</form>
<form class="dform" hx-post="/admin/series/{{.Key}}/series-url" hx-target="#detail-meta" hx-swap="outerHTML">
<form class="dform" hx-post="/admin/series/{{.Key}}/series-url" hx-target="#detail-meta" hx-swap="outerHTML" hx-indicator="closest .dform" hx-disabled-elt="find button, input">
<h3>Repair series URL</h3>
<p class="hint">The Poll fetches this address — storing is not verifying it. A Site-wide host change is a SQL migration, not two hundred forms.</p>
<div class="field">
<input type="url" name="series_url" value="{{.URL}}" required>
<button type="submit" class="ghost">Set</button>
<label class="sr-only" for="url-{{.Key}}">Series page URL</label>
<input id="url-{{.Key}}" type="url" name="series_url" value="{{.URL}}" required aria-describedby="hint-url-{{.Key}}">
<button type="submit" class="ghost">Save URL</button>
</div>
<p class="hint" id="hint-url-{{.Key}}">The Poll fetches this address. Saving does not verify it — A Site-wide host change is a SQL migration, not per-series edits.</p>
<p class="error-inline" role="status" hidden></p>
</form>
</div>
{{if .CanPoll}}
<div class="dform">
<div class="field"><a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="#detail-meta" hx-swap="outerHTML" href="#">Check now</a></div>
<div class="field"><button type="button" class="ghost"
hx-post="/admin/series/{{.Key}}/poll" hx-target="#detail-meta" hx-swap="outerHTML"
hx-indicator="#detail-meta" hx-disabled-elt="this">Check now</button></div>
<p class="error-inline" role="status" hidden></p>
</div>
{{end}}
{{if .CanRemove}}
<div class="dform">
<div class="field"><button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="#detail-meta" hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button></div>
<div class="field"><button type="button" class="ghost danger"
aria-expanded="false" aria-controls="confirm-remove"
onclick="document.getElementById('confirm-remove').hidden = false; this.setAttribute('aria-expanded','true')">Remove</button></div>
<div class="confirm-row" id="confirm-remove" role="group" aria-live="polite" hidden>
<span>Remove “{{.Title}}”? Stored cover is lost.</span>
<div>
<button type="button" class="danger-solid"
hx-post="/admin/series/{{.Key}}/remove" hx-target="#detail-meta"
hx-indicator="#detail-meta" hx-disabled-elt="this">Remove</button>
<button type="button" onclick="document.getElementById('confirm-remove').hidden = true; document.querySelector('.dform .danger').setAttribute('aria-expanded','false')">Cancel</button>
</div>
</div>
<p class="error-inline" role="status" hidden></p>
</div>
{{end}}
{{if .Finished}}
<div class="dform">
<div class="field"><button type="button" class="ghost" hx-post="/admin/series/{{.Key}}/unfinish" hx-target="#detail-meta" hx-swap="outerHTML">Un-finish</button></div>
<div class="field"><button type="button" class="ghost"
hx-post="/admin/series/{{.Key}}/unfinish" hx-target="#detail-meta" hx-swap="outerHTML"
hx-indicator="#detail-meta" hx-disabled-elt="this">Un-finish</button></div>
<p class="error-inline" role="status" hidden></p>
</div>
{{else}}
<div class="dform">
<div class="field"><button type="button" class="ghost" onclick="document.getElementById('confirm-finish').hidden = false">Finish</button></div>
<div class="field"><button type="button" class="ghost" aria-expanded="false" aria-controls="confirm-finish"
onclick="document.getElementById('confirm-finish').hidden = false; this.setAttribute('aria-expanded','true')">Finish</button></div>
{{if .SiteCompleted}}<p class="hint">{{.SiteCompleted}}</p>{{end}}
<div class="confirm-row calm" id="confirm-finish" role="group" aria-live="polite" hidden>
<span>Mark this Series finished?</span>
<div>
<button type="button" class="go" hx-post="/admin/series/{{.Key}}/finish" hx-target="#detail-meta" hx-swap="outerHTML">Finish</button>
<button type="button" onclick="document.getElementById('confirm-finish').hidden = true">Cancel</button>
<button type="button" class="go" hx-post="/admin/series/{{.Key}}/finish" hx-target="#detail-meta" hx-swap="outerHTML"
hx-indicator="#detail-meta" hx-disabled-elt="this">Finish</button>
<button type="button" onclick="document.getElementById('confirm-finish').hidden = true; document.querySelector('[aria-controls=confirm-finish]').setAttribute('aria-expanded','false')">Cancel</button>
</div>
</div>
<p class="error-inline" role="status" hidden></p>
</div>
{{end}}
{{end}}
@@ -65,11 +90,11 @@
the provenance line beside the number — describe the value they sit
next to. */}}
{{define "series-detail-meta"}}
<div class="detail-meta" id="detail-meta">
<div class="detail-meta" id="detail-meta" aria-live="polite">
<span>ch {{.Chapter}}</span>
{{if .Provenance}}<span>{{.Provenance}}</span>{{end}}
<span>checked {{.Checked}}</span>
<span>{{.Readers}} readers</span>
<span>{{.Readers}} reader{{if ne .Readers 1}}s{{end}}</span>
{{if .Corrected}}<span class="mark">{{.Corrected}}</span>{{end}}
{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}
{{if .FinishedSince}}<span class="mark">{{.FinishedSince}}</span>{{end}}
@@ -78,4 +103,4 @@
{{if .Orphan}}<span class="mark">orphan</span>{{end}}
{{if .SightingRaised}}<span class="mark">sighting-raised</span>{{end}}
</div>
{{end}}
{{end}}
@@ -6,13 +6,14 @@
{{define "series-list"}}
<form class="filterbar" id="filterbar" method="get" action="/admin/series">
<input type="hidden" name="kind" value="{{.Kind}}">
<label class="fsel"><span>Show</span><select name="filter" onchange="this.form.submit()">
<label class="fsel"><span>Filter</span><select name="filter" onchange="this.form.submit()" aria-label="Filter series — changes apply immediately">
{{range .Filters}}<option value="{{.Name}}"{{if .Selected}} selected{{end}}>{{.Label}} ({{.Count}})</option>{{end}}
</select></label>
<label class="fsel"><span>Site</span><select name="site" onchange="this.form.submit()">
<label class="fsel"><span>Site</span><select name="site" onchange="this.form.submit()" aria-label="Filter by site — changes apply immediately">
<option value=""{{if not .Site}} selected{{end}}>All sites</option>
{{range .Sites}}<option value="{{.}}"{{if eq $.Site .}} selected{{end}}>{{.}}</option>{{end}}
</select></label>
<noscript><button type="submit" class="ghost">Apply</button></noscript>
<span class="segrow">
<a href="{{.KindBoth}}"{{if not .Kind}} class="active"{{end}}>both</a>
<a href="{{.KindManga}}"{{if eq .Kind "manga"}} class="active"{{end}}>manga</a>
@@ -41,15 +42,32 @@
no page to fetch and on an orphan, so the owner is never offered a button
that can never do anything. */}}
{{define "series-row"}}
<div class="trow{{if .Attention}} attention{{end}}{{if .Band}} band{{end}}">
<div class="trow{{if .Attention}} attention{{end}}{{if .Band}} band{{end}}" id="row-{{.Key}}">
<span class="c-title"><a href="/admin/series/{{.Key}}">{{.Title}}</a>{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}</span>
<span class="c-site site-{{.Site}}">{{.Site}}</span>
<span class="c-ch">{{.Ch}}</span>
<span>{{.Age}}</span>
<span class="c-rd">{{.Readers}}</span>
{{/* data-label is the column word for the phone layout, which drops the
thead: "1200.25 3d 4" says nothing without it. */}}
<span class="c-ch" data-label="ch">{{.Ch}}</span>
<span data-label="checked">{{.Age}}</span>
<span class="c-rd" data-label="readers">{{.Readers}}</span>
<span class="c-note">{{if .Failure}}<span class="mark">{{.Failure}}</span>{{end}}{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}{{if .Finished}}<span class="mark mark-faint">finished</span>{{end}}</span>
<span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}{{if .CanRemove}}<button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="closest .trow" hx-swap="outerHTML" hx-include="#filterbar" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button>{{end}}</span>
<span class="c-act">{{if .CanPoll}}<button type="button" class="ghost"
hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML"
hx-indicator="closest .trow" hx-disabled-elt="this"
hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}'>Check now</button>{{end}}{{if .CanRemove}}<button type="button" class="ghost danger"
aria-expanded="false" aria-controls="confirm-remove-{{.Key}}"
onclick="document.getElementById('confirm-remove-{{.Key}}').hidden = false; this.setAttribute('aria-expanded','true')">Remove</button>{{end}}</span>
{{if .RemovalRefused}}<span class="row-msg">a Reader has bookmarked this Series again</span>{{end}}
{{if .CanRemove}}<div class="confirm-row" id="confirm-remove-{{.Key}}" role="group" aria-live="polite" hidden>
<span>Remove “{{.Title}}”? Progress is lost.</span>
<div>
<button type="button" class="danger-solid"
hx-post="/admin/series/{{.Key}}/remove" hx-target="closest .trow" hx-swap="outerHTML"
hx-include="#filterbar" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}'
hx-indicator="closest .trow" hx-disabled-elt="this">Remove</button>
<button type="button" onclick="document.getElementById('confirm-remove-{{.Key}}').hidden = true; document.querySelector('#row-{{.Key}} .c-act .danger').setAttribute('aria-expanded','false')">Cancel</button>
</div>
</div>{{end}}
</div>
{{end}}
+60 -13
View File
@@ -3,6 +3,7 @@ package web
import (
"context"
"embed"
"fmt"
"html/template"
"io/fs"
"log"
@@ -29,6 +30,14 @@ var staticFS embed.FS
// RecentCount is how many series the "Continue reading" strip shows.
const RecentCount = 5
// maxChapterNum bounds a chapter number a Reader or the owner types. The
// number reaches the store as a float64, so without a ceiling a hand-rolled
// POST stores 1e308 and every later reader of that row — the poller's
// HasNewChapter comparison, the display string — inherits it. Matches the
// `max` on the card's chapter input; no real series is within three orders of
// magnitude of it.
const maxChapterNum = 9999
// Handler serves the browser UI: full pages at / and htmx fragments at /ui/.
// It is a separate handler from api.Handler because the two speak different
// representations (HTML versus JSON) to different clients under different auth.
@@ -109,7 +118,8 @@ func (v listView) ListURL(tab string) string {
// loginView is what the login template receives.
type loginView struct {
Error string
Error string
GuildName string
}
// New parses every template up front so a broken one kills the process at
@@ -243,7 +253,7 @@ func (h *Handler) render(w http.ResponseWriter, status int, name string, data an
func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
readerID, ok := h.sessionReader(r)
if !ok {
h.render(w, http.StatusOK, "login", loginView{})
h.render(w, http.StatusOK, "login", loginView{GuildName: h.discord.GuildName})
return
}
view, err := h.buildListView(readerID, libOf(r.URL.Query().Get("lib")), r.URL.Query().Get("tab"))
@@ -418,12 +428,20 @@ func (h *Handler) refreshChrome(w http.ResponseWriter, r *http.Request) {
}
h.writeChromeOOB(w, view)
}
// writeAnnounceOOB appends a visually-hidden live region update out-of-band,
// so a successful mutation announces itself without moving focus.
func (h *Handler) writeAnnounceOOB(w http.ResponseWriter, msg string) {
fmt.Fprintf(w, `<div id="sr-announce" class="sr-only" role="status" aria-live="polite" aria-atomic="true" hx-swap-oob="true">%s</div>`, template.HTMLEscapeString(msg))
}
func (h *Handler) writeNoticeOOB(w http.ResponseWriter, msg string) {
fmt.Fprintf(w, `<p id="notice" class="notice" hx-swap-oob="true">%s</p>`, template.HTMLEscapeString(msg))
}
// renderLogin renders the login page with an error message, for refused or
// failed sign-ins. Every message is author-written text — nothing Discord
// supplied is ever interpolated into a page.
func (h *Handler) renderLogin(w http.ResponseWriter, status int, msg string) {
h.render(w, status, "login", loginView{Error: msg})
h.render(w, status, "login", loginView{Error: msg, GuildName: h.discord.GuildName})
}
// logout revokes the session row and clears the cookie in one step: the next
@@ -488,7 +506,14 @@ func (h *Handler) uiFavorite(w http.ResponseWriter, r *http.Request) {
}
b.Favorite = !b.Favorite
b.UpdatedAt = time.Now().UnixMilli()
msg := ""
if b.Favorite {
msg = fmt.Sprintf("Added %s to favourites", b.Title)
} else {
msg = fmt.Sprintf("Removed %s from favourites", b.Title)
}
h.saveAndRenderCard(w, r, b)
h.writeAnnounceOOB(w, msg)
}
// uiStatus moves a bookmark between the two lifecycle buckets. Finished is not
@@ -514,7 +539,14 @@ func (h *Handler) uiStatus(w http.ResponseWriter, r *http.Request) {
return
}
b.UpdatedAt = time.Now().UnixMilli()
msg := ""
if b.Status == store.StatusArchived {
msg = fmt.Sprintf("Archived %s", b.Title)
} else {
msg = fmt.Sprintf("Restored %s", b.Title)
}
h.saveAndRenderCard(w, r, b)
h.writeAnnounceOOB(w, msg)
}
// uiChapter forces the read chapter to a value the user typed.
@@ -539,8 +571,8 @@ func (h *Handler) uiChapter(w http.ResponseWriter, r *http.Request) {
}
raw := strings.TrimSpace(r.PostFormValue("chapter"))
num, err := strconv.ParseFloat(raw, 64)
if err != nil || num < 0 || math.IsNaN(num) || math.IsInf(num, 0) {
http.Error(w, "chapter must be a non-negative number", http.StatusBadRequest)
if err != nil || num < 0 || num > maxChapterNum || math.IsNaN(num) || math.IsInf(num, 0) {
http.Error(w, "chapter must be a non-negative number below 10000", http.StatusBadRequest)
return
}
@@ -550,27 +582,42 @@ func (h *Handler) uiChapter(w http.ResponseWriter, r *http.Request) {
b.LastChapterNum = num
}
b.UpdatedAt = time.Now().UnixMilli()
msg := fmt.Sprintf("Updated %s to chapter %s", b.Title, raw)
h.saveAndRenderCard(w, r, b)
h.writeAnnounceOOB(w, msg)
}
// uiDelete removes the row and answers with an empty body, which htmx swaps in
// place of the card — removing it from the page.
func (h *Handler) uiDelete(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
if key == "" {
http.Error(w, "missing key", http.StatusBadRequest)
b, ok := h.loadForMutation(w, r)
if !ok {
return
}
if err := h.store.Delete(readerOf(r), key); err != nil {
log.Printf("ui delete %q: %v", key, err)
if err := h.store.Delete(readerOf(r), b.Key); err != nil {
log.Printf("ui delete %q: %v", b.Key, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
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)
msg := fmt.Sprintf("Removed %s", b.Title)
h.writeAnnounceOOB(w, msg)
h.writeNoticeOOB(w, msg)
view, err := h.buildListView(readerOf(r), currentLib(r), currentTab(r))
if err != nil {
log.Printf("ui chrome: %v", err)
return
}
h.writeChromeOOB(w, view)
if len(view.Items) == 0 {
fmt.Fprint(w, `<main id="list" class="list" tabindex="-1" hx-swap-oob="true">`)
if err := h.tmpl.ExecuteTemplate(w, "list", view); err != nil {
log.Printf("render list oob: %v", err)
return
}
fmt.Fprint(w, `</main>`)
}
}
// installUserscript renders the bindmounted script with the acting Reader's