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

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

## Library (Reader-facing)

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

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

## /admin

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

## Login

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

## Handlers

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

## Verification

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

## Note on shape

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

Reviewed-on: #177
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #177.
This commit is contained in:
2026-08-27 23:09:42 +07:00
committed by sulthan
parent cddd16bcdc
commit 3a83161b1c
32 changed files with 1567 additions and 237 deletions
@@ -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?