af07314bb6
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.
136 lines
20 KiB
Markdown
136 lines
20 KiB
Markdown
---
|
||
target: backend/internal/web
|
||
total_score: 27
|
||
max_score: 40
|
||
na_heuristics:
|
||
p0_count: 1
|
||
p1_count: 2
|
||
timestamp: 2026-08-27T12-24-48Z
|
||
slug: 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 install`ed 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?
|