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,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"?