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>
16 KiB
target, total_score, max_score, na_heuristics, p0_count, p1_count, timestamp, slug
| target | total_score | max_score | na_heuristics | p0_count | p1_count | timestamp | slug |
|---|---|---|---|---|---|---|---|
| Reader library web surface (app.html) | 32 | 40 | 0 | 2 | 2026-08-27T15-38-17Z | 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
- 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.
- Confirm-row focus routing.
toggleConfirmRowfocuses the affirmative button for the reversible action (Archive) and Cancel for the irreversible one (Remove) — filter.js:159-164, paired with.confirm-row.calmgrey vs the danger wash (style.css:786-826). A one-line detail that measurably lowers accidental destruction. Nobody notices this; everybody benefits from it. - 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 hitERR_NAME_NOT_RESOLVEDand the monogram fallback executed.
Priority Issues
[P1] Nothing is sticky, on a mobile-first surface built for hundreds of series
- Why it matters:
position: stickyappears 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);withpadding-top: env(safe-area-inset-top)and az-indexabove cards; keep.recent/.keyrownon-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
.actionscell, the chapter input and confirm buttons define 2px token-coloured rings (style.css:703-826);.tabs a,.libswitch a,.ghost,.login-card buttonand.empty .clear-searchdefine 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..searchgoes further and zeroes its outline (372), delegating to a 1pxborder-bottom-colorchange — 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.searcha real ring instead of the hairline delegation. Raise the 10px steps to 11-12px (themin-width: 720pxblock already does exactly this for.keyrow .pair spanat line 1024 — the mobile branch is the one that needs it more, not less). - Suggested command:
/impeccable auditthen/impeccable typeset
[P2] Five icon-only actions per card, at rest, in both list states
- Why it matters:
.actionsrenders Play, Fav, Chapter, Archive/Restore, Remove (card.html:48-87) — one over the ≤4 working-memory ceiling, on every card. At rest all five arevar(--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::beforeanimates a bar; there is no interim text, noaria-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-inlineslot (or a sibling) and setaria-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-titleclamps to 2 lines (style.css:512-517);.titlegets onlyoverflow-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 scrapedog:title— the length is not under your control. - Fix: apply the same
-webkit-line-clamp: 2to.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/Novelslibrary 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 aand.libswitch ahave 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-hiddenmonogram is correctly decorative because the sibling<h3 class="title">carries the name; every icon button has botharia-labelandtitle; 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.fullclass that exists nowhere inchrome.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 aunder@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.cssthrough 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
- 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?
- 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?
- 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?
- 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?