Compare commits

...

1 Commits

Author SHA1 Message Date
sulthan 3a83161b1c 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>
2026-08-27 23:09:42 +07:00
32 changed files with 1567 additions and 237 deletions
+5 -1
View File
@@ -55,13 +55,17 @@ DISCORD_CLIENT_SECRET=
# The guild whose membership gates sign-in (Developer Mode -> right-click the
# server -> Copy Server ID).
DISCORD_GUILD_ID=
# Optional: human-readable name for that guild, shown on the login screen so a
# stranger knows which community owns this library and who to ask for an
# invite. When unset the page falls back to a generic "private community"
# label.
# DISCORD_GUILD_NAME=
# Exact callback URL, e.g. https://bookmark.example.com/auth/discord/callback.
# Discord matches it verbatim, so it must equal the registered redirect.
DISCORD_REDIRECT_URI=
# Optional: a role snowflake members must hold on top of guild membership.
# Empty (the default) means membership alone suffices.
# DISCORD_REQUIRED_ROLE=
# Subdomain Traefik routes to the browser UI (required by the prod override).
# Left commented on purpose: an example value here would be a silent
# wrong-hostname fallback, and Traefik would publish the UI router on a domain
@@ -0,0 +1,113 @@
---
target: backend/internal/web (admin surface)
total_score: 22
max_score: 40
na_heuristics:
p0_count: 1
p1_count: 2
timestamp: 2026-08-27T07-53-05Z
slug: backend-internal-web-templates-admin-html
---
Method: dual-agent (A: AssessDesign · B: AssessEvidence)
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | 2 | Domain status is excellent; *interaction* status is absent — no `hx-indicator`/`hx-disabled-elt` in any admin template (only `card.html:52,71,85,108,119` has them). |
| 2 | Match System / Real World | 3 | `Due`/`Checked`/`Gap` (`lanes.html:24`) are unlabeled integers and a raw Go duration (`admin_lanes.go:229-231`); `Never chk` (`overview.html:13`). |
| 3 | User Control and Freedom | 2 | Only Finish has Cancel + reversal (`series-detail.html:51-57`). Clear marks, Revoke, both Removes and both `Set` writes are one-way, and the old chapter survives only as a `placeholder` (`series-detail.html:19`). |
| 4 | Consistency and Standards | 2 | "Needs attention" is `--patina` on the Series list (`admin.css:285-287`) and `--danger` on Lanes/Sites (`admin.css:189-191`, `:409-413`); sibling controls are `<a href="#">` vs `<button>` (`series-list.html:51`); three duration formatters. |
| 5 | Error Prevention | 2 | Server-side gating is strong (`admin_lanes.go:82-90`, `admin_series.go:315-319`, `readers.html:36-42`), but `.ghost::after` inflates each target by 12px horizontally (`style.css:250`) against 12px/18px sibling gaps (`admin.css:303-305`, `:118-124`) — the overlap resolves to the destructive later sibling. |
| 6 | Recognition Rather Than Recall | 2 | `.thead { display:none }` below 899px (`admin.css:826-828`) and the Sites figures carry `Label: ""` from Go (`admin_overview.go:170-173`), so the labels exist at no width on a phone. |
| 7 | Flexibility and Efficiency | 3 | Bookmarkable state, figures-as-doors, OOB count refresh (`admin_series.go:503-515`); but the 30s `outerHTML` swap has no `hx-sync` (`lanes.html:12-13`) and there is no title search over 50-per-page (`admin_series.go:21`). |
| 8 | Aesthetic and Minimalist Design | 3 | Verified restrained on screenshot; the charge is density on the landing page — 11 hygiene figures + 4 library figures + a 6-column table before any content (`overview.html:9-13`). |
| 9 | Error Recovery | 1 | Every admin failure is a bare `http.Error`; htmx does not swap non-2xx; `admin.html:15` never loads `filter.js`, whose handlers are `.card`-scoped anyway (`filter.js:133-136`, `:172-187`). No error slot exists in any admin template. |
| 10 | Help and Documentation | 2 | Good in-context prose (`readers.html:10-15`, `series-detail.html:17`, `:26`), but nothing explains `Due`, `Gap`, or the six outcome words, and the chips are documented dead ends (`admin_lanes.go:242-246`). |
| **Total** | | **22/40** | **Acceptable — significant improvements needed** |
Adjudication note: Assessment A scored H4 at 1 and H8 at 2; both were raised one step after the parent verified the rendered pages (main flows are consistent and genuinely uncluttered on screenshot — the divergences are detail-level).
## Design Specificity Verdict
**Authored at the level of language and judgement; category-interchangeable at the level of composition and interaction.**
**LLM assessment.** The *writing* could belong to no other product. `laneState` emits sentences — `paused · resumes in 4h20m`, `refusing · backs off until 23:40`, `browser asleep`, `nothing eligible` — where a generic console prints `SKIPPED` (`admin_lanes.go:273-321`). `overviewVerdict` returns `no Lane has reported yet` with `HasCounts=false` so the page omits the counts clause rather than printing confident zeroes (`admin_overview.go:205-217`, `overview.html:7`). `door()` writes count and href as one atom so a figure cannot link to a list with a different number (`admin_overview.go:190-195`). `--patina` is declared as the admin's only accent with a stated reason and spent on the wordmark's `em` so the brand says which surface you are on (`style.css:91-94`, `admin.css:15-21`). `CanPoll`/`CanRemove` are visibility, not disablement (`admin_series.go:668-675`).
The *layout and interaction* would ship unedited as a Kubernetes admin: topbar + 4-tab underline navrow + micro-label + `repeat(auto-fit, minmax(232px,1fr))` stats grid + wide table (`admin.html:19-46`, `admin.css:30-63`, `:313-318`, `:236-241`). The uppercase-mono micro-label idiom appears at seven sizes between 10px and 13px with tracking from `.04em` to `.2em` (`admin.css:69-79`, `:202-207`, `:328-333`, `:531-536`, `:595-604`, `:732-738`) — a style, not a system: nothing tells the reader which size means what. And the destructive interactions are htmx's default `hx-confirm`, which is conspicuously *not* the pattern the Reader-facing library authored for itself (`card.html:113-121`).
**Deterministic scan.** CLI detector over all six admin templates: **exit 0, `[]`, zero findings.** Nothing to dismiss as a Go-template false positive.
**Browser evidence.** All five admin pages were rendered from the repo's own templates via a throwaway Go module with realistic fixtures covering every state the Go code can produce, then measured in Playwright at 390×844 and 1280×900. **No horizontal overflow, no clipping, no overlap on any page at either viewport** (overview `scrollW 375 < 390`; readers `390 == 390`; all `.tbl` grids collapse to flex-wrap under 899px, lanes additionally under 1019px). Injected detector fired 9 `text-overflow` (brand `em` 41px; `span.mark.bad` 33px; `span.ok` 47px; `a.ghost.act` 22px ×5), 22–48 `ai-color-palette` "cyan neon" per page, one `cream-palette`, and `overused-font` at 16–27%. **All are false positives**: the overflows are inline boxes spilling into their own cell's padding inside the border box (`1014 < c-ctrl left 1038`), the "cyan" is every `--patina` usage, the cream is the designed light-branch `--ink` (`style.css:125`), and the font is the deliberate `--font-display` on brand and headings only. The one real signal inside the noise is *frequency*: 22–48 patina elements per page means the admin's single accent is doing a great many different jobs.
No user-visible overlay is left in a browser — the live server was injected, read, and stopped.
## Overall Impression
The thinking behind this surface is better than the surface. Judgement is computed in Go and the template only prints, so the page structurally cannot lie about its own numbers — that is rarer than it sounds and it is why the copy can be as confident as it is. What has not been spent is the *feedback and repair* budget: the owner comes here to fix one wrong row, and the surface will not tell them whether the fix landed, will not label the columns on the device they are holding, and hands the two irreversible actions to a browser dialog while spending a hand-built confirm row on the reversible one.
**Single biggest opportunity:** make the admin surface as accountable for its own actions as it already is for the poller's. Load the error path, add busy state, and move destruction onto the confirm-row pattern the codebase already owns.
## What's Working
1. **Judgement is an atom.** `laneState` returns `(phrase, good, attention)` together (`admin_lanes.go:273-321`); `door()` binds count to href (`admin_overview.go:190-195`); `overviewVerdict` documents that it deliberately answers a *different* question from `laneState` so page and notification cannot contradict each other (`admin_overview.go:197-204`). This eliminates a whole bug class: a colour that disagrees with the sentence beside it.
2. **Zeros and unknowns are honest, and the design pays for it.** A measured zero loses its link and drops to `--mute` (`admin_overview.go:190-195`, `admin.css:341-343`); a virgin pass log suppresses the counts clause entirely; a failed outcome sum degrades per row to `none observed` instead of blanking the table (`admin_lanes.go:172-180`). Health consoles usually fail here — a page of confident zeroes reading as health.
3. **Removal from the list is a complete interaction.** `HX-Reswap: delete`, OOB heading count re-rendered over the press's own filter state, and if a Bookmark raced the press the row swaps *back in* with the reason in-row (`admin_series.go:474-515`, `series-list.html:52`, `:62`). Success, contention and refusal land in the same slot at the same scale. Every other action should look like this.
## Priority Issues
**[P0] An htmx failure on the admin surface is completely invisible.**
- *Why it matters:* every admin error path is `http.Error` (`admin.go:98`, `:109`, `:136`, `:155`; `admin_lanes.go:118-128`; `admin_series.go:307-322`); htmx does not swap non-2xx; `admin.html:15` loads only `htmx.min.js`, and `filter.js`'s `htmx:responseError`/`htmx:sendError` handlers are both unloaded here *and* `.card`-scoped (`filter.js:133-136`, `:172-187`). Verified: no admin template contains `error-inline`, `hx-indicator` or `hx-disabled-elt`. A rejected chapter correction or a dropped LAN connection produces **zero visual change**. The owner's natural response is to press again, and with no `hx-disabled-elt` the second press is a second write. This is the corrective-task path; it breaks trust exactly where trust is the product.
- *Fix:* load `filter.js` from the admin shell (or extract its error module) and generalise `showError`'s `.closest(".card")` to a configurable slot; add `<p class="error-inline" role="status" hidden>` to each `.dform`, `#readers` and the `.trow` — `.row-msg` (`admin.css:672-677`, `series-list.html:52`) already proves the row can carry a message; add `hx-disabled-elt="this"` to every writing control. The `http.Error` bodies are already short human sentences worth showing verbatim.
- *Suggested command:* `/impeccable harden`
**[P1] Below 899px the tables lose their only labels, and the Sites table never had any.**
- *Why it matters:* `.thead { display:none }` (`admin.css:826-828`; `:793-795` for lanes at 1019px) with cells flattening to equal-weight left-aligned text. The Sites figures are `door("", …)` ×4 (`admin_overview.go:170-173`), so those labels exist at **no width** on a phone. Measured and screenshot-confirmed: a Lanes row on a phone reads `3 2 4m30s ran 2m ago` — four bare values. The Series grid survives (title owns its line, site is a coloured word, ages self-label as `3h ago`); Lanes degrades; Sites does not survive, and it is on the landing page. The primary device is a phone.
- *Fix:* give collapsed cells their labels instead of hiding the header — populate `fig.Label` for site rows (the field exists, `admin_overview.go:43-47`) and render `.trow > *[data-lbl]::before { content: attr(data-lbl) " " }` inside the 899px block in the existing 11px `--mute-2` idiom; same for `Due`/`Checked`/`Gap`; add `.tbl.sites .c-site { width: 100% }` so the site name owns its line as `.c-title` already does.
- *Suggested command:* `/impeccable adapt`
**[P1] Destructive actions are gated by native browser dialogs while the reversible one gets the bespoke confirm.**
- *Why it matters:* `hx-confirm` on Clear marks (`readers.html:31`), Revoke sessions (`:39`), list Remove (`series-list.html:51`) and detail Remove (`series-detail.html:40`) — versus a hand-built `.confirm-row calm` with Cancel and `aria-live` for **Finish**, which is reversible via Un-finish (`series-detail.html:51-57`, `admin_series.go:257-294`). Inverted effort. A native dialog cannot carry `--danger`, so "sign this Reader out of every device" and "clear some counters" look identical at the decision moment, and it is OS chrome inside a sheet that has removed every corner and shadow. The full `--danger-wash` + `.danger-solid` pattern already exists and is already used by the Reader-facing card for *its* irreversible remove (`style.css:688-717`, `card.html:113-121`). AGENTS.md states the law directly: any move that pulls a series out of the list must be confirm-gated via its own `.confirm-row`.
- *Fix:* move Revoke and both Removes onto `.confirm-row` with `--danger-wash`/`.danger-solid`, Clear marks onto `.calm` (it restores a privilege, `admin.go:142-147`). The confirm copy transfers verbatim. Note `admin.css:307-311` already styles `.tbl .trow > .confirm-row` at `grid-column: 1/-1` — **that rule is dead today**; the CSS is waiting for the markup.
- *Suggested command:* `/impeccable harden`
**[P2] `--danger` is used to mean "system unhealthy", which the token law forbids, and "needs attention" is spoken in two colours.**
- *Why it matters:* `style.css:91-94` states `--patina` is the admin page's only accent and that neither ember nor danger may say "system unhealthy". But `admin.css:409-413` and `:189-191` paint `.c-state.bad`, `.c-skip .bad` and `.trow.attention .c-site` in `--danger` — screenshot-confirmed: `refusing · backs off until 14:20` and `not checking` render oxblood, and the attention site names go red — while the identical semantic on the Series list is verdigris (`admin.css:285-287`). Colour is the owner's fastest read, and cross-page it does not resolve. Separately `.c-state`'s base colour is `--patina` with no `.ok` rule, so `no pass yet` — an *unknown* — renders in the healthy accent.
- *Fix:* route all attention through `--patina`; reserve `--danger` for Remove/Revoke and their wash. If a second severity tier is genuinely wanted for `refusing`/`not checking`, declare a token in both `:root` branches rather than borrowing destruction's colour. Give `.c-state.ok` an explicit rule and make base `.c-state` neutral so an unlabeled state is not a claim.
- *Suggested command:* `/impeccable colorize`
**[P2] Phone hit targets: the destructive sibling wins ambiguous taps, and the pager is untappable.**
- *Why it matters:* `.ghost::after { inset: -15px -12px }` (`style.css:250`) inflates each ghost by 24px of combined horizontal overhang against a 12px gap in `.c-act` (`admin.css:303-305`) and 18px in `.reader-actions` (`:118-124`) — the pseudo-elements overlap and the later sibling paints on top. The later sibling is `Remove` and `Revoke sessions`. Measured at 390px: Check now 76×16, Remove 50×16, Clear marks 92×16, Revoke 126×16, pager `next ›` 49×11, pause `<select>` ~31px tall, `.fig` links 9–34×20–24, navrow links 41–42px high. `.pg.disabled` sits at `--faint` = **1.62:1** — "no next page" reads as "the control is missing". The scene is one thumb, at night.
- *Fix:* raise the `.c-act`/`.reader-actions` gap past 26px, or put the destructive control on its own line at phone width; give `.pg`, `.segrow a`, `.fig` and the pause select real 44px boxes inside the 899px block; add `line-height` to `.navrow a`.
- *Suggested command:* `/impeccable adapt`
## Persona Red Flags
**Alex (power user / single operator — no colleague, no runbook).** The failure chips are permanently non-navigable *by design* (`admin_lanes.go:242-246`), so `refused 14` is a dead end: fourteen refusals and no path to a single affected Series. Nothing on the surface defines `Due`, `Gap`, or the six outcome words. No title search over a 50-row page (`admin_series.go:21`), no bulk action, no keyboard path to anything but tab order.
**Sam (accessibility / low-vision).** `aria-live="polite"` wraps the self-refreshing Lanes block (`admin.html:38`), which replaces itself every 30s — a screen reader re-announces the whole table twice a minute — while `#readers` and `#detail-meta`, the fragments that change *because the owner acted*, have no live region at all. The politeness is on the wrong element. Revoke's only success signal is the button's absence. The micro-label idiom bottoms out at 10px uppercase mono with `.2em` tracking in `--mute-2` (`admin.css:708-720`, `:732-738`) — `#877f76` is 4.86:1 on `--ink`, and `style.css:67-68` says that budget was set for 10px mono specifically, i.e. it is at the edge, not above it. On banded rows `--danger` measures 4.46:1 and `--mute-2` 4.49:1 against `--hover` (`admin.css:495-497`) — both just under AA.
**Casey (thumb-only mobile).** Fails the hit-target cluster above, and specifically the **30s refresh racing the pause select**: `lanes.html:12-13` swaps `#lanes` outerHTML every 30s with no `hx-sync` while the duration is a native `<select>` inside that fragment (`lanes.html:47-49`). Open the picker, scroll to `24h`, straddle a refresh boundary — the select is rebuilt at its `selected` default of `6h`, silently. The owner then gets six hours and a perfectly accurate confirmation phrase saying so.
**Project-specific — the owner on a phone, at night, mid-chapter, fixing one wrong row.** The surface's true primary scene, and it fails in sequence: (1) *finding* the row — filter/site/kind but no title search, so they page through 50-row pages using the 11px zero-padding pager whose disabled state is invisible at 1.62:1; (2) *reaching* the fix — the correction form exists only on the detail page (`series-detail.html:15-23`), one hop past a list row whose action slot already carries two controls; (3) *knowing it worked* — the P0 case exactly. And the one visual anchor confirming "yes, this is the right series" is squashed: `style.css:489` sets `height: calc(var(--cover-w) * 4/3)` = 124px, which `admin.css:708-711` never overrides while setting `width: 160px` and `aspect-ratio: 3/4` — both dimensions definite, so `aspect-ratio` is ignored and portrait artwork renders as a **160×124 landscape crop**. Confirmed on the rendered page.
## Minor Observations
- **"1 readers."** `series-detail.html:72` prints `{{.Readers}} readers` over an `int` (`admin_series_detail.go:30`) — screenshot-confirmed on the detail page. `readers.html:20` pluralizes `session{{if ne .Sessions 1}}s{{end}}` correctly four files away.
- **First-run Overview has no empty state.** No `{{else}}` on any of the three blocks; a fresh install renders the verdict line, 11 zeros, 4 zeros, then a `.tbl.sites` **header row with nothing under it**. `lanes.html:56-58` and `series-list.html:34` both guard properly; the one page a new owner sees first does not.
- **~60 lines of dead CSS.** `.lanelist`/`.lane-site`/`.lane-fact`/`.lane-mark`/`.lane-browser` (`admin.css:100-116`, `:141-143`, `:160-191`) — `lanes.html` uses `.tbl.lanes`. `.tbl .trow > .confirm-row` (`:307-311`) has no markup. `.c-state.ok` is emitted but unstyled. `.act` is applied at `series-list.html:51` and `series-detail.html:35` and **defined nowhere**.
- **`admin.css:5-9`** is a `prefers-color-scheme: light` block redefining `--measure-wide` to the identical `1080px`. Delete it; it teaches the next reader a lie about there being a width to keep in sync.
- **The no-cover placeholder has typography but no content.** `.cover` gets a centred 10px uppercase `--mute-2` label idiom (`admin.css:708-720`); `series-detail.html:12` renders an empty hatched box. `NoCover` already exists (`admin_series_detail.go:142`) — print it, or drop the styling.
- **`Check now` is an `<a href="#">` carrying `hx-post`** (`series-list.html:51`, `series-detail.html:35`) while `Remove` beside it is a real `<button>`. Long-press, middle-click and no-JS keyboard activation all navigate to `#`.
- **Three duration formatters**, one of them the stdlib's: `since()` prints raw Go durations (`admin_lanes.go:344-350`) so `Gap` reads `1m30s`, while `checkedAge` (`admin_series.go:748-765`) and `humanDuration` read like English.
- **The Overview's 11 hygiene labels are the Series page's 11 Show options** (`admin_series.go:27-40`) in two shapes; the select already carries the counts *and* attaches narrowing, which is arguably the better of the two presentations.
- **`.stat` rows have no separators** (`admin.css:320-326`). On the rendered desktop page a 4-column row reads `NO SERIES URL 3 NEVER READ A CHAPTER 0` — figure and next label collide into one phrase with only a 16px gap to separate them.
- **`readers.html` has no empty state.** Unreachable in practice (the owner is always in the roster), but both siblings guard.
## Questions to Consider
1. **Why does the Overview exist?** Fifteen figures and a table, where 11 figures duplicate a select that already shows the same counts *with narrowing attached*. Strip it to the verdict line, the four Library figures and the Sites table — what is lost?
2. **If a chip can never be a door, should it still be a count?** `refused 14` invites a tap that will never exist (`admin_lanes.go:242-246`). Is the honest answer `refused · 14 attempts`, or that Lanes needs the per-Series drill-down the taxonomy currently cannot provide?
3. **The surface built a Cancel for Finish and handed Revoke to the browser.** If the reasoning is that Revoke's `hx-confirm` sentence is already unambiguous, why does the Reader-facing library still spend a `.confirm-row` on *its* remove? Which surface has the wrong standard?
4. **What is `--danger` for?** The token says destruction; the law says only `--patina` may say "system unhealthy"; the CSS paints `refusing` in danger. Either a third severity token gets declared in both branches, or Lanes comes back under the law. Which — and how did the surface run this long with both readings?
5. **Is 30 seconds a design decision or a default?** It polls tab-hidden, clobbers a half-operated select, and on a dimmed phone nobody sees it. Would `refreshed 40s ago · tap to refresh` be cheaper *and* more honest?
@@ -0,0 +1,135 @@
---
target: backend/internal/web
total_score: 27
max_score: 40
na_heuristics:
p0_count: 1
p1_count: 2
timestamp: 2026-08-27T12-24-48Z
slug: backend-internal-web
---
Method: dual-agent (A: AssessmentA/designer · B: AssessmentB/task — B crashed before yielding; its detector artifacts were recovered from `history://AssessmentB` and `/tmp/impeccable-critique`, and the browser overlay pass was re-run in the parent)
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | 3 | Busy hairline, per-card error slots, 15s htmx timeout, OOB chrome refresh all good — but a *successful* mutation announces nothing, and `hx-delete` swaps the card for an empty body in total silence (`card.html:124-134`, `web.go:569-573`). |
| 2 | Match System / Real World | 3 | Reader copy is human ("Chapter you're on", `card.html:95`); the owner's view leaks implementation vocabulary — "Sighting counters", "deferral blocked", "Lanes", "band" (`readers.html:10-14`, `lanes.html:15`). |
| 3 | User Control and Freedom | 2 | Esc closes panels and restores focus (`filter.js:120-150`); removal is irreversible with no undo (`web.go:558-573`), and `readers.html:53` Cancel restores neither `aria-expanded` nor focus. |
| 4 | Consistency and Standards | 2 | Three confirm implementations: focus-managed JS (`card.html:70,83`), bare inline `onclick` (`readers.html:31,34`; `series-detail.html:47,70`), native `confirm()` (`setup.html:29`). |
| 5 | Error Prevention | 3 | `max="9999"` guard, no-op submit is a no-write (`web.go:527-531`), restore fires instantly *because* reversible. Docked for `onchange="this.form.submit()"` (`series-list.html:9,12`) — WCAG 3.2.2 On-Input. |
| 6 | Recognition Rather Than Recall | 3 | The permanent action key answers the unlabelled icon strip (`chrome.html:35-45`) but teaches a second vocabulary: "Delete" vs `aria-label="Remove"`, "Read" vs "Continue reading", "Fav" vs "Toggle favourite". |
| 7 | Flexibility and Efficiency | 2 | Esc is the only accelerator. No skip link, no `/`-to-search, no bulk action, no sort; 200 series get one substring filter. |
| 8 | Aesthetic and Minimalist Design | 3 | Containerless sheets, one `--measure`, three faces each with one job — but **563px of chrome measured** before the first card at 390×844, and the inert action key renders as a row of buttons. |
| 9 | Error Recovery | 3 | `filter.js:180-250` is the best-designed code here: 401 → "Session expired — nothing was saved" + login link, echoes the server's 400 reason, no auto-dismiss with the rationale written down. Docked: the echoed reasons are engineer strings ("missing key", "invalid status", `web.go:446,456,513`). |
| 10 | Help and Documentation | 3 | Setup panel is honest documentation incl. the Violentmonkey-on-mobile caveat (`setup.html:17-20`); empty states teach. Docked: Lanes/Overview ship an ops vocabulary with zero explanation. |
| **Total** | | **27/40** | **Acceptable — significant improvements needed** |
## Design Specificity Verdict
**LLM assessment — split: the reader library is authored; `/admin` is a generic ops console wearing Cinder tokens.**
Library-side evidence that could not be lifted into another product: `--danger` exists as a separate token *specifically* so a remove confirm is never misread as an unread chapter across a dark room (`style.css:77-79`); the busy indicator is a grey hairline that deliberately refuses the accent (`style.css:840-852`); source tokens are named for the actual scraped sites (`style.css:110-117`); `--hatch` exists because `og:image` is often absent and the slot must stay honest rather than fake artwork (`style.css:119-122`); the monogram sits *under* the `<img>` unconditionally so a stored-then-404'd cover degrades to a letter (`card.html:9-19`); `overflow-wrap: anywhere` is justified by a measured 789px-wide `og:title`; `step="any"` by a real series read to 1200.25; the Updated tab exists only for manga because novels have no poller (`app.html:64-70`).
Admin-side: Overview is a stat grid plus a site table (`overview.html:7-15`); Series is a filter bar with two auto-submitting selects, a paged table and `‹ prev / next ›` (`series-list.html:7-70`). Strip `--patina` and it is any 2014 Rails admin. It also contradicts PRODUCT.md's own principle that the owner's panel is "one list with one button, not an admin console."
**Deterministic scan.** `node detect.mjs --json backend/internal/web/templates` → **exit 0, `[]`, zero findings** — and that is a degraded scan, not a clean bill: the static-HTML engine's parser deps (`htmlparser2`, `css-select`, `css-tree`, `domutils`) are absent from the skill install, Go templates are fragments rather than full pages, and `<link href="/static/style.css">` is absolute so no CSS ever cascades from a template directory. Assessment B worked around all three — rendered the real templates through `html/template` in a throwaway Go program outside the repo, copied `static/` beside them, `npm install`ed the four parsers into a `/tmp` engine copy — and got **64 findings** over the rendered pages: 29 `undersized-ui-text`, 12 `cramped-padding`, 9 `low-contrast`, 7 `overused-font`, 7 `cream-palette`. Re-run against a dark-only stylesheet (light + `min-width` blocks stripped, because the engine discards `@media` conditions and merges every block last-wins): **103 findings** — 71 `undersized-ui-text`, 25 `low-contrast`, 7 `overused-font`.
Agreements and false positives: the detector's `low-contrast` hits land on exactly the pair Assessment A computed by hand — `--ember #e0452c` on `--ember-wash #221311` at **4.3:1** — independent confirmation of the P1 below. `overused-font` (Instrument Serif at 34% of text) and `cream-palette` are false positives: both are the committed brief. Most `cramped-padding` is an artifact of the engine flattening `@media` conditions. `text-occlusion` on "Rotate credential" is a false positive from the collapsed `<details>`.
**Visual overlays.** Injection succeeded. Overlays were rendered live in the browser tab at 390×844, dark, over the real stylesheet, and the in-page detector reported **19 anti-patterns**: 2× `low-contrast` (`#e0452c` on `#221311`, 4.3:1), 11× `undersized-ui-text` (10px "Manga", "Novels", "Userscripts", "Read", "Fav", "Chapter", "Archive", "Delete", "Continue reading", "Chapter you're on", "Latest known: …"), 1× `overused-font`, 1× `text-occlusion` (FP). Two measurements taken in the same browser:
- **First card top = 563px** on 390×844 — topbar 102 + search 44 + tabs 46 + keyrow 50 + setup 57 + recent strip 250. Exactly one list row on first paint, on the primary device. Assessment A predicted ~560 from the stylesheet alone.
- **Desktop overflows with no content.** `empty.html` at 1280×800: `zoom: 1.2` on `:root` × `min-height: 100dvh` on `.sheet` = 960px in an 800px viewport, so an empty library scrolls 20%. `.login-card` divides `--zoom` back out (`style.css:885-886`); `.sheet` does not (`style.css:234-237`).
## Overall Impression
The reader library is one of the more disciplined self-hosted UIs I've reviewed: one heat colour that means exactly one thing, a token file whose comments record contrast arithmetic and the bugs each rule was written against, and error handling designed by someone who has been interrupted mid-tap on a phone. What it does not have is a non-visual user. Every one of the five primary actions swaps its own DOM node out from under the focused element with no announcement and no focus move — the interface's most careful work is all visual, and its least careful work is everything a screen reader depends on. Second biggest opportunity: the first screen. 563px of learn-once chrome ahead of the one row the reader opened the app to see.
## What's Working
1. **The ember law is enforced by omission, not convention** (`style.css:77-94`, `840-852`). Splitting `--danger` from `--ember`, giving the busy bar a grey hairline instead of the obvious accent, and picking `--patina` from the far side of the wheel for admin means that at 2am one colour on screen has one meaning. Most systems declare that rule and leak it inside a month.
2. **`filter.js:180-250`.** Routes failures to the nearest meaningful slot, distinguishes "session expired — *nothing was saved*" on a write from the same 401 on a navigation, echoes a 400 reason only when it looks like a reason rather than an error page, and refuses an auto-dismiss timer with the reason written in the code.
3. **The cover fallback is correct in the way that only comes from being burned** (`card.html:9-19`). Monogram always beneath the image plus `onerror="this.remove()"` handles the case everyone forgets — a cover stored successfully and later 404ing — where the naive `{{else}}` leaves a broken-image glyph in a 93px slot.
## Priority Issues
**[P0] htmx swaps destroy focus and announce nothing, on all five primary actions.**
Favourite (`card.html:56-58`), restore (`:75-78`), archive (`:115-117`), chapter save (`:89-92`) and remove (`:124-127`) all target the card with `hx-swap="outerHTML"`. The `<article>` is in no live region and nothing moves focus. Remove is worst: `web.go:569-573` answers with an empty body, so the focused button and its container both vanish and focus falls to `<body>` in silence — indistinguishable from a crash. That the fix is known is proven three files over: `series-detail.html:91` puts `aria-live="polite"` on `#detail-meta`, so the owner's form announces itself while the reader's card does not.
**Fix:** one persistent visually-hidden `<div role="status" id="sr-announce">` in `app.html` and `admin.html`; emit the result text into it out-of-band from each mutation handler ("Archived Berserk"). For removal, move focus to the next `.card`'s play cell (or `#list` when it was the last row) in an `htmx:afterSwap` handler beside the existing hook at `filter.js:48`.
**Suggested command:** `/impeccable harden`
**[P1] The ember state indicator fails WCAG AA at 4.3:1 — the one pair the whole design rests on.**
Hand-computed by A and independently confirmed by the in-browser detector: `--ember #e0452c` on `--ember-wash #221311` = **4.33:1** for `.new-chapter` (11px mono, `style.css:609` on `.card.is-new` ground `:534`), the active `.tab-new` (`:392`), and the `.count` badge (`:394-401`). `--ember` on `--ink` passes at 4.61 — this is the case that was checked against the page but never against its own tinted background. Light branch source labels fail too: `--lightnovelworld` 4.25, `--demonic` 4.48, `--novelfull` 4.49, all dropping further on `--ember-wash`.
**Fix:** dark `--ember` → ≈`#e85a41` (4.98 on `--ember-wash`, 5.31 on `--ink`), or darken `--ember-wash` to `#1c0f0d`. Light: `--lightnovelworld: #47705f`, `--novelfull: #6f6244`, `--demonic: #7b5d4a`. Verify against `--ember-wash`, not only `--ink`.
**Suggested command:** `/impeccable audit`
**[P1] Control boundaries are invisible: `--field-line` is 1.32:1 against the page.**
`--field-line #2c2926` on `--ink` = 1.32:1 (light branch 1.44:1), and it is the *only* boundary for the chapter input (`style.css:731`), every `.ghost` — Log out, Admin, install links, Rotate (`:273`), the library switch (`:623,633`), Clear search (`:867`), and **Cancel inside the remove confirm** (`:767`). WCAG 1.4.11 wants 3:1 for a control's identifying boundary. At the highest-stakes moment the design gives destruction a filled `--danger` slab and safety a 12px `--mute` label in a box nobody can see.
**Fix:** raise `--field-line` to ≈`#4a4540` (3.06:1 on `--ink`) for interactive borders; leave `--rule`/`--rule-soft` decorative but stop using `--field-line` as if it were. Give Cancel real button weight in the confirm row — it is the recommended action.
**Suggested command:** `/impeccable audit`
**[P2] The admin surface renders "healthy" and "broken" identically.**
`.mark.bad` (`admin.css:396-400`), `.mark` (`:362-366`), `.mark.mark-strong` (`:376-383`), `.c-state.ok` *and* `.c-state.bad` (`:425-430`), `.c-skip .ok` *and* `.c-skip .bad` (`:431-449`) all resolve to the same `--patina` / `--patina-wash` / `--patina-line` triple, so `lanes.html:18` paints "unreachable" and "reachable" in one colour separated by a 7px dot, and `overview.html:15` chips are pixel-identical. The page whose purpose is "is anything wrong at a glance" now has to be read chip by chip. The one-accent law was meant to keep ember and danger off admin, not to collapse ok into bad.
**Fix:** keep `--patina` for good, give `.bad` the existing `--slate` family (`--slate-soft` on `--slate-wash` = 6.06:1) plus a glyph or word so the distinction is not colour-alone.
**Suggested command:** `/impeccable colorize`
**[P2] Two unlabelled write inputs and two auto-submitting selects on the owner's most consequential page.**
`series-detail.html:19` (`type="number" name="chapter" placeholder="{{.Chapter}}"`) and `:29` (`type="url" name="series_url"`) have no `<label>`, `aria-label` or `aria-labelledby`; the placeholder is the *current value*, so a screen reader hears a bare number as the field's name — WCAG 3.3.2/4.1.2 on a form that writes a value every Reader sees. `series-list.html:9,12` carry `onchange="this.form.submit()"`, making the filter unusable from a keyboard (first arrow-key navigates). Three lines away the same file gets it right (`<label class="fsel">`), as does `lanes.html:49`.
**Fix:** real `<label for>` on both, current value moved from `placeholder` into a hint line; replace auto-submit with a visible Apply button.
**Suggested command:** `/impeccable clarify`
**[P2] Desktop overflows with an empty library (browser-verified).**
`style.css:955` sets `zoom: 1.2` on `:root` at ≥1280px; `.sheet` keeps `min-height: 100dvh` (`:234-237`) without dividing `--zoom` back out, the way `.login-card` correctly does (`:885-886`). Measured live: `empty.html` at 1280×800 → sheet 960px, scrollbar on a page with no content. The `--zoom` token comment at `:129-131` names this exact hazard.
**Fix:** `min-height: calc(100dvh / var(--zoom))` on `.sheet`, matching `.login-card`.
**Suggested command:** `/impeccable adapt`
## Persona Red Flags
**Sam (screen reader, keyboard-only, 200% zoom, low vision)** — the hardest-hit persona:
- Focus destroyed and nothing announced on all five card actions; remove is total silence (`card.html:56,75,89,115,124`; `web.go:569-573`).
- Favourite never exposes state: static `aria-label="Toggle favourite"` overrides the dynamic `title`, no `aria-pressed` (`card.html:53-55`).
- The pencil is a disclosure with no disclosure semantics — no `aria-expanded`/`aria-controls` in markup, while archive and remove three lines away have both (`card.html:61-62` vs `:68-69,81-82`).
- Broken heading order: `<main id="list">` has no heading, so every card `<h3>` nests under the sibling `<h2>Continue reading</h2>` — by heading navigation all 40 series appear to live inside the 5-item strip (`chrome.html:11`, `app.html:92`, `card.html:25`). Two `<h1>`s on series detail (`admin.html:25` + `series-detail.html:9`).
- `aria-label` on a bare `<div class="keyrow">` with no role is dropped: the legend announces as five orphan words (`chrome.html:35`).
- Search results never announced: filtering toggles `card.hidden` with no live region and no count; `#no-match` is unhidden without `role="status"` (`filter.js:12-32`, `list.html:7`).
- `.play` and `.remove` have no `:focus-visible` rule while `.fav`, `.pencil`, `.box`, `.restore` do — tabbing a card, three cells light and two do not (`style.css:677-689`). Two inputs drop the focus ring for a 1px border change (`:355,735`). Exactly one `:focus-visible` outline exists in the codebase and it is scoped to `.admin-sheet .ghost` (`admin.css:734-738`).
- No skip link past ten chrome controls (`app.html:26-92`). `.tabs`/`.recent-strip` scroll horizontally with `scrollbar-width: none` and no fade, so at 200% zoom "Archived" is off-screen with no cue (`style.css:360-369`).
- Login refusal is never announced: `role="alert"` on markup present at parse time does not fire, and the page reloads on failure (`login.html:24-25`, `discord.go:184-185`).
- Correct and worth keeping: `alt=""` on the decorative cover inside an `aria-hidden` duplicate link, with the title as a real `<h3>` beside it (`card.html:7-19`). Measured hit targets are honest — tab 44px, card action cell 46px.
**Alex (impatient power user)**:
- One list row at 563px on first paint; the chrome that costs it is all learn-once content. The recent strip hides itself when empty (`chrome.html:10`); the five-item legend never does (`:35`).
- Esc is the only keyboard accelerator in the product (`filter.js:120-150`). No `/`, no `j/k`, no Enter-to-continue.
- No bulk actions, no sort: archiving ten dead bookmarks is 30 taps.
- His fast path evaporates on the first keystroke — the strip hides while filtering (`filter.js:22-24`) and is server-suppressed on every tab but All (`web.go:344-350`).
- Removal is irreversible and thumb-adjacent as the fifth cell in a five-cell strip (`web.go:558-573`).
**Jordan (confused first-timer)**:
- The same install action appears twice on the first screen, framed differently (`list.html:22-26` under `setup.html:11-14`).
- A five-item action legend renders for a list with zero rows (`chrome.html:35`), and it *looks* like a toolbar — icon-plus-label cells in a bordered row, visually indistinguishable from the card action strip it explains, yet inert. Confirmed in the screenshot at both 390px and 1280px.
- "USERSCRIPTS" renders as a bare mono label with no caret or affordance; that it is a `<details>` summary is invisible (`setup.html:8`, screenshot).
- Five unlabelled icons per row explained by a legend using different words ("Delete" vs "Remove").
- Cancel is nearly invisible beside a filled Remove (`style.css:764-778`).
- "Archive this?" never says what archiving does; the copy that explains it ("it keeps getting checked for new chapters") only appears *after* he archives something (`card.html:112`, `list.html:16`).
- Worst-case sign-in is an unstyled plaintext `internal error` (`discord.go:196,204`).
## Minor Observations
- `--faint-2 #57504b` monogram on `--hatch` ≈ 2.19:1 — decorative by declaration (`aria-hidden`), but it is the only identity a coverless series gets (`card.html:19`).
- 2.7MB PNG on the login page (`static/login-art.png`, `login.html:20`) — the first bytes a phone on mobile data receives, on a one-button page.
- Server error strings surface verbatim and capitalised to readers: "Missing key.", "Not found.", "Invalid status." (`filter.js:216-221` ← `web.go:446,456,513`).
- Five verbs for three concepts: Delete / Remove / Un-finish / Shelve / Archive.
- `.keyrow` is not conditioned on library: novels have no Updated tab but get the same five-key legend.
- `readers.html:59` uses `<p class="empty">` where every other empty state is `<div class="empty"><strong>…</strong><p>…</p></div>`.
- `series-detail.html:54` cancels via `document.querySelector('.dform .danger')` — page-global, unique only by luck.
- Worth preserving: `prefers-reduced-motion` naming pseudo-elements because `*` does not match them (`style.css:1031-1035`); `hx-sync` on the self-refreshing Lanes fragment (`lanes.html:12`); `.ghost::after` hit-target expansion (`style.css:282`); the coarse-pointer block keyed to input method rather than viewport (`admin.css:983-1027`); the confirm row focusing *Cancel* on the irreversible action and the affirmative on the reversible one (`filter.js:106-114`).
## Questions to Consider
1. The action key exists because the icon strip cannot be read — so why is the strip still unlabelled? A permanent inert legend that looks like a toolbar costs more vertical space and more confusion than labelled cells or one overflow control would.
2. PRODUCT.md says the owner's panel is "one list with one button, not an admin console." It is now four nav tabs, a paged table and a per-series detail page. Did the principle change, or did the surface grow while nobody held it to the principle?
3. The ember law was enforced so literally on admin that "reachable" and "unreachable" now render identically. When a design law starts destroying the distinction the surface exists to make, is it still a law or a habit?
4. Removal is irreversible, thumb-adjacent and silent to a screen reader, gated only by a confirm. Why is the confirm the whole safety story rather than an optimistic remove with a 10-second undo in the existing `#notice` slot? The confirm interrupts every removal; undo interrupts none and still catches the mistake.
5. The strip disappears when nothing is new, and the list is already ordered by reading recency. If the strip only renders when Updated is also non-empty, is it 250px of duplicated answer — and should the first screen simply *be* the Updated bucket?
@@ -0,0 +1,130 @@
---
target: backend/internal/web
total_score: 26
max_score: 40
na_heuristics:
p0_count: 0
p1_count: 2
timestamp: 2026-08-27T14-36-34Z
slug: backend-internal-web
---
Method: dual-agent (A: AssessDesign-2 [designer] · B: AssessDetector [scout])
Note: Assessment A completed its analysis but hung in a yield loop after writing its report; recovered from its on-disk artifact and cancelled. Two of its claims failed parent verification and are corrected below.
## Design Health Score
Mode: **Operate** (all 10 heuristics apply; none n/a).
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | 3 | Busy hairline is grey, not ember (`style.css:865-877`), chrome refreshed out-of-band on every mutation (`web.go:397`) — but the horizontal Recent strip hides its own overflow (`style.css:471-476`) |
| 2 | Match System / Real World | 3 | Reader copy is native ("Chapter you're on" `card.html:96`); admin leaks ops jargon — "Sighting … deferral blocked" `readers.html:10-12`, "Outcomes · status" `lanes.html:26` |
| 3 | User Control and Freedom | 2 | Esc closes panels and returns focus (`filter.js:163-170`), Restore is one-tap undo (`card.html:75`) — but Remove and Archive have no post-action undo, only a pre-action gate |
| 4 | Consistency and Standards | 3 | Action order play∣fav∣chapter∣lifecycle is invariant (`card.html:48-86`); admin is a second system — patina accent (`admin.css:10-14`) and `--measure-wide: 1080px` (`admin.css:2`) vs `--measure: 760px` (`style.css:124`) |
| 5 | Error Prevention | 3 | `max=9999` + `step=any` (`card.html:99-102`), no-op chapter save skipped (`web.go:547-550`), every move out of the list confirm-gated (`card.html:111,123`); no inline validation on the chapter field |
| 6 | Recognition Rather Than Recall | 3 | Permanent keyrow (`chrome.html:35-45`) decodes the icon strip — but it is hidden while filtering (`filter.js:22-24`), exactly when cards are being scanned |
| 7 | Flexibility and Efficiency | 2 | Instant client-side filter (`filter.js:1-13`), bookmarkable tabs (`app.html:61`); no keyboard shortcut, no bulk archive, no swipe — curation is per-card taps |
| 8 | Aesthetic and Minimalist Design | 3 | Hairlines not cards, single column, Instrument Serif titles (`style.css:590`); phone chrome stacks ~220px (search `style.css:347` + tabs + keyrow `style.css:420` + strip `style.css:453`) before the first card |
| 9 | Error Recovery | 3 | Per-slot inline errors with verbatim server reason (`filter.js:200-224,260-263`), 401 → "Log in again" (`filter.js:273`), no self-destruct timer (`filter.js:241-245`); tab-switch failures land in the easy-to-miss global `#notice` (`style.css:830`) |
| 10 | Help and Documentation | 1 | Help is only empty states (`list.html:20-26`, `setup.html:9-17`); a guild outsider gets one sentence (`login.html:28`) and an opaque refusal (`discord.go:185`) with no guild name and no next step |
| **Total** | | **26/40** | **Competent, with one weak flank (help) and mobile ergonomics debt** |
## Design Specificity Verdict
**Authored for this product. Not category-interchangeable.**
**LLM assessment**: The visual language is a committed editorial position, not a framework default. Specific choices that no generic CRUD would carry: a single 760px hairline column with `border-bottom: 1px solid var(--rule)` instead of cards (`style.css:124,543`); the ember law enforced in code and in comments — "neither ember … may say 'system unhealthy'" (`style.css:236-241`), busy bar deliberately grey (`style.css:865`); heat expressed typographically (hot title `style.css:592`, 2px ember underline `style.css:525`, ember wash `style.css:548`) with no pulse or badge bounce; the hatch + monogram cover fallback (`style.css:121`, `card.html:18-19`) built for scraped covers that 404; manga and novels as two separate libraries with the Updated tab existing only for manga because only manga has a poller-fed "what's out" (`app.html:64-69`, `web.go:317`); the Recent strip capped at 5 and suppressed on Updated to avoid duplicating the list (`web.go:31,346`).
What a generic app would keep unchanged: the underline tab row (`style.css:374-403`), the client-side title filter (`filter.js:4-13`), the admin table with select filters and pagination (`series-list.html:9-33`), the single OAuth button (`login.html:22-26`). That is a fair split — the IA is generic, the surface is not.
The one real breach of the committed system is admin: `--measure-wide: 1080px` (`admin.css:2`) plus a patina accent buys horizontal sprawl (7-column lane grid, `admin.css:482`) that collapses back to stacked rows at 899px anyway (`admin.css:924`). Width did not save the tables; it created a second visual system to learn.
**Deterministic scan**: `detect.mjs --json backend/internal/web/templates` → **exit 0, zero findings** (`[]`), verified across three runs. Pipeline liveness confirmed against a control snippet, which correctly returned exit 2 with `overused-font`. The scan covers templates only — CSS lives outside the scan root, so `ai-color-palette`, `cream-palette` and line-length rules never ran. Mechanical sweep instead:
- **Zero** hardcoded colours outside the token blocks in either stylesheet. All 98 hex definitions sit in the dark `:root` (`style.css:55-122`) or the light branch (`style.css:138-191`). One non-token literal exists: `rgba(0,0,0,.5)` in a login-art drop-shadow (`style.css:946`). Every `#…` match in `admin.css` is an issue number in a comment.
- **Ember/danger segregation holds.** 32 `--ember` lines and 26 `--danger` lines in `style.css`, no overlap; `admin.css` uses `--ember` zero times.
- **0** `z-index`, **0** `position: fixed`, **2** `!important` (both justified: `[hidden]` override `style.css:217`, reduced-motion kill `style.css:1060`).
- **0** injection sinks. `filter.js` writes through `textContent` only; no `template.HTML/JS/URL` anywhere in the package; the single raw write is escaped (`web.go:425`).
- **Accessibility is authored, not audited in**: 36 real `<button>`s, **0** clickable `<div>`s, **0** hrefless `<a>`s, ~77 `aria-*` attributes, all 4 `<img>` intentionally `alt=""` beside a text name, **0** icon-only controls without an accessible name, 19 `:focus-visible` rules, `lang` + viewport + `color-scheme` on all three shells, one `prefers-reduced-motion` block.
Detector and review agree on the important thing: there are no mechanical anti-patterns here. Every finding below is a design judgment, not a lint.
**Visual overlays**: none. Browser visualization was skipped — the UI is only reachable through the Go/Docker stack, which was out of scope, so no local URL existed to point automation at. No overlay exists in your browser.
## Overall Impression
This is the rare self-hosted tool with an actual design system that the code obeys. Tokens are complete in both colour branches, the ember law is enforced in comments *and* in the busy-state colour choice, destruction is gated and coloured correctly, and the accessibility work is native rather than retrofitted. The problems are not taste problems — they are **mobile ergonomics and dead ends**.
Biggest single opportunity: **the phone.** Mobile is stated as a hard functional constraint, but two of the three horizontal-scroll surfaces (Recent strip, tab row) hide their scrollbars and offer no fade, snap, or peek, so their off-screen content is simply invisible; and ~220px of chrome loads above the first card. The product's answer to "what do I read next" is the strip — and on a 360px viewport its newest item can sit off-screen with no hint that it exists.
## What's Working
1. **The ember law is enforced where it is easiest to break.** The busy indicator is grey (`style.css:865-877`), not ember; error is `--danger`, not ember; the code comment states the rule (`style.css:236-241`) and the token contrast is annotated with a measured ratio ("verified 5.31:1 on `--ember-wash`", `style.css:73`). Design systems fail at exactly this junction. This one holds.
2. **Out-of-band chrome truthfulness.** Every mutation re-renders the strip, keyrow and Updated count out-of-band (`web.go:397`, `chrome.html:10,35,75`) and rebroadcasts a refilter event (`filter.js:104`), so the badge can never describe the pre-tap library. The comment at `web.go:474-476` explicitly buys correctness with one extra read.
3. **Confirm gates focus the safe button.** `filter.js:155-157` deliberately puts focus on Cancel, not Remove — "pre-armed Enter is the opposite of what a confirm gate is for." Paired with the danger wash and the title quoted into the prompt (`card.html:124`), destruction is weighted correctly.
## Priority Issues
### [P1] Both horizontal-scroll surfaces hide their own overflow
- **Why it matters**: `.recent-strip` (`style.css:468-476`) and `.tabs` (`style.css:374-383`) both set `overflow-x: auto` with `scrollbar-width: none` and a killed webkit scrollbar, and neither has a fade mask, scroll-snap, or a deliberately half-peeked item. At 93px covers plus 14px gaps (`style.css:127,470`), four cards fill a 360-390px viewport and the fifth — the newest — is invisible. Same for the fourth tab and its count pill. The strip is the product's answer to "what next"; an affordance nobody discovers is a feature that does not exist.
- **Fix**: Add `scroll-snap-type: x proximity` on `.recent-strip` with `scroll-snap-align: start` per `.recent-card`, plus a right-edge `mask-image` gradient on both `.recent` and `.tabs`. Alternatively size the strip padding so one card is always half-visible. No JS.
- **Suggested command**: `/impeccable adapt`
### [P1] A guild outsider hits a dead end with no next step
- **Why it matters**: The entire funnel is one page. `login.html:16,28` says "Private library" and "Guild membership is required"; a non-member's refusal is "not a member of this community" (`discord.go:185`) with no guild name, no invite path, no "ask a member." Heuristic 10 scores 1 almost entirely on this. It is also the only screen a stranger ever sees.
- **Fix**: Add one help line under `login.html:28` naming the guild (deployment config, safe to display) and stating how to get in. Keep the existing distinction between a Discord outage and a membership refusal (`web.go:171` vs `185`) — that part is already right.
- **Suggested command**: `/impeccable clarify`
### [P2] Removal ends in silence for sighted users
- **Why it matters**: **Correction to Assessment A** — the screen-reader path is *not* missing: `web.go:592` already announces "Removed <title>" out-of-band and `refreshChrome` updates the counts. What is missing is the visual half. The card is swapped out to an empty body (`card.html:127`, `web.go:578-596`), focus hops to the next play link (`filter.js:82-88`), and nothing else happens. Peak-end: the peak is the danger confirm, the end is absence. Users hesitate to curate, and libraries bloat.
- **Fix**: On delete success also write a transient `#notice` (`style.css:830`) carrying the same "Removed <title>" text. An Undo would need a re-create POST; the notice alone closes the loop for one line of Go.
- **Suggested command**: `/impeccable polish`
### [P2] Archive is confirm-gated despite being one-tap reversible
- **Why it matters**: Archive is the most common curation move and is undone by a single Restore tap (`card.html:75`), which itself fires instantly and without a gate. Its confirm row already wears the calm ash treatment (`style.css:806`) — the design admits the action is cheap while still charging two taps for it. Every gate spent on a cheap action devalues the gate on Remove.
- **Fix**: This one is a product decision, not a defect: the AGENTS.md law says every move out of the list is confirm-gated. If you want to relax it, the honest form is instant archive plus a persistent "Archived — Undo" notice, and the law should be amended in the same change. If you keep the law, keep the gate.
- **Suggested command**: `/impeccable shape`
### [P2] Adjacent lifecycle cells differ only by hue at rest
- **Why it matters**: **Partial correction to Assessment A** — the mis-tap claim was overstated. Each action is a full-cell `<button>` (`style.css:674-680`, `card.html:53-86`), so the target is 68×46, not the 17px glyph; the lifecycle cluster already sits recessed on `--ash` with an inset hairline before it (`style.css:724-726`). What remains real: Archive and Remove are neighbours in that cluster, and at rest they are separated only by `--slate` mute vs `--trash` (`style.css:684,708`) — the slate/danger wash distinction appears on hover, which a phone does not have. A slip lands on the destructive gate.
- **Fix**: Give `.actions .remove` a resting treatment that reads without hover — a left hairline in `--danger-soft`, or move Remove to the far edge with a wider divider. Do not add a gap; the recessed ground is doing that job already.
- **Suggested command**: `/impeccable polish`
## Persona Red Flags
**Midnight phone reader (primary persona: 2am, Bromite, one thumb, dimmed OLED)**
- The `.is-new` wash is `#1c0f0d` on an `#100f0e` page (`style.css:74,55`) — roughly 5% brighter. At low screen brightness the wash effectively disappears, leaving `--paper-hot` title colour (`style.css:64`) as the sole "new" signal. The ember foot-rule and 1px cover outline (`style.css:519,551`) survive better; the wash is the weakest link in the product's most important state.
- Two competing continue affordances: the cover link (`card.html:7`, `aria-hidden`) and the play cell (`card.html:49`). The cover is the bigger, more obvious target and carries no focus ring; the play cell is the labelled one.
- The chapter override field opens the Android numeric keypad over the "Latest known" hint it is meant to be compared against (`card.html:99-106`), and its focus ring is paper-white (`style.css:760`) — a bright flash in a dark room, against the stated no-glare requirement.
**First-time guild member (invited, app is new, library empty)**
- `setup.html:7` is a `<details>` closed by default, so on a first run the install CTAs are hidden behind a tap on a surface the user has no reason to trust yet. The rich empty state (`list.html:17-27`) only fires when *both* libraries are empty; otherwise they get the terse line at `list.html:29`.
- The library switch (`app.html:30-34`) defaults to manga with no explanation that Novels is a separate library. A first novel bookmark lands in a tab they are not looking at, which reads as "the save failed."
- Getting to a first bookmark requires: notice the panel → open it → choose install vs download → complete the mobile Violentmonkey save flow, which is documented in exactly one paragraph (`setup.html:15-17`).
**Owner triaging from a phone (secondary but real — the owner is also a night reader)**
- Lanes is a 7-column grid (`admin.css:482`) that collapses by hiding the header and printing `data-label::before` pseudo-labels (`admin.css:915,985`). It works, but a phone row becomes four logical lines with three numeric cells, each needing its label read.
- The pause control (`lanes.html:44-55`) puts a select plus button inline at row end; at 360px it wraps and renders at 10px mono (`admin.css:317`).
- `admin.css` has no `prefers-color-scheme` block of its own and no reduced-motion block; both are inherited via `style.css` tokens and its global `*` rule. That works today and breaks silently the day admin is loaded without `style.css`.
## Minor Observations
- Dark and light branches are genuinely co-maintained — the light branch retunes ember to `#c23a22` (`style.css:154`) rather than inverting it, and there is no pure-white page surface (`#f7f4ef` light / `#100f0e` dark). The only `#fff` is `--ember-ink` on the solid ember button (`style.css:156`), which is isolated.
- `.ghost::after { inset: -15px -12px }` (`style.css:295`) reaching 44px without shifting layout is the most mobile-aware line in the codebase. The action cells deserve the same care.
- `zoom: 1.2` above 1280px (`style.css:980`) preserves hairline proportions where a font-size scale would not; the `--zoom` token (`style.css:131`) handles the viewport-unit consequence. Unusual, correct, well commented.
- The reduced-motion block explicitly kills `::before` animations because `*` misses pseudo-elements (`style.css:1056-1060`). Rare and right.
- `.tabs a:hover` is the only `border-radius` in the reader sheet (`style.css:397`). Tabs are not cards; leave it.
- Credential rotation uses a native blocking `hx-confirm` (`setup.html:29`) while every other destructive action uses the custom confirm row. Defensible (rotation is rarer and more severe) but it is a third confirmation vocabulary.
- The Recent strip is hidden by JS while a filter is active (`filter.js:22-24`) — correct, since it would show non-matching series. Side effect: the keyrow is the only chrome left, and per-card icon meaning falls back on the keyrow at exactly the moment the user is scanning cards.
- `login.html:10` preloads the serif but not DM Sans, unlike `app.html:16,19` — minor FOIT on the first paint a new user ever sees.
- `color-mix(in oklch, …)` (`style.css:515,549,863`) degrades to transparent rather than breaking. Fine on Chromium/Bromite.
- `.is-dim` archived cards get `grayscale(1) opacity(.85)` (`style.css:546,568`), which also flattens the hatch fallback on missing covers. Intentional, but archived + no cover is close to unreadable.
## Questions to Consider
1. **If the action strip is right, why does the keyrow exist?** The keyrow (`chrome.html:35`) is a permanent legend for an icon set that cannot be read. Label the cells directly on phone — the keyrow already knows how to stack icon over word (`style.css:430`) — and you delete the keyrow and reclaim ~28px of feed.
2. **Who is the Updated tab for, if the strip already answers that question?** Updated (`app.html:65`) and the Recent strip (`chrome.html:9`, `web.go:346`) render the same `HasNewChapter` predicate — one as a list, one as covers. Pick one and give the phone back ~90px.
3. **Does brass favourite compete with ember new?** `--brass #b8912f` (`style.css:83`) and `--ember #e85a41` (`style.css:73`) are both warm. Night readers scan for ember; a gold fav-mark plus brass foot-rule (`card.html:27`, `style.css:527`) puts a second warm accent in the same scan. Would a cool metal separate reward from heat?
4. **If the single measured column is law, why does admin get 1080px?** The tables collapse at 899px anyway (`admin.css:924`), so the extra 320px bought sprawl, not capability. What would admin look like inside `--measure` with row drill-downs instead of 7-column grids?
5. **A new chapter arriving while the page is open is invisible.** The poller updates the database; only admin Lanes auto-refreshes (`lanes.html:13`). A reader must switch tabs to learn anything changed. Is a 60s `hx-trigger` poll on the chrome fragment worth the requests, given the product's whole value proposition is "you find out without visiting the site"?
@@ -0,0 +1,116 @@
---
target: Reader library web surface (app.html)
total_score: 32
max_score: 40
na_heuristics:
p0_count: 0
p1_count: 2
timestamp: 2026-08-27T15-38-17Z
slug: backend-internal-web-templates-app-html
---
Method: dual-agent (A: CritiqueDesignReview · B: CritiqueDetectorEvidence)
Target: the Reader library surface — `backend/internal/web/templates/{app,chrome,list,card,setup,icons}.html`, `static/style.css`, `static/filter.js`. Mode: **Operate**.
## Design Health Score
| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | 3 | `card.htmx-request::before` (style.css:880-889) animates a silent bar for up to the full 15s htmx timeout (app.html:12) with no "saving" text and no `aria-busy`. |
| 2 | Match System / Real World | 4 | Solid. Copy is situational, not generic: "Chapter you're on" vs "Latest known" (card.html:96-106); four distinct empty states (list.html:11-29). |
| 3 | User Control and Freedom | 3 | Confirm+Cancel and Esc-to-close cover accidental opens (filter.js:152-200); a committed Remove has no undo. |
| 4 | Consistency and Standards | 3 | `:focus-visible` rings are defined for every `.actions` cell, the chapter input and confirm buttons (style.css:703-826) but for **no** navigation control — `.tabs a`, `.libswitch a`, `.ghost`, `.login-card button`, `.empty .clear-search` fall back to the UA ring. |
| 5 | Error Prevention | 4 | `step="any"` chapter guard, confirm-gate on every move-out-of-list, `hx-disabled-elt` on every mutating control, Cancel focused on Remove (filter.js:159-164). |
| 6 | Recognition Rather Than Recall | 3 | The `.keyrow` legend (chrome.html:34-46) exists precisely because the 5-icon strip is unlabelled — and it scrolls away, at 10px, in exactly the long-list case it was built for. |
| 7 | Flexibility and Efficiency | 2 | No bulk actions, no sort, no shortcut beyond Esc. A 300-series library is triaged one card, two taps at a time. |
| 8 | Aesthetic and Minimalist Design | 3 | Hairline-only, one-measure, disciplined — but the minimalism is paid for in legibility: 9 distinct functional labels compute to **10px** at 390px (Assessment B), below any UI-text floor. |
| 9 | Error Recovery | 4 | `reasonFrom()` surfaces the server's literal 400 text (filter.js:270-292); distinct 401/400/network/timeout copy; auto-`scrollIntoView` to the message. |
| 10 | Help and Documentation | 3 | In-context `<details class="setup">` covers install and rotation; nothing in the library view explains what dim/italic, ember, or brass mean without first finding the keyrow. |
| **Total** | | **32/40** | **Good — solid foundation, weak areas are specific and cheap** |
## Design Specificity Verdict
**Authored, not assembled.** This could not ship unchanged on another product, and that is rare.
**LLM assessment (Assessment A, unanchored):** the specificity is load-bearing, not decorative. `--ember` is a contract, not an accent — every "new chapter" surface shares that one token (`.is-new .title` 604-610, `.tabs .tab-new` 409-412, `.foot-rule` 529-536, `.libswitch a.active` 663-667) and destruction is deliberately split onto a duller `--danger` family so a remove-confirm is never mistaken across the room for an unread chapter (style.css:77-83). The `:root` block carries per-pair contrast measurements, and the light branch **re-derives** every hue instead of reusing dark values (137-195) — evidence the "both branches touched together" law is honoured, not asserted. Three purpose-chosen faces, self-hosted because Bromite blocks `fonts.googleapis.com` (style.css:1-5). One `--measure: 760px` column, no cards, no shadows, and in 1075 lines exactly one stray corner (`.tabs a:hover` 2px radius, line 402). The gaps are not taste failures; they are unfinished passes — sticky positioning, focus rings on nav, and a legibility floor.
**Deterministic scan:** `detect.mjs --json backend/internal/web/templates` → **0 findings, exit 0**, and Assessment B validated that zero three ways (synthetic bad HTML fired 2 rules; `--no-config` reproduced the zero; no `config.json` exists). Note the scan's blind spot: the detector did **not** resolve the linked `/static/style.css`, so a clean markup scan says nothing about the tokens. The runtime pass is where everything was found.
**Visual overlays:** injection **succeeded** — but against a standalone render, not the live app. Assessment B found `web.go:125` parses templates with a plain `template.ParseFS` and no FuncMap, so it rendered the repo's real templates + real CSS from a throwaway program in `/tmp/bmrender`, served it, and injected `detect.js`. Overlay confirmed present; screenshots at `/tmp/bmrender/shots/` (390×844 light, dark, dark+overlay). **20 runtime findings, none of which the static scan could see:** `undersized-ui-text` ×18, `wide-tracking` ×1, `overused-font` ×1. Every card action measured **66.8 × 46 px** — clears 44px on both axes. No horizontal overflow at 390px (an earlier 499px reading was the overlay's own label boxes). Both servers stopped, no Docker started, no repo file changed.
False positives I accept from B: 9 of the 18 `undersized-ui-text` hits are the same `.hint` rule counted once per row inside `hidden` chapter forms — one defect, not nine. `overused-font` on Instrument Serif is the rule's share-of-text heuristic misreporting a deliberate display serif. `wide-tracking` anchors to the mono meta line, not body copy. **The remaining 9 are real and unanimous with A's typography read:** `Manga`, `Novels`, `Userscripts`, `Read`, `Fav`, `Chapter`, `Archive`, `Delete`, `Continue reading` all render at 10px.
## Overall Impression
This is the best-argued small codebase I have critiqued in a while — the CSS comments read like design review notes, and the taste is consistent. What works is the discipline: one heat signal, one column, hairlines, situational copy, and a confirm model that distinguishes reversible from final and even routes focus differently for each.
What does not work is that the design's own mitigations are undermined by two omissions it never got around to. The card strip needs the legend to be readable; the legend is 10px and scrolls away. The tab row is the primary navigation; it is the one row with no focus ring. Both are 20-line fixes.
**Single biggest opportunity: make the chrome persistent and lift the 10px floor.** Those two changes convert a well-reasoned desktop-shaped design into one that actually works one-handed at night on a phone — which PRODUCT.md says is the entire point.
## What's Working
1. **Ember law is real.** One reserved token for "unread chapter", nothing else borrows it, destruction gets its own duller family. This is why a dense icon-heavy list stays scannable to a tired reader: any warm colour means *something to read*. Most design systems claim this; this one enforces it in a comment and then actually holds the line for 1075 lines.
2. **Confirm-row focus routing.** `toggleConfirmRow` focuses the affirmative button for the reversible action (Archive) and **Cancel** for the irreversible one (Remove) — filter.js:159-164, paired with `.confirm-row.calm` grey vs the danger wash (style.css:786-826). A one-line detail that measurably lowers accidental destruction. Nobody notices this; everybody benefits from it.
3. **State coverage is exhaustive and honest.** Four distinct empty states by real cause, verbatim server 400 text surfaced inline instead of "something went wrong", a chapter form that disambiguates your progress from the published latest, and a cover slot with hatch + monogram + `onerror="this.remove()"` covering both "never acquired" and "404s after storage" (card.html:9-19). Verified in B's render: the broken-cover row hit `ERR_NAME_NOT_RESOLVED` and the monogram fallback executed.
## Priority Issues
**[P1] Nothing is sticky, on a mobile-first surface built for hundreds of series**
- **Why it matters:** `position: sticky` appears **nowhere** in style.css (verified). Casey, one-handed, three screens into a 300-item library, must scroll to the top to switch tabs, search, or re-read the icon legend — the exact moment search and legend earn their existence. This also silently defeats the keyrow, which is the whole mitigation for the unlabelled 5-icon strip.
- **Fix:** `.chrome` (searchbar + tabs) → `position: sticky; top: 0; background: var(--ink);` with `padding-top: env(safe-area-inset-top)` and a `z-index` above cards; keep `.recent`/`.keyrow` non-sticky if header height is tight, or sticky the keyrow instead and accept a shorter tab row.
- **Suggested command:** `/impeccable adapt`
**[P1] Navigation controls have no `:focus-visible`; nine functional labels render at 10px**
- **Why it matters:** two separate legibility/a11y regressions in the same layer. (a) Every `.actions` cell, the chapter input and confirm buttons define 2px token-coloured rings (style.css:703-826); `.tabs a`, `.libswitch a`, `.ghost`, `.login-card button` and `.empty .clear-search` define none, so keyboard focus on the primary navigation falls back to a UA ring — likely bright blue, against a palette explicitly tuned for low glare. `.search` goes further and zeroes its outline (372), delegating to a 1px `border-bottom-color` change — materially weaker than every other control in the file. (b) B measured 10px computed on `.libswitch a`, `.keyrow .pair span`, `.recent h2`, `.setup summary`, `.hint`. 10px mono, at night, on a phone, is not a minimalism choice a night-reading brief supports.
- **Fix:** add `:focus-visible { outline: 2px solid var(--paper); outline-offset: 2px; }` to the four nav selectors, matching the established pattern; give `.search` a real ring instead of the hairline delegation. Raise the 10px steps to 11-12px (the `min-width: 720px` block already does exactly this for `.keyrow .pair span` at line 1024 — the mobile branch is the one that needs it more, not less).
- **Suggested command:** `/impeccable audit` then `/impeccable typeset`
**[P2] Five icon-only actions per card, at rest, in both list states**
- **Why it matters:** `.actions` renders Play, Fav, Chapter, Archive/Restore, Remove (card.html:48-87) — one over the ≤4 working-memory ceiling, on every card. At rest all five are `var(--mute)` grey; per-action colour is a *response* to interaction, not a resting cue. The design already knows this, which is why the keyrow exists — and the keyrow is 10px and scrolls away (P1). Fix P1 and this drops to P3; leave P1 and this is five monochrome glyphs versus a distracted thumb.
- **Fix:** either resolve via P1 (sticky + legible legend) and keep five, or move Chapter-override behind the same disclosure affordance as Archive/Remove so the resting strip is Play, Fav, Archive, Remove.
- **Suggested command:** `/impeccable layout`
**[P2] Up to 15 seconds of silent busy state**
- **Why it matters:** `.card.htmx-request::before` animates a bar; there is no interim text, no `aria-busy`, and no live-region message until the outcome lands. app.html:8-12 documents the 15s cap and its own reasoning is "a phone that walks into a dead zone" — so the slow path is anticipated, and it is the one path with no feedback. To a distracted user this reads as "did my tap register?" for an uncomfortably long stretch; to a screen reader user there is literally nothing between the tap and the result.
- **Fix:** after ~2s of `htmx-request`, reveal a "Saving…" line in the card's existing `.error-inline` slot (or a sibling) and set `aria-busy="true"` on the article.
- **Suggested command:** `/impeccable harden`
**[P3] Long titles clamp in the strip but not in the list**
- **Why it matters:** `.recent-title` clamps to 2 lines (style.css:512-517); `.title` gets only `overflow-wrap: anywhere` (592-601) despite the adjacent comment documenting a real 90-character scraped title. Horizontal overflow is defended; a card growing four lines tall and breaking the list's rhythm is not. Titles are scraped `og:title` — the length is not under your control.
- **Fix:** apply the same `-webkit-line-clamp: 2` to `.title`, or decide the list shows titles in full and say why the two components diverge.
- **Suggested command:** `/impeccable polish`
## Persona Red Flags
**Casey (distracted, one-handed, mobile — the stated primary user)**
- No sticky chrome: every tab switch or search after scrolling is a full reach to the top of the viewport.
- 10px `Manga`/`Novels` library switch and 10px keyrow labels — the two things you glance at, set at the smallest size on the page.
- Five same-weight grey glyphs per card demand deliberate recognition, not a colour-coded glance.
- *Working:* `.actions { flex: 1 0 100% }` puts the strip full-width at the bottom of each card's content — correct thumb zone. Every cell measured 66.8 × 46 px.
**Sam (screen reader / keyboard / low vision)**
- `.tabs a` and `.libswitch a` have no custom focus ring; `.search:focus-visible { outline: none }` delegates to a 1px hairline recolour on the one control most in need of being findable.
- No `aria-busy`/live cue during the in-flight window — announcements exist before nothing and after everything.
- *Working, despite appearances:* cover `alt=""` + `aria-hidden` monogram is correctly decorative because the sibling `<h3 class="title">` carries the name; every icon button has both `aria-label` and `title`; mutations announce through `#sr-announce`.
**Riley (edge-case stress tester)**
- Chapter `max="9999"` is a client-only cosmetic guard — server validation (`web.go:564-569`) rejects negative/NaN/Inf but no upper bound, so a direct POST stores an arbitrary magnitude.
- `closeCardPanels` (filter.js:118-128) enforces one open panel *per card*, not per page: a confirm-remove can sit open on card 3 while a chapter form is open on card 40.
- 300 series: no pagination, no virtualization, no bulk action, no sticky nav — sequential two-tap cleanup only.
- *Working:* 0-series is handled with four distinct states including a true first-run with install links; missing covers are handled from both directions.
## Minor Observations
- `.keyrow .full { display: inline; }` (style.css:1025) targets a `.full` class that exists nowhere in `chrome.html` — dead rule, delete it.
- `.tabs a:hover { border-radius: 2px 2px 0 0; }` (402) is the only corner rounding in the sheet, an unexplained exception to the stated no-corners law.
- `.libswitch a` under `@media (pointer: coarse)` computes ~42px tall (1066-1068) against the file's own claim at 1061-1063 that "every control is already 44".
- Restore fires instantly while Archive is confirm-gated — defensible as "reversible needs no confirm", but Restore *is* the reversal of Archive, so the asymmetry deserves a sentence in the CSS comment where the rule is stated.
- The detector cannot see `style.css` through a `<link href="/static/...">`. Any future detector run on this repo must target rendered HTML in a browser, not the templates directory, or it will report clean and mean nothing.
## Questions to Consider
1. If a permanent legend is required to make the card strip legible, is 5 icon-only actions the right density for a phone — or is Chapter-override one disclosure away from a 4-icon strip that needs no legend?
2. Why does neither the tab row nor the search box stay reachable once a Reader is three screens into the library the Updated badge exists to triage?
3. Ember means "new chapter" everywhere except the wordmark and the login screen (style.css:238-241). Is a law with a carved-out exception still a law, or does the brand need its own accent instead of borrowing the one reserved signal?
4. The 10px steps read as confidence — restraint pushed to its limit. On a surface whose brief names night reading and glare as an explicit personal requirement, is that restraint or is it the one place taste overrode the brief?
+3
View File
@@ -136,6 +136,9 @@ gated by membership in one configured guild.
DISCORD_REDIRECT_URI=https://bookmark.violetcrown.my.id/auth/discord/callback
# Optional: only members holding this role may sign in.
# DISCORD_REQUIRED_ROLE=<role snowflake>
# Optional: display name for that guild — shown on the login screen so a
# stranger knows which Discord to ask for an invite.
# DISCORD_GUILD_NAME=Your Guild Name
```
The guild id is in Discord's client with Developer Mode on: right-click the
+3 -3
View File
@@ -8,7 +8,7 @@ web
## Users
Members of one private Discord guild, each with their own library. Accounts exist and are created by signing in — there is no signup form, no invite code and no approval step: any member of the configured guild becomes a Reader on their first Discord login. The person running the deployment is the owner, seeded at startup, and the only Reader with an administrative capability (revoking another Reader's sessions).
Members of one private Discord guild, each with their own library. Accounts exist and are created by signing in — there is no signup form, no invite code and no approval step: any member of the configured guild becomes a Reader on their first Discord login. The person running the deployment is the owner, seeded at startup, and the only Reader with an administrative capability (`/admin` — Overview, Lanes, Readers, Series).
Reading happens on **asurascans.com**, **demonicscans.org**, **comix.to** and **kagane.to** for manga and **novelfull.com** and **lightnovelworld.net** for novels, primarily via Bromite on mobile, with checks and corrections from a desktop browser. The web UI is the cross-device view into progress the userscripts capture while reading.
@@ -34,7 +34,7 @@ Not a public reading tracker or social app — a private, self-hosted sync layer
- "Continue reading" horizontal strip for series with an unread chapter.
- A Reader with no bookmarks at all sees a deliberate empty library offering both userscript install links, not an error and not a blank page.
- Isolation is the load-bearing invariant: two Readers cannot see or change each other's bookmarks. A series both track is one shared row polled once, with independent progress on each side.
- The owner can revoke a specific Reader's sessions; nothing else in the UI differs by Reader.
- The owner alone can reach `/admin` (Overview, Lanes, Readers, Series) for library/lane/series maintenance and Reader session/Sighting controls; otherwise nothing else in the UI differs by Reader.
- htmx-driven partial updates, no client-side framework or build step — templates are Go `html/template`, `go:embed`-ed.
- Mobile-first is a hard functional constraint (primary device is a phone), not just a starting breakpoint.
@@ -54,7 +54,7 @@ Not a public reading tracker or social app — a private, self-hosted sync layer
- Mobile is the primary target; desktop is an enhancement, not the design center.
- Progress data integrity over visual flourish: `updated_at`/list-ordering behavior is a correctness constraint the UI must respect, not decorate over.
- A leak between Readers fails silently and looks like working software — isolation is asserted from both directions, never inferred from counting one Reader's rows.
- No roles, no org chrome: the owner's Readers panel is one list with one button (revoke someone's sessions), not an admin console, and otherwise every Reader's view is the same.
- No roles, no org chrome beyond the owner gate: `/admin` is the one owner-only console (Overview, Lanes, Readers, Series) for library-wide hygiene, lane health/pauses and series/Reader maintenance — Readers offers revoke sessions and clear Sighting marks, both confirm-gated — and otherwise every Reader's view is the same.
- Prefer native platform affordances (system dark/light, native touch targets) over custom widgetry — this is a lean self-hosted tool, not a product to demo.
## Accessibility & Inclusion
+1
View File
@@ -48,6 +48,7 @@ covers are stored, so the library renders in full with the browser switched off.
| `DISCORD_CLIENT_ID` | *(required)* | Discord application credentials for the browser sign-in (ADR-0002). |
| `DISCORD_CLIENT_SECRET` | *(required)* | As above. Never logged, never echoed in an error. |
| `DISCORD_GUILD_ID` | *(required)* | The one guild whose membership gates sign-in, checked at login only. Membership *is* registration: any member becomes a Reader on first login. |
| `DISCORD_GUILD_NAME` | empty | Human-readable name for `DISCORD_GUILD_ID`, shown on the login screen so a stranger knows which community owns this library and who to ask for an invite. When unset the page shows a generic "private community" label. |
| `DISCORD_REDIRECT_URI` | *(required)* | Exact callback URL; Discord matches it verbatim against the registered redirect. |
| `DISCORD_REQUIRED_ROLE` | empty | Role snowflake a member must additionally hold. Empty means guild membership alone suffices. |
| `DISCORD_API_BASE` | `https://discord.com/api/v10` | Test seam — tests point it at a local stub so the real token exchange runs. |
+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
+1
View File
@@ -128,6 +128,7 @@ func loadConfig() Config {
ClientID: os.Getenv("DISCORD_CLIENT_ID"),
ClientSecret: os.Getenv("DISCORD_CLIENT_SECRET"),
GuildID: os.Getenv("DISCORD_GUILD_ID"),
GuildName: os.Getenv("DISCORD_GUILD_NAME"),
RequiredRole: os.Getenv("DISCORD_REQUIRED_ROLE"),
APIBase: envOr("DISCORD_API_BASE", "https://discord.com/api/v10"),
RedirectURI: os.Getenv("DISCORD_REDIRECT_URI"),
+21 -12
View File
@@ -1200,7 +1200,9 @@ func TestLaneResumeRoundTrip(t *testing.T) {
if !strings.Contains(body, `hx-post="/admin/lanes/asura/pause"`) || !strings.Contains(body, ">Pause</button>") {
t.Errorf("resume response does not offer Pause again:\n%s", body)
}
if strings.Contains(body, "Resume") || strings.Contains(body, "paused") {
// The page's own copy names pause as a healthy idle state, so the tell is
// the row's phrase and the Resume control, not the bare word.
if strings.Contains(body, ">Resume</button>") || strings.Contains(body, "resumes in") {
t.Errorf("resume response still reads as paused:\n%s", body)
}
paused, _, err := st.LaneGates("asura")
@@ -1252,7 +1254,7 @@ func TestPausedLaneRendersAsPausedNotStalled(t *testing.T) {
}
body := lanesBody(t, router, st)
if !strings.Contains(body, `class="ok">paused · resumes in 6h<`) {
if !strings.Contains(body, `>paused · resumes in 6h<`) || !strings.Contains(body, `class="ok"`) {
t.Errorf("a paused Lane does not render the patina phrase:\n%s", body)
}
if strings.Contains(body, `class="trow attention"`) {
@@ -1652,7 +1654,9 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000,
})
for _, bad := range []string{"", "abc", "-3", "NaN", "Infinity", "-Inf"} {
// 1e5 is past the ceiling but a perfectly good float64, so it only fails
// if the handler bounds the value rather than just parsing it.
for _, bad := range []string{"", "abc", "-3", "NaN", "Infinity", "-Inf", "10000", "1e5"} {
t.Run("input "+bad, func(t *testing.T) {
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodPost,
@@ -2678,11 +2682,14 @@ func TestSeriesListRowShape(t *testing.T) {
if got := strings.Count(body, "Check now"); got != 2 {
t.Errorf("Check now control count = %d, want 2 (only the two pollable rows):\n%s", got, body)
}
if got := strings.Count(body, ">Remove<"); got != 1 {
t.Errorf("Remove control count = %d, want 1 (only the orphan):\n%s", got, body)
if got := strings.Count(body, ">Remove<"); got != 2 {
t.Errorf("Remove control count = %d, want 2 (opener + confirm for the orphan):\n%s", got, body)
}
if strings.Contains(body, "confirm-row") {
t.Errorf("a confirm row renders in this batch:\n%s", body)
if !strings.Contains(body, "confirm-row") {
t.Errorf("expected hidden confirm-row for the orphan:\n%s", body)
}
if !strings.Contains(body, `hidden>`) {
t.Errorf("confirm-row should be hidden initially:\n%s", body)
}
// No ember: the new-chapter signal stays off the admin surface. Scoped to
// the page content — the shell's brand mark legitimately wears the ember
@@ -3475,8 +3482,10 @@ func TestOverviewPerSiteTableLinksToNarrowedLists(t *testing.T) {
srv := newRouter(st, testConfig())
body := overviewBody(t, srv, st)
// The landed grid pins six columns; the header states them in order.
if !strings.Contains(body, `<div class="thead"><span>Site</span><span>Series</span><span>No cover</span><span>Never chk</span><span>Stale</span><span>State</span></div>`) {
// The landed grid pins six columns; the header states them in order. Cell
// attributes (tooltips) are not part of the contract, the words are.
thead := regexp.MustCompile(`<div class="thead"><span[^>]*>Site</span><span[^>]*>Series</span><span[^>]*>No cover</span><span[^>]*>Never checked</span><span[^>]*>Stale</span><span[^>]*>Status</span></div>`)
if !thead.MatchString(body) {
t.Errorf("per-Site table is not the six pinned columns:\n%s", body)
}
// The Site label is a door to the Site-narrowed list.
@@ -4221,8 +4230,8 @@ func TestSeriesRemoveRendersOnlyOnOrphans(t *testing.T) {
router := newRouter(st, testConfig())
body := adminSeriesPage(t, router, st, "")
if got := strings.Count(body, ">Remove<"); got != 1 {
t.Errorf("list offers Remove %d times, want 1 (only the orphan):\n%s", got, body)
if got := strings.Count(body, ">Remove<"); got != 2 {
t.Errorf("list offers Remove %d times, want 2 (opener + confirm for orphan):\n%s", got, body)
}
for _, tc := range []struct {
key string
@@ -4409,7 +4418,7 @@ func TestRemoveRacedBookmarkIsRefusedNotError(t *testing.T) {
if !strings.Contains(body, "a Reader has bookmarked this Series again") {
t.Errorf("refusal does not say what happened:\n%s", body)
}
if !strings.Contains(body, `class="c-rd">1</span>`) {
if !strings.Contains(body, `data-label="readers">1</span>`) {
t.Errorf("refusal does not render the fresh count:\n%s", body)
}
var one int
+4 -2
View File
@@ -45,11 +45,13 @@ services:
# in; distroless already carries tzdata, so the name just resolves.
TZ: ${API_TZ:-Asia/Jakarta}
# Discord OAuth for the browser UI (ADR-0002). The first four are
# required; DISCORD_REQUIRED_ROLE is optional and empty by default.
# Guild membership is the whole gate: any member becomes a Reader.
# required; DISCORD_REQUIRED_ROLE and DISCORD_GUILD_NAME are optional and
# empty by default. Guild membership is the whole gate: any member
# becomes a Reader.
DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:?set DISCORD_CLIENT_ID in .env}
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:?set DISCORD_CLIENT_SECRET in .env}
DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:?set DISCORD_GUILD_ID in .env}
DISCORD_GUILD_NAME: ${DISCORD_GUILD_NAME:-}
DISCORD_REQUIRED_ROLE: ${DISCORD_REQUIRED_ROLE:-}
DISCORD_API_BASE: ${DISCORD_API_BASE:-https://discord.com/api/v10}
DISCORD_REDIRECT_URI: ${DISCORD_REDIRECT_URI:?set DISCORD_REDIRECT_URI in .env}
+42 -8
View File
@@ -128,8 +128,11 @@ Recurring specs (copy these rather than inventing sizes):
- Tab: `400 17px display` (`18px` ≥720px), active gets `border-bottom: 2px` in
`--paper` (`--ember` for Updated) plus `margin-bottom: -1px` so it lands on
the row's own hairline.
- Meta / label / badge / action key: `500 10–11px mono`, `letter-spacing:
- Meta / label / badge / action key: `500 11px mono`, `letter-spacing:
.04em`–`.2em`, `text-transform: uppercase`. Eyebrows use the widest tracking.
**11px is the floor** — nothing in this UI sets mono below it. The brief names
night reading and glare as the usage scene, and a 10px small-caps label at
arm's length on a phone is where that scene stops being served.
- Empty-state heading: `400 20px display`; body `400 14px/1.6 sans`, `max-width: 44ch`.
- Primary button: `--paper` fill, `--ink` text, `400 17–19px display`, no border radius.
- Ghost button: mono small-caps, transparent, `border-bottom: 1px --field-line`.
@@ -140,11 +143,20 @@ Recurring specs (copy these rather than inventing sizes):
.sheet
.topbar .brand (mark + wordmark) + .ghost (log out)
.chrome .searchbar + nav.tabs (column on phone, row ≥720px via order:)
sticky at top: 0, z-index 2, on an --ink ground
.keyrow one-line action key: Read / Fav / Chapter / Archive / Done / Delete
.recent h2 eyebrow + .recent-strip > a.recent-card
main#list article.card … | .empty
```
**Sticky chrome.** Search and the tab row are the two controls a 300-item
library needs mid-scroll, so `.chrome` alone sticks (`padding-top:
env(safe-area-inset-top)` for the notch cutout). Everything above it —
`.topbar`, `.keyrow`, `.recent` — scrolls away on purpose: another 150px of
permanent chrome on an 844px phone costs more than re-scrolling for an icon
reminder. If header height ever grows, unstick `.recent`/`.keyrow` further
rather than adding to the sticky region, and keep `.chrome` above the cards.
The owner's admin page (`admin.html`) is the same sheet with two sections in
place of the list — `.lanes` (Poll Lane rows) and `.readers` (the roster) —
and no library switch: it belongs to neither library, so its topbar carries a
@@ -225,9 +237,16 @@ Rules that are easy to break:
- `[hidden] { display: none !important; }` is load-bearing — every disclosure
panel is a flex container, and `display` beats `hidden`.
- Busy state is `.card.htmx-request::before`, a 1px grey bar sliding across the
top hairline (`barSlide`), plus the action strip at `opacity: .5`. Never a
spinner, and deliberately `--mute` not `--ember` — on a list screen ember
means "new chapter" and nothing else, so a system state can't borrow it.
top hairline (`barSlide`), the action strip at `opacity: .5`, and past 2s the
word `Saving…` in `::after` (`busyWord`, §6). `filter.js` sets `aria-busy` on
the card over the same window because htmx sets none, so the wait is not
silent to a screen reader. Never a spinner, and deliberately `--mute` not
`--ember` — on a list screen ember means "new chapter" and nothing else, so a
system state can't borrow it.
- Titles clamp at 3 lines (`.recent-title` at 2 — there the title is a
reminder, in the list it is the identifier). `.is-new .title` needs
`width: fit-content`, or `-webkit-box` stretches the ember underline to the
full row and the rule stops being sized to the text.
- `.open` on the pencil / lifecycle cell marks which panel is showing;
`filter.js` `togglePanel()`/`toggleConfirmRow()` own that class alongside
`hidden`. An open lifecycle cell needs the next surface step up from
@@ -251,7 +270,7 @@ is a 1px bar, not a rotating ring); toasts are `--ash` with a 2px left rule,
## 6. Motion
Three animations, all ≤ 1.15s and all disabled under
Four animations, all ≤ 1.15s and all disabled under
`prefers-reduced-motion: reduce` (pseudo-elements need naming explicitly in
that query — `*` does not match `::before`/`::after`, so the busy bar and
error dot are listed by name and fall back to their static drawn form):
@@ -259,16 +278,29 @@ error dot are listed by name and fall back to their static drawn form):
- `sheetIn` — 180ms fade + 4px rise, on a row and on each disclosure panel.
- `barSlide` — the sliding hairline, for any busy state.
- `mutePulse` — the 5px dot on `.error-inline`.
- `busyWord` — `0s 2s forwards`, a delay rather than a motion: it reveals the
`Saving…` word only once a request has outlived a plausible response. The
reduced-motion block cancels the animation and so would pin it at
`opacity: 0`; that branch re-declares `opacity: 1` to show it from the start.
Any future state revealed this way needs the same two-line pair.
No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`.
## 7. Accessibility floor (not negotiable)
- Touch targets on the phone layout are 44–46px; the 44px desktop cells are
pointer-only (≥720px).
- Every icon-only control keeps `title` + `aria-label`; the SVG inside is
`aria-hidden`. Lifecycle buttons also carry `aria-expanded` +
`aria-controls` pointing at their `.confirm-row`.
- **Every focusable control carries a visible ring**: `outline: 2px solid
var(--paper)` with `2px` offset on `:focus-visible`, since the UA default is
a bright blue tuned for neither branch of this palette. `.searchbar` recolours
its border on `:focus-within` as a resting cue, but that 1px change is not the
ring — the `.search` input declares its own. A new control that suppresses
`outline` must replace it, not drop it.
- Touch targets are 44–46px under `(pointer: coarse)` — including inline text
controls like `.libswitch a`, where padding plus an 11px line lands short of
44 and needs `min-height` + `place-items: center`. The 44px desktop cells are
pointer-only (≥720px).
- The cover link is `tabindex="-1" aria-hidden="true"` because the title link
and the play cell already reach the same URL — do not make it a third tab stop.
- Tabs keep `role="tab"` / `role="tablist"`; the active one is marked by class,
@@ -292,7 +324,9 @@ No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`.
5. Icon → `templates/icons.html`; nothing inlines SVG paths. Brand mark stays
the one exception (`chrome.html`'s `mark` template), since it takes
page-level custom properties the sprite can't carry per-instance.
6. Phone first (44px targets, single column), then the ≥720px block.
6. Phone first (44px targets under `(pointer: coarse)`, single column), then the
≥720px block. Mono no smaller than 11px, and a `:focus-visible` ring on
anything focusable — both are §7 floors, not preferences.
7. Verify: `cd backend && go test ./...`, then run the binary and screenshot
both widths and both colour schemes (Playwright: `emulateMedia`,
`setViewportSize`; disable the browser cache — `/static/*` is served with