--- 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 `` 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 `` 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 `
`. **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 `
` 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 `` 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 `
` 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 `