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>
19 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 |
|---|---|---|---|---|---|---|---|
| backend/internal/web | 26 | 40 | 0 | 2 | 2026-08-27T14-36-34Z | 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 inadmin.cssis an issue number in a comment. - Ember/danger segregation holds. 32
--emberlines and 26--dangerlines instyle.css, no overlap;admin.cssuses--emberzero times. - 0
z-index, 0position: fixed, 2!important(both justified:[hidden]overridestyle.css:217, reduced-motion killstyle.css:1060). - 0 injection sinks.
filter.jswrites throughtextContentonly; notemplate.HTML/JS/URLanywhere 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, ~77aria-*attributes, all 4<img>intentionallyalt=""beside a text name, 0 icon-only controls without an accessible name, 19:focus-visiblerules,lang+ viewport +color-schemeon all three shells, oneprefers-reduced-motionblock.
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
- 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. - 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 atweb.go:474-476explicitly buys correctness with one extra read. - Confirm gates focus the safe button.
filter.js:155-157deliberately 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 setoverflow-x: autowithscrollbar-width: noneand 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 proximityon.recent-stripwithscroll-snap-align: startper.recent-card, plus a right-edgemask-imagegradient on both.recentand.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,28says "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:28naming 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:171vs185) — 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:592already announces "Removed