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.
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