Files
mangaBookmark/.impeccable/critique/2026-08-27T07-53-05Z__backend-internal-web-templates-admin-html.md
sulthan 3a83161b1c Cinder pass across /admin, the login gate, and the library's a11y floor (#177)
One commit (`af07314`), three strands of browser-UI work against one design system. `docs/design-system.md` was updated to match the CSS, not the reverse.

## Library (Reader-facing)

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

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

## /admin

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

## Login

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

## Handlers

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

## Verification

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

## Note on shape

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

Reviewed-on: #177
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-08-27 23:09:42 +07:00

21 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 (admin surface) 22 40 1 2 2026-08-27T07-53-05Z 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?