Files
mangaBookmark/.impeccable/critique/2026-08-27T12-24-48Z__backend-internal-web.md
sulthan af07314bb6 Cinder pass across /admin, the login gate, and the library's a11y floor
Uncommitted work from three design runs on this branch, against one design
system: docs/design-system.md is updated to match the CSS, not the reverse.

Library (Reader-facing):
- .chrome sticks at top: 0. Search and the tab row were unreachable three
  screens into a 300-item library, which is exactly where they earn their
  keep; everything above them still scrolls away on purpose.
- One :focus-visible ring (2px --paper) on the nine controls that defined
  none and fell back to the UA blue. .searchbar keeps its border recolour as
  a resting cue but no longer stands in for a ring.
- Mono labels lift 10px -> 11px everywhere. The brief names night reading and
  glare as the usage scene; 10px small-caps was where taste overrode it.
- A card in flight past 2s says "Saving..." and carries aria-busy. htmx sets
  neither, so the wait up to its 15s timeout was silent in both channels.
- Titles clamp at 3 lines; .is-new .title takes width: fit-content, or
  -webkit-box stretches the ember underline past the text it sizes to.

/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
  assumed rows.
- The admin shell picks up the library's chrome: htmx 15s timeout, the shared
  #notice slot, #sr-announce, filter.js. admin.css follows the same pass.
- 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 guild id.

Handlers:
- maxChapterNum (9999) 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 inherited
  it. Matches the max on the card's chapter input.

go test ./... green.
2026-08-27 23:05:40 +07:00

20 KiB
Raw Permalink Blame History

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
backend/internal/web 27 40 1 2 2026-08-27T12-24-48Z 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 installed 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?