The edit panel's only visible text was "Latest known: Ch 30" sitting directly
above a field pre-filled with your own progress — so on the one occasion the
form is worth opening, when those two numbers differ, the visible label named
the wrong one. Correcting a chapter number is this UI's stated primary job.
The field gets a real label ("Chapter you're on", tied by for/id, replacing the
screen-reader-only aria-label) and "Latest known" moves below it as context.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing was compressed: the list page shipped ~123 KB of HTML, CSS and JS in
the clear on every cold load, which is the wrong bill for a phone on mobile
data. A stdlib middleware compresses the four content types this server
actually serves in bulk and leaves woff2 alone, since it is already compressed.
Measured on the live server: style.css 21.8 KB -> 6.5, htmx 50.9 -> 16.4,
filter.js 7.6 -> 2.9.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "Continue reading" strip and the Updated badge describe the whole library
but live outside the #list htmx swaps, so nothing corrected them after a
mutation: archiving a series left it sitting in the strip under "Continue
reading", with the badge still counting it. A tab switch had the same problem
from the other side — the strip was server-gated to All but could not be
re-rendered client-side, so /?tab=all reached by htmx and the same URL reloaded
produced different DOM.
Both regions move into chrome.html and always render, hidden when they have
nothing to say, so an out-of-band swap always has an id to target. /ui/list and
every mutation append them; the tab the reader is on comes from HX-Current-URL.
filter.js keeps only the job the server cannot do — hiding the strip while a
filter is active.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Continue reading" was the head of the same updated_at DESC list rendered
directly beneath it, so on a small library it said nothing new for ~240px of
the first phone screen. Scoping it to series with an unread chapter gives it
the one question the list does not already answer, since the list's order is
reading recency and its head *is* the old strip.
Consequence, accepted deliberately: with nothing new anywhere the strip does
not render at all, and the list starts at the top of the page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moving focus into an opened confirm row took the first button in DOM order,
which is the affirmative in all three rows — so the one irreversible action
opened with its destructive button pre-armed and a stray Enter would fire it.
That inverts the point of the gate the row exists to be.
The reversible rows still open on their affirmative; remove opens on Cancel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Esc closes whichever panel a card has open and returns focus to the cell that
owns it; opening a confirm row moves focus to its primary button, so the
announcement is deterministic and tab order stays inside the confirm.
- The inline error scrolls itself into view and no longer self-destructs after
5s: this reader gets interrupted mid-tap, and an expiring notice left a failed
write with no trace at all. It clears on the next request from that card.
- Every tab clears 44px wide (All was 15.25); desktop action cells go 40 -> 44.
- login.html's error gets role="alert".
- The card monogram carries its own aria-hidden — Chromium exposes it through
the cover link's, because that link is programmatically focusable.
- The busy bar ends its travel flush with the card edge, so .card.htmx-request
no longer needs overflow: hidden and cannot clip a panel mid-request.
Verified at 390 and 1280 in both themes; detector over templates+static reports
only the two known Instrument Serif false positives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The strip is the head of the same updated_at DESC reading list the All tab
renders, and app.html put it on every tab: on a small library it repeated the
list immediately below it while eating ~230px of the first phone screen, and on
Archived and Finished it showed reading series above the opposite bucket.
buildListView now fills Recent only for tab=all with more reading series than
the strip holds. Because the strip lives outside the htmx-swapped #list, an
in-page tab switch cannot re-render it, so applyFilter also decides its
visibility from the active tab; setActiveTab pokes it after a swap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The one irreversible path was defended with 40px buttons 8px apart and a
question — "Remove this?" — that never said what was leaving or what it cost.
Confirm buttons go to 46px with a 12px gap (all three rows), and the remove
question names the series and the loss. The row wraps, so a long title takes
the width it needs instead of overflowing; verified at 320 and 390 in both
themes with a 90-character title.
Button order is unchanged: the group is right-aligned with Cancel outermost, so
the destructive button already sits further from a right thumb's arc than
Cancel does, and all three rows keep the same shape.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stylesheet asserted twice that --ember only ever means "new chapter" while
spending it on eight things, including "Nothing new." — the heat signal firing
on its own negation.
Add a --danger token (a duller oxblood, tuned per theme) and move remove hover,
the open remove cell, the confirm wash and the login error onto it. Text-input
focus follows the .searchbar idiom and turns --paper, so a valid number field
no longer paints itself red. Drop .empty.hot. Ember now survives on the is-new
group, the Updated tab and the brand, which the law comment now says out loud.
Contrast, both themes: --danger on --ink 4.82 dark / 6.65 light, --danger-ink
on the solid button 4.94 / 7.30, --danger-soft on the wash 9.00 / 7.98.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The client filter only toggles card.hidden, so a query matching nothing left
the list area blank while the unfiltered "Continue reading" strip stayed
populated above it — the screen contradicted itself and read as a crash.
list.html now carries a hidden no-match block; applyFilter counts visible cards,
fills the query in with textContent, and hides .recent for the duration of a
filter. The Clear-search button is handled by a delegated click listener so it
survives an htmx tab swap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The userscript writes last_chapter/latest_chapter as "Chapter N" and the
poller writes the same label, so the templates' own "Ch " prefix doubled it on
every real row. A manual edit through the web UI stores a bare "250", so the
list carried two formats at once and stopped being scannable.
Move the formatting out of the templates into DisplayChapter/DisplayLatest on
Bookmark, which strip a leading "Chapter"/"Ch." lead-in and re-add exactly one
"Ch ", falling back to the numeric column when the label is empty.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contrast: --mute-2 and --trash both failed AA in both themes — every
10px mono label (recent chapter, section headings, chapter hint) sat
between 2.5:1 and 3.7:1, and the trash icon, the one irreversible
control, was the least visible thing in the strip. Retuned per theme:
labels now 4.8-5.2:1, trash 4.3-4.5:1 against a 3:1 requirement.
Focus: the search input clears its own outline and the underline lives
on the parent, so :focus-within on .searchbar carries the ring.
The inline error slot gets role=status, so a failed write is announced
rather than looking like an ignored tap.
The tab strip drops role=tablist/tab: these are real links that change
the URL, and the roles promised a tabpanel contract that was never
implemented, leaving aria-selected unset on every one. aria-current
carries the state instead.
prefers-reduced-motion now names pseudo-elements — `*` does not match
them, so the busy bar and the error dot, both ::before, kept animating
for the users who asked them not to.
Log out was a 54x15 target; an ::after pad makes it 78x45 without
moving the label.
Also: the two dark-tuned hex borders in the desktop block become
theme-aware tokens, and the preload swaps mono for DM Sans, the larger
face and the one the body text needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes the 2026-07-30 impeccable critique of the web UI.
Archive and finish now open their own .confirm-row disclosure, the
pattern delete already used, so no single tap moves a series out of the
list. Restore stays instant — it is the reversal. The two reversible
confirms wear grey; remove keeps the ember wash.
The six action cells cluster by consequence: navigate (play) | organize
(favourite, chapter) | lifecycle (archive/restore, finish, remove). The
lifecycle cells sit on a recessed ground, separated by a hairline on a
phone and by a gap on desktop, so the 4th-slot icon swapping between
.box and .restore no longer breaks position memory.
htmx:responseError branches on status: 401 offers a login link, 400
surfaces the server's own reason, everything else keeps the retry copy.
Busy bar and inline error move to --mute, leaving --ember exclusive to
the new-chapter signal.
Also: aria-live on the confirm rows, max on the chapter input, cover
width and row gap as tokens instead of a hardcoded 94px indent, and
mobile covers 30% larger.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>